From 87eff1d6cef15537609b7afe9b95e6db666d0894 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 23 Jun 2026 23:24:14 +0800 Subject: [PATCH 01/14] feat(wallet): add home ad banners --- .../main/java/one/mixin/android/Constants.kt | 2 - .../android/api/response/WalletHomeBanner.kt | 88 ++++++++++ .../android/api/service/ReferralService.kt | 7 + .../java/one/mixin/android/di/AppModule.kt | 2 +- .../android/repository/ReferralRepository.kt | 28 ++++ .../android/ui/home/web3/Web3ViewModel.kt | 4 + .../android/ui/setting/LogAndDebugFragment.kt | 70 +++++--- .../mixin/android/ui/wallet/WalletFragment.kt | 18 +- .../ui/wallet/WalletHomeBannerActionTarget.kt | 34 ++++ .../ui/wallet/WalletHomeClassicFragment.kt | 129 +++++++++++++- .../ui/wallet/WalletHomePreferences.kt | 8 + .../ui/wallet/WalletHomePrivacyFragment.kt | 69 +++++++- .../android/ui/wallet/WalletViewModel.kt | 4 + .../android/ui/wallet/home/WalletHomeState.kt | 6 + .../ui/wallet/home/components/BannerCards.kt | 117 +++++++++++-- .../util/analytics/AnalyticsTracker.kt | 13 ++ app/src/main/res/drawable/ic_deafult.xml | 94 +++++++++++ .../main/res/layout/fragment_log_debug.xml | 12 ++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../api/response/WalletHomeBannerTest.kt | 54 ++++++ .../ui/conversation/PerpsTradeActionTest.kt | 45 +++++ .../ui/wallet/WalletHomePreferencesTest.kt | 19 +++ .../ui/wallet/home/WalletHomeBuilderTest.kt | 157 ++++++++++++++++++ 24 files changed, 937 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/api/response/WalletHomeBanner.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt create mode 100644 app/src/main/res/drawable/ic_deafult.xml create mode 100644 app/src/test/java/one/mixin/android/api/response/WalletHomeBannerTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 9796e1ec9e..3d4887dd03 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -536,13 +536,11 @@ object Constants { const val ROUTE_BOT_USER_ID = "61cb8dd4-16b1-4744-ba0c-7b2d2e52fc59" const val REFERRAL_BOT_USER_ID = "b35af74d-cca6-400c-a62b-5a7e659de91e" - const val SAFE_BOT_USER_ID = "b5418449-9ed6-4979-a690-82690949c542" const val ROUTE_BOT_URL = "https://api.route.mixin.one" const val REFERRAL_API_URL = "https://api.reward.mixin.one" - const val GOOGLE_PAY = "googlepay" const val PAYMENTS_ENVIRONMENT = WalletConstants.ENVIRONMENT_PRODUCTION diff --git a/app/src/main/java/one/mixin/android/api/response/WalletHomeBanner.kt b/app/src/main/java/one/mixin/android/api/response/WalletHomeBanner.kt new file mode 100644 index 0000000000..07d104c375 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/WalletHomeBanner.kt @@ -0,0 +1,88 @@ +package one.mixin.android.api.response + +import com.google.gson.annotations.SerializedName + +data class WalletHomeBanner( + @SerializedName(value = "banner_id", alternate = ["id"]) + val bannerId: String? = null, + @SerializedName("placement") + val placement: String? = null, + @SerializedName("lang") + val lang: String? = null, + @SerializedName("icon_url") + val iconUrl: String? = null, + @SerializedName("title") + val title: String? = null, + @SerializedName("description") + val description: String? = null, + @SerializedName("actions") + val actions: List? = emptyList(), + @SerializedName("action_url") + val actionUrl: String? = null, + @SerializedName("tracking_key") + val trackingKey: String? = null, + @SerializedName("status") + val status: String? = null, + @SerializedName("start_at") + val startAt: String? = null, + @SerializedName("end_at") + val endAt: String? = null, + @SerializedName("chains") + val chains: List? = emptyList(), + @SerializedName("priority") + val priority: Int = 0, + @SerializedName("created_at") + val createdAt: String? = null, + @SerializedName("updated_at") + val updatedAt: String? = null, +) { + val key: String + get() = bannerId.takeUnless(String?::isNullOrBlank) + ?: actionUrl.takeUnless(String?::isNullOrBlank) + ?: title.takeUnless(String?::isNullOrBlank) + ?: iconUrl.orEmpty() + + val hasVisualContent: Boolean + get() = !title.isNullOrBlank() || + !description.isNullOrBlank() || + visibleActions.isNotEmpty() || + !iconUrl.isNullOrBlank() + + val visibleActions: List + get() = actions.orEmpty() + .firstOrNull { !it.label.isNullOrBlank() && !it.action.isNullOrBlank() } + ?.let(::listOf) + .orEmpty() + + val hasButtonStyle: Boolean + get() = visibleActions.isNotEmpty() + + val isActive: Boolean + get() = status.isNullOrBlank() || status.equals(BANNER_STATUS_ACTIVE, ignoreCase = true) + + companion object { + const val BANNER_STATUS_ACTIVE = "active" + const val BANNER_STATUS_INACTIVE = "inactive" + } +} + +data class WalletHomeBannerAction( + @SerializedName("label") + val label: String? = null, + @SerializedName("action") + val action: String? = null, +) + +fun Set.syncedWalletHomeClosedBannerIds(remoteBanners: List): Set { + val remoteKeys = remoteBanners.mapNotNull { it.key.takeIf(String::isNotBlank) }.toSet() + return filter { remoteKeys.contains(it) }.toSet() +} + +fun List.visibleWalletHomeBanners(closedBannerIds: Set): List = + filter { banner -> + banner.key.isNotBlank() && + banner.isActive && + banner.hasVisualContent && + (!banner.actionUrl.isNullOrBlank() || banner.visibleActions.isNotEmpty()) && + !closedBannerIds.contains(banner.key) + }.sortedByDescending { it.priority } diff --git a/app/src/main/java/one/mixin/android/api/service/ReferralService.kt b/app/src/main/java/one/mixin/android/api/service/ReferralService.kt index cc5df11963..f1d42ed9e2 100644 --- a/app/src/main/java/one/mixin/android/api/service/ReferralService.kt +++ b/app/src/main/java/one/mixin/android/api/service/ReferralService.kt @@ -1,10 +1,17 @@ package one.mixin.android.api.service import one.mixin.android.api.MixinResponse +import one.mixin.android.api.response.WalletHomeBanner import one.mixin.android.api.response.referral.ReferralResponse import retrofit2.http.GET +import retrofit2.http.Query interface ReferralService { @GET("referral") suspend fun referral(): MixinResponse + + @GET("app-banners") + suspend fun walletHomeBanners( + @Query("chain") chains: List? = null, + ): MixinResponse> } diff --git a/app/src/main/java/one/mixin/android/di/AppModule.kt b/app/src/main/java/one/mixin/android/di/AppModule.kt index 2c3e07af43..79661bc904 100644 --- a/app/src/main/java/one/mixin/android/di/AppModule.kt +++ b/app/src/main/java/one/mixin/android/di/AppModule.kt @@ -531,7 +531,7 @@ object AppModule { val sourceRequest = chain.request() val b = sourceRequest.newBuilder() b.addHeader("User-Agent", API_UA) - .addHeader("Accept-Language", Locale.getDefault().language) + .addHeader("Accept-Language", Locale.getDefault().language + "-" + Locale.getDefault().country) .addHeader("Mixin-Device-Id", getStringDeviceId(resolver)) .addHeader(xRequestId, UUID.randomUUID().toString()) val botPublicKey = appContext.defaultSharedPreferences.getString(PREF_ROUTE_BOT_PK, null) diff --git a/app/src/main/java/one/mixin/android/repository/ReferralRepository.kt b/app/src/main/java/one/mixin/android/repository/ReferralRepository.kt index 3faf7e54af..a47cbac2ef 100644 --- a/app/src/main/java/one/mixin/android/repository/ReferralRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/ReferralRepository.kt @@ -4,6 +4,7 @@ import javax.inject.Inject import one.mixin.android.api.referral.ReferralShareInfo import one.mixin.android.api.referral.calculateReferralRebatePercentOrNull import one.mixin.android.api.referral.requestReferralMixinAPI +import one.mixin.android.api.response.WalletHomeBanner import one.mixin.android.api.response.referral.ReferralCode import one.mixin.android.api.response.referral.ReferralResponse import one.mixin.android.api.service.ReferralService @@ -88,6 +89,33 @@ class ReferralRepository requestSession = { userRepository.fetchSessionsSuspend(it) }, ) } + + suspend fun fetchWalletHomeBanners(chains: List = emptyList()): List { + runCatching { + userRepository.getBotPublicKey(REFERRAL_BOT_USER_ID, false) + }.onFailure { + Timber.w(it, "Failed to warm up referral bot session before fetching wallet home banners") + } + + return requestReferralMixinAPI( + invokeNetwork = { referralService.walletHomeBanners(chains.takeIf { it.isNotEmpty() }) }, + successBlock = { response -> response.data.orEmpty() }, + failureBlock = { response -> + Timber.d( + "Fetch wallet home banners failed code=%s message=%s", + response.errorCode, + response.errorDescription, + ) + true + }, + exceptionBlock = { + Timber.w(it, "Fetch wallet home banners failed") + true + }, + requestSession = { userRepository.fetchSessionsSuspend(it) }, + ).orEmpty() + } + } internal fun hasValidReferralMembership(membership: Membership?): Boolean = membership?.isMembership() == true diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt index 3a2aeeffc9..063a7f6fd6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt @@ -46,6 +46,7 @@ import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.job.MixinJobManager import one.mixin.android.job.SyncOutputJob import one.mixin.android.repository.AccountRepository +import one.mixin.android.repository.ReferralRepository import one.mixin.android.repository.TokenRepository import one.mixin.android.repository.UserRepository import one.mixin.android.repository.Web3Repository @@ -86,6 +87,7 @@ class Web3ViewModel @Inject constructor( private val userRepository: UserRepository, private val assetRepository: AssetRepository, private val tokenRepository: TokenRepository, + private val referralRepository: ReferralRepository, private val jobManager: MixinJobManager, private val web3Repository: Web3Repository, private val rpc: Rpc, @@ -97,6 +99,8 @@ class Web3ViewModel @Inject constructor( suspend fun findOrSyncApp(appId: String) = userRepository.findOrSyncApp(appId) + suspend fun walletHomeBanners(chains: List) = referralRepository.fetchWalletHomeBanners(chains) + suspend fun findMarketItemByAssetId(assetId: String) = tokenRepository.findMarketItemByAssetId(assetId) fun web3TokensExcludeHidden(walletId: String) = web3Repository.web3TokensExcludeHidden(walletId) diff --git a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt index a62f01d7e6..b04b7a7a46 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt @@ -39,6 +39,7 @@ import one.mixin.android.ui.home.web3.trade.perps.PREF_HIDE_SL_GUIDE_UNTIL import one.mixin.android.ui.home.web3.trade.perps.PREF_HIDE_TP_GUIDE_UNTIL import one.mixin.android.ui.setting.diagnosis.DiagnosisFragment import one.mixin.android.ui.wallet.PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED +import one.mixin.android.ui.wallet.PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED import one.mixin.android.ui.wallet.PREF_WALLET_HOME_REFERRAL_CLOSED import one.mixin.android.util.debug.FileLogTree import one.mixin.android.util.viewBinding @@ -143,10 +144,15 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { } resetTpslGuide.setOnClickListener { - resetDebugSharedPreferences() + resetHiddenDebugSharedPreferences() toast(R.string.Reset_TpSl_Guide) } + resetWalletHomeBanners.setOnClickListener { + resetWalletHomeBannerSharedPreferences() + toast(R.string.Reset_Wallet_Home_Banners) + } + deleteWeb3Transactions.setOnClickListener { context?.let { ctx -> alertDialogBuilder() @@ -240,36 +246,21 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { view.loadCaptchaWithoutFallback(captchaType) } - private fun resetDebugSharedPreferences() { + private fun resetHiddenDebugSharedPreferences() { val editor = defaultSharedPreferences.edit() - debugSharedPreferenceKeys().forEach { key -> + hiddenDebugSharedPreferenceKeys(Session.getAccountId()).forEach { key -> editor.remove(key) } editor.apply() } - private fun debugSharedPreferenceKeys(): List = - buildList { - add(PREF_HIDE_TP_GUIDE_UNTIL) - add(PREF_HIDE_SL_GUIDE_UNTIL) - add(TradeFragment.PREF_TRADE_SPOT_GUIDE_SHOWN) - add(TradeFragment.PREF_TRADE_PERPETUAL_GUIDE_SHOWN) - add(Constants.Account.PREF_GLOBAL_MARKET) - add(Constants.Account.PREF_MARKET_TYPE) - add(Constants.Account.PREF_MARKET_ORDER) - add(Constants.Account.PREF_MARKET_TOP_PERCENTAGE) - add(Constants.Account.PREF_HAS_USED_BUY) - add(Constants.Account.PREF_HAS_USED_SWAP) - add(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED) - add(PREF_WALLET_HOME_REFERRAL_CLOSED) - add(PREF_WALLET_HOME_CASHBACK_BANNER_CLOSED) - Session.getAccountId()?.let { accountId -> - add("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$accountId") - add("${Constants.Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED}_$accountId") - add("${Constants.Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED}_$accountId") - add("${Constants.Account.PREF_TRADE_PERPETUAL_ORDER_BADGE_DISMISSED}_$accountId") - } + private fun resetWalletHomeBannerSharedPreferences() { + val editor = defaultSharedPreferences.edit() + walletHomeBannerDebugSharedPreferenceKeys(defaultSharedPreferences.all.keys).forEach { key -> + editor.remove(key) } + editor.apply() + } private fun shareLogsFile() { val dialog = @@ -317,3 +308,34 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { } } } + +private fun hiddenDebugSharedPreferenceKeys(accountId: String?): List = + buildList { + add(PREF_HIDE_TP_GUIDE_UNTIL) + add(PREF_HIDE_SL_GUIDE_UNTIL) + add(TradeFragment.PREF_TRADE_SPOT_GUIDE_SHOWN) + add(TradeFragment.PREF_TRADE_PERPETUAL_GUIDE_SHOWN) + add(Constants.Account.PREF_GLOBAL_MARKET) + add(Constants.Account.PREF_MARKET_TYPE) + add(Constants.Account.PREF_MARKET_ORDER) + add(Constants.Account.PREF_MARKET_TOP_PERCENTAGE) + add(Constants.Account.PREF_HAS_USED_BUY) + add(Constants.Account.PREF_HAS_USED_SWAP) + accountId?.let { + add("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$it") + add("${Constants.Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED}_$it") + add("${Constants.Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED}_$it") + add("${Constants.Account.PREF_TRADE_PERPETUAL_ORDER_BADGE_DISMISSED}_$it") + } + } + +private fun walletHomeBannerDebugSharedPreferenceKeys(existingKeys: Set): Set = + buildSet { + add(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED) + add(PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED) + add(PREF_WALLET_HOME_REFERRAL_CLOSED) + add(PREF_WALLET_HOME_CASHBACK_BANNER_CLOSED) + existingKeys + .filter { it.startsWith("$PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED:") } + .forEach(::add) + } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletFragment.kt index 4a962a9194..633d7f4d13 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletFragment.kt @@ -484,12 +484,28 @@ class WalletFragment : BaseFragment(R.layout.fragment_wallet) { }?.let { wallet -> jobManager.addJobInBackground(RefreshSingleWalletJob(wallet)) } + refreshWalletHomeBanners() + } + + private fun refreshWalletHomeBanners() { + when (selectedWalletDestination) { + is WalletDestination.Privacy -> { + if (privacyWalletFragment.isAdded) privacyWalletFragment.refreshWalletHomeBanners() + } + is WalletDestination.Classic, + is WalletDestination.Import, + is WalletDestination.Watch, + is WalletDestination.Safe -> { + if (classicWalletFragment.isAdded) classicWalletFragment.refreshWalletHomeBanners() + } + null -> Unit + } } override fun onResume() { super.onResume() jobManager.addJobInBackground(RefreshSafeAccountsJob()) - if (classicWalletFragment.isVisible) classicWalletFragment.update() + update() } private fun closeMenu() { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt new file mode 100644 index 0000000000..48be72167a --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt @@ -0,0 +1,34 @@ +package one.mixin.android.ui.wallet + +import one.mixin.android.Constants +import one.mixin.android.extension.SpotTradeAction +import one.mixin.android.extension.toPerpsTradeAction +import one.mixin.android.extension.toSpotTradeAction + +internal sealed interface WalletHomeBannerActionTarget { + data class SpotTrade(val action: SpotTradeAction) : WalletHomeBannerActionTarget + data class PerpsMarket(val marketId: String) : WalletHomeBannerActionTarget + data object PerpsTab : WalletHomeBannerActionTarget + data object Buy : WalletHomeBannerActionTarget + data class Web(val url: String) : WalletHomeBannerActionTarget +} + +internal fun String.toClassicWalletHomeBannerActionTarget(): WalletHomeBannerActionTarget { + toSpotTradeAction()?.let { return WalletHomeBannerActionTarget.SpotTrade(it) } + toPerpsTradeAction()?.let { action -> + return when (val marketId = action.marketId) { + null -> WalletHomeBannerActionTarget.PerpsTab + else -> WalletHomeBannerActionTarget.PerpsMarket(marketId) + } + } + return if (isBuyAction()) { + WalletHomeBannerActionTarget.Buy + } else { + WalletHomeBannerActionTarget.Web(this) + } +} + +private fun String.isBuyAction(): Boolean = + startsWith(Constants.Scheme.BUY, true) || + startsWith(Constants.Scheme.MIXIN_BUY, true) || + startsWith(Constants.Scheme.HTTPS_BUY, true) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index e6884c26a7..8f2e7afef0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -30,6 +30,10 @@ import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.RxBus +import one.mixin.android.api.response.WalletHomeBanner +import one.mixin.android.api.response.WalletHomeBannerAction +import one.mixin.android.api.response.syncedWalletHomeClosedBannerIds +import one.mixin.android.api.response.visibleWalletHomeBanners import one.mixin.android.databinding.FragmentPrivacyWalletBinding import one.mixin.android.databinding.ViewWalletFragmentHeaderBinding import one.mixin.android.db.web3.vo.Web3TokenItem @@ -47,6 +51,8 @@ import one.mixin.android.extension.numberFormat2 import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.openUrl import one.mixin.android.extension.putBoolean +import one.mixin.android.extension.putInt +import one.mixin.android.extension.putStringSet import one.mixin.android.extension.toast import one.mixin.android.extension.withArgs import one.mixin.android.job.MixinJobManager @@ -62,6 +68,8 @@ import one.mixin.android.ui.home.bot.INTERNAL_REFERRAL_ID import one.mixin.android.ui.home.reminder.RecoveryReminderBottomSheetDialogFragment import one.mixin.android.ui.home.web3.Web3ViewModel import one.mixin.android.ui.home.web3.trade.SwapActivity +import one.mixin.android.ui.home.web3.trade.TradeFragment +import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity import one.mixin.android.ui.wallet.home.WalletHomeBuilder import one.mixin.android.ui.wallet.home.WalletHomeCallbacks import one.mixin.android.ui.wallet.home.WalletHomeDataState @@ -133,6 +141,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var pendingRawTransactionCount: Int = 0 private var pendingTransactionCount: Int = 0 private var watchAddresses: List = emptyList() + private var dynamicBanners: List = emptyList() private val assetsAdapter by lazy { WalletWeb3TokenAdapter(false) } private var distance = 0 @@ -155,6 +164,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) if (value != field) { field = value walletHomeDataState = WalletHomeDataState.EMPTY + dynamicBanners = emptyList() _walletId.value = value loadWalletHomeCache() } @@ -233,6 +243,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) super.onViewCreated(view, savedInstanceState) Timber.e("onViewCreated called in WalletHomeClassicFragment") refreshBitcoinPrice() + refreshWalletHomeBanners() binding.apply { _headBinding = @@ -487,7 +498,8 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) fiatRate = fiatRate, ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) - val showBanner = showAddWalletBanner + val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds()) + val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val currentImportKeyAction = importKeyAction val pendingCount = walletHomePendingTransactionCount(pendingRawTransactionCount, pendingTransactionCount) @@ -521,6 +533,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) watchIndicator = if (isWatchWallet) walletHomeWatchIndicator(watchAddresses) else null, importKeyAction = currentImportKeyAction, showAddWalletBanner = showAddWalletBanner, + dynamicBanners = visibleDynamicBanners, showReferralBanner = showReferral, showImportSafetyFooter = !isLoading, ) @@ -593,6 +606,33 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + fun refreshWalletHomeBanners() { + if (!isAdded || walletId.isEmpty()) return + lifecycleScope.launch { + val remoteBanners = web3ViewModel.walletHomeBanners(walletHomeBannerChains()) + syncClosedDynamicBannerIds(remoteBanners) + dynamicBanners = remoteBanners + renderHome() + } + } + + private suspend fun walletHomeBannerChains(): List = + web3ViewModel.getAddresses(walletId) + .map { it.chainId } + .filter(String::isNotBlank) + .distinct() + + private fun closedDynamicBannerIds(): Set = + defaultSharedPreferences.getStringSet(dynamicBannerClosedKey(), emptySet()).orEmpty() + + private fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = closedDynamicBannerIds() + val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) + if (syncedClosedBannerIds != closedBannerIds) { + defaultSharedPreferences.putStringSet(dynamicBannerClosedKey(), syncedClosedBannerIds) + } + } + private fun loadWalletHomeCache() { if (!isAdded || walletId.isEmpty()) return walletHomeDataState = WalletHomeDataState.EMPTY @@ -619,6 +659,9 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private fun classicWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.CLASSIC, walletId) + private fun dynamicBannerClosedKey(): String = + walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, walletId) + private val walletHomeCallbacks = object : WalletHomeCallbacks { override fun onAddWalletClicked() { AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) @@ -629,6 +672,34 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + override fun onDynamicBannerClicked(banner: WalletHomeBanner) { + AnalyticsTracker.trackWalletHomeAdBanner( + banner.trackingKey, + AnalyticsTracker.WalletHomeAdBannerSource.BACKGROUND, + ) + banner.actionUrl + ?.takeIf { it.isNotBlank() } + ?.let(::openClassicBannerAction) + } + + override fun onDynamicBannerActionClicked(banner: WalletHomeBanner, action: WalletHomeBannerAction) { + AnalyticsTracker.trackWalletHomeAdBanner( + banner.trackingKey, + AnalyticsTracker.WalletHomeAdBannerSource.BUTTON, + ) + action.action + ?.takeIf { it.isNotBlank() } + ?.let(::openClassicBannerAction) + } + + override fun onDynamicBannerClosed(banner: WalletHomeBanner) { + defaultSharedPreferences.putStringSet( + dynamicBannerClosedKey(), + closedDynamicBannerIds().toMutableSet().apply { add(banner.key) }, + ) + renderHome() + } + override fun onReferralClicked() { lifecycleScope.launch { web3ViewModel.findOrSyncApp(INTERNAL_REFERRAL_ID)?.let { app -> @@ -758,6 +829,60 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) override fun onTopMoverClicked(index: Int) = Unit } + private fun openClassicBannerAction(url: String) { + when (val target = url.toClassicWalletHomeBannerActionTarget()) { + is WalletHomeBannerActionTarget.SpotTrade -> { + val tab = if (target.action.openLimit) TradeFragment.TAB_ADVANCED else TradeFragment.TAB_SIMPLE + AnalyticsTracker.trackTradeStart(TradeWallet.WEB3, TradeSource.WALLET_HOME) + defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$walletId", tab) + SwapActivity.show( + requireActivity(), + target.action.input, + target.action.output, + target.action.amount, + target.action.referral, + inMixin = false, + walletId = walletId, + entrySource = TradeSource.WALLET_HOME, + entryType = if (target.action.openLimit) { + AnalyticsTracker.SpotTradeType.ADVANCED + } else { + AnalyticsTracker.SpotTradeType.SIMPLE + }, + initialTab = tab, + ) + } + is WalletHomeBannerActionTarget.PerpsMarket -> { + PerpsActivity.showDetail( + requireActivity(), + target.marketId, + "", + "", + "", + AnalyticsTracker.PerpsSource.WALLET_HOME, + ) + } + WalletHomeBannerActionTarget.PerpsTab -> { + AnalyticsTracker.trackTradeStart(TradeWallet.WEB3, TradeSource.WALLET_HOME) + defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$walletId", TradeFragment.TAB_PERPETUAL) + SwapActivity.show( + requireActivity(), + inMixin = false, + walletId = walletId, + entrySource = TradeSource.WALLET_HOME, + entryType = AnalyticsTracker.SpotTradeType.PERPETUAL, + initialTab = TradeFragment.TAB_PERPETUAL, + ) + } + WalletHomeBannerActionTarget.Buy -> { + WalletActivity.showBuy(requireActivity(), true, null, null, walletId) + } + is WalletHomeBannerActionTarget.Web -> { + WebActivity.show(requireActivity(), target.url, null) + } + } + } + private fun showPendingTransactions() { if (walletId.isNotEmpty()) { WalletActivity.show(requireActivity(), WalletActivity.Destination.AllWeb3Transactions(walletId = walletId), pendingType = true) @@ -797,6 +922,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) lifecycleScope.launch { refreshWalletHomeMetadata(walletId) } + refreshWalletHomeBanners() } refreshJob = PendingTransactionRefreshHelper.startRefreshData( fragment = this, @@ -815,6 +941,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) override fun onHiddenChanged(hidden: Boolean) { if (!hidden) { jobManager.addJobInBackground(RefreshSingleWalletJob(Web3Signer.currentWalletId)) + refreshWalletHomeBanners() } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt index 5a850f6b0a..280c0d2f3e 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt @@ -1,4 +1,12 @@ package one.mixin.android.ui.wallet +import one.mixin.android.ui.wallet.home.WalletHomeType + internal const val PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED = "pref_wallet_home_add_wallet_banner_closed" +internal const val PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED = "pref_wallet_home_dynamic_banner_closed" internal const val PREF_WALLET_HOME_REFERRAL_CLOSED = "pref_wallet_home_referral_closed" + +internal fun walletHomeDynamicBannerClosedKey( + walletType: WalletHomeType, + walletId: String, +) = "$PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED:${walletType.name}:$walletId" diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt index 64d4656637..780f9de4cf 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt @@ -32,6 +32,10 @@ import one.mixin.android.Constants.Account.PREF_HAS_USED_SWAP import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.api.handleMixinResponse +import one.mixin.android.api.response.WalletHomeBanner +import one.mixin.android.api.response.WalletHomeBannerAction +import one.mixin.android.api.response.syncedWalletHomeClosedBannerIds +import one.mixin.android.api.response.visibleWalletHomeBanners import one.mixin.android.databinding.FragmentPrivacyWalletBinding import one.mixin.android.databinding.ViewWalletFragmentHeaderBinding import one.mixin.android.event.BadgeEvent @@ -44,9 +48,11 @@ import one.mixin.android.extension.mainThread import one.mixin.android.extension.navTo import one.mixin.android.extension.numberFormat2 import one.mixin.android.extension.numberFormat8 +import one.mixin.android.extension.openAsUrlOrWeb import one.mixin.android.extension.openUrl import one.mixin.android.extension.toast import one.mixin.android.extension.putBoolean +import one.mixin.android.extension.putStringSet import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshSnapshotsJob import one.mixin.android.job.RefreshTokensJob @@ -131,6 +137,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var positions: List = emptyList() private var topMovers: List = emptyList() private var pendingDisplays: List = emptyList() + private var dynamicBanners: List = emptyList() private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() private var walletHomeDataState = WalletHomeDataState.EMPTY @@ -190,6 +197,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) Timber.e("onViewCreated called in WalletHomePrivacyFragment") _walletId.value = Session.getAccountId().orEmpty() refreshBitcoinPrice() + refreshWalletHomeBanners() binding.apply { _headBinding = @@ -393,7 +401,8 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) fiatRate = fiatRate, ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) - val showBanner = showAddWalletBanner + val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds()) + val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val cards = WalletHomeBuilder.build( walletType = WalletHomeType.PRIVACY, @@ -426,6 +435,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) pendingIndicator = pendingDisplays.toWalletHomePendingIndicator(), quoteColorReversed = defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false), showAddWalletBanner = showAddWalletBanner, + dynamicBanners = visibleDynamicBanners, showReferralBanner = showReferral, showBuyBadge = defaultSharedPreferences.getBoolean(PREF_HAS_USED_BUY, true), showSwapBadge = defaultSharedPreferences.getBoolean(PREF_HAS_USED_SWAP, true), @@ -459,10 +469,32 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) ?.takeIf { it > BigDecimal.ZERO } ?: bitcoinPriceUsd + fun refreshWalletHomeBanners() { + lifecycleScope.launch { + val remoteBanners = walletViewModel.walletHomeBanners() + syncClosedDynamicBannerIds(remoteBanners) + dynamicBanners = remoteBanners + renderHome() + } + } + + private fun closedDynamicBannerIds(): Set = + defaultSharedPreferences.getStringSet(dynamicBannerClosedKey(), emptySet()).orEmpty() + + private fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = closedDynamicBannerIds() + val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) + if (syncedClosedBannerIds != closedBannerIds) { + defaultSharedPreferences.putStringSet(dynamicBannerClosedKey(), syncedClosedBannerIds) + } + } private fun privacyWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.PRIVACY, Session.getAccountId().orEmpty()) + private fun dynamicBannerClosedKey(): String = + walletHomeDynamicBannerClosedKey(WalletHomeType.PRIVACY, Session.getAccountId().orEmpty()) + private fun List.toWalletHomePositionSummary(): WalletHomePositionSummary? { if (isEmpty()) return null val totalMargin = positionMarginUsdTotal() @@ -488,7 +520,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) private val walletHomeCallbacks = object : WalletHomeCallbacks { override fun onAddWalletClicked() { - AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) + showAddWalletDialog() } override fun onBannerClosed() { @@ -496,6 +528,34 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + override fun onDynamicBannerClicked(banner: WalletHomeBanner) { + AnalyticsTracker.trackWalletHomeAdBanner( + banner.trackingKey, + AnalyticsTracker.WalletHomeAdBannerSource.BACKGROUND, + ) + banner.actionUrl + ?.takeIf { it.isNotBlank() } + ?.openAsUrlOrWeb(requireActivity(), null, parentFragmentManager, lifecycleScope) + } + + override fun onDynamicBannerActionClicked(banner: WalletHomeBanner, action: WalletHomeBannerAction) { + AnalyticsTracker.trackWalletHomeAdBanner( + banner.trackingKey, + AnalyticsTracker.WalletHomeAdBannerSource.BUTTON, + ) + action.action + ?.takeIf { it.isNotBlank() } + ?.openAsUrlOrWeb(requireActivity(), null, parentFragmentManager, lifecycleScope) + } + + override fun onDynamicBannerClosed(banner: WalletHomeBanner) { + defaultSharedPreferences.putStringSet( + dynamicBannerClosedKey(), + closedDynamicBannerIds().toMutableSet().apply { add(banner.key) }, + ) + renderHome() + } + override fun onReferralClicked() { lifecycleScope.launch { walletViewModel.findOrSyncApp(INTERNAL_REFERRAL_ID)?.let { app -> @@ -666,6 +726,10 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } } + private fun showAddWalletDialog() { + AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) + } + override fun onResume() { super.onResume() _walletId.value = Session.getAccountId().orEmpty() @@ -686,6 +750,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) jobManager.addJobInBackground(RefreshSnapshotsJob()) jobManager.addJobInBackground(SyncOutputJob()) refreshAllPendingDeposit() + refreshWalletHomeBanners() } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt index a98474f311..73002de9d1 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt @@ -44,6 +44,7 @@ import one.mixin.android.job.RefreshTokensJob import one.mixin.android.job.RefreshTopAssetsJob import one.mixin.android.job.RefreshUserJob import one.mixin.android.repository.AccountRepository +import one.mixin.android.repository.ReferralRepository import one.mixin.android.repository.TokenRepository import one.mixin.android.repository.UserRepository import one.mixin.android.repository.Web3Repository @@ -71,6 +72,7 @@ internal constructor( private val accountRepository: AccountRepository, private val web3Repository: Web3Repository, private val tokenRepository: TokenRepository, + private val referralRepository: ReferralRepository, private val assetRepository: AssetRepository, private val jobManager: MixinJobManager, private val pinCipher: PinCipher, @@ -332,6 +334,8 @@ internal constructor( suspend fun profile(): MixinResponse = tokenRepository.profile() + suspend fun walletHomeBanners() = referralRepository.fetchWalletHomeBanners() + suspend fun fetchSessionsSuspend(ids: List) = userRepository.fetchSessionsSuspend(ids) suspend fun findBondBotUrl() = userRepository.findOrSyncApp(MIXIN_BOND_USER_ID) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt index 13709f9c3b..cce87436ff 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt @@ -3,6 +3,8 @@ package one.mixin.android.ui.wallet.home import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.R +import one.mixin.android.api.response.WalletHomeBanner +import one.mixin.android.api.response.WalletHomeBannerAction import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.Web3TransactionItem import one.mixin.android.extension.numberFormat8 @@ -38,6 +40,7 @@ data class WalletHomeState( val hideActions: Boolean = false, val quoteColorReversed: Boolean = false, val showAddWalletBanner: Boolean = false, + val dynamicBanners: List = emptyList(), val showReferralBanner: Boolean = false, val showBuyBadge: Boolean = false, val showSwapBadge: Boolean = false, @@ -61,6 +64,9 @@ data class WalletHomeBalanceSnapshot( interface WalletHomeCallbacks { fun onAddWalletClicked() fun onBannerClosed() + fun onDynamicBannerClicked(banner: WalletHomeBanner) = Unit + fun onDynamicBannerActionClicked(banner: WalletHomeBanner, action: WalletHomeBannerAction) = Unit + fun onDynamicBannerClosed(banner: WalletHomeBanner) = Unit fun onReferralClicked() fun onReferralClosed() fun onSupportClicked() diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 32e9a98e03..6e16e7f858 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -36,10 +36,14 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R +import one.mixin.android.api.response.WalletHomeBanner +import one.mixin.android.api.response.WalletHomeBannerAction +import one.mixin.android.compose.CoilImageCompat import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.ui.wallet.home.WalletHomeCallbacks @@ -51,9 +55,10 @@ internal fun BannerPager( state: WalletHomeState, callbacks: WalletHomeCallbacks, ) { - val pages = remember(state.showAddWalletBanner) { + val pages = remember(state.showAddWalletBanner, state.dynamicBanners) { buildList { - if (state.showAddWalletBanner) add(WalletHomeBannerPage.ADD_WALLET) + addAll(state.dynamicBanners.map(WalletHomeBannerPage::Dynamic)) + if (state.showAddWalletBanner) add(WalletHomeBannerPage.AddWallet) } } if (pages.isEmpty()) return @@ -70,7 +75,7 @@ internal fun BannerPager( .wrapContentHeight() .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor), ) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { HorizontalPager( state = pagerState, modifier = Modifier @@ -78,14 +83,19 @@ internal fun BannerPager( .wrapContentHeight(), verticalAlignment = Alignment.CenterVertically, ) { page -> - when (pages[page]) { - WalletHomeBannerPage.ADD_WALLET -> BannerCard( + when (val bannerPage = pages[page]) { + WalletHomeBannerPage.AddWallet -> BannerCard( iconRes = R.drawable.ic_wallet_home_add, titleRes = R.string.wallet_home_add_wallet_banner_title, descriptionRes = null, ctaRes = R.string.add_wallet, onClick = callbacks::onAddWalletClicked, ) + is WalletHomeBannerPage.Dynamic -> DynamicBannerCard( + banner = bannerPage.banner, + onClick = callbacks::onDynamicBannerClicked, + onActionClick = callbacks::onDynamicBannerActionClicked, + ) } } } @@ -98,8 +108,9 @@ internal fun BannerPager( .padding(top = 16.dp, end = 16.dp) .size(12.dp) .clickable { - when (pages.getOrNull(pagerState.currentPage)) { - WalletHomeBannerPage.ADD_WALLET -> callbacks.onBannerClosed() + when (val currentPage = pages.getOrNull(pagerState.currentPage)) { + WalletHomeBannerPage.AddWallet -> callbacks.onBannerClosed() + is WalletHomeBannerPage.Dynamic -> callbacks.onDynamicBannerClosed(currentPage.banner) null -> Unit } }, @@ -141,7 +152,7 @@ private fun BannerCard( Row( modifier = Modifier .fillMaxWidth() - .padding(end = 22.dp), + .padding(top = 20.dp, end = 22.dp, bottom = 20.dp), verticalAlignment = Alignment.Top, ) { Image( @@ -167,7 +178,82 @@ private fun BannerCard( ) } Spacer(modifier = Modifier.height(12.dp)) - BannerAction(textRes = ctaRes, onClick = onClick) + BannerAction(text = stringResource(ctaRes), onClick = onClick) + } + } +} + +@Composable +private fun DynamicBannerCard( + banner: WalletHomeBanner, + onClick: (WalletHomeBanner) -> Unit, + onActionClick: (WalletHomeBanner, WalletHomeBannerAction) -> Unit, +) { + val actions = banner.visibleActions + val iconShape = if (banner.hasButtonStyle) CircleShape else RoundedCornerShape(8.dp) + val bottomPadding = if (banner.hasButtonStyle) 20.dp else 22.dp + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp, end = 22.dp, bottom = bottomPadding) + .clickable { onClick(banner) }, + verticalAlignment = Alignment.Top, + ) { + val iconUrl = banner.iconUrl?.takeIf { it.isNotBlank() } + if (iconUrl != null) { + CoilImageCompat( + model = iconUrl, + placeholder = R.drawable.ic_deafult, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(42.dp) + .clip(iconShape), + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_deafult), + contentDescription = null, + modifier = Modifier + .size(42.dp) + .clip(iconShape), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = banner.title.orEmpty(), + color = MixinAppTheme.colors.textMinor, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W500, + ) + if (actions.isEmpty()) { + banner.description?.takeIf { it.isNotBlank() }?.let { description -> + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = description, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W400, + ) + } + } else { + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + actions.forEach { action -> + BannerAction( + text = action.label.orEmpty(), + onClick = { onActionClick(banner, action) }, + modifier = Modifier.weight(1f, fill = false), + fontWeight = FontWeight.W400, + ) + } + } + } } } } @@ -246,7 +332,7 @@ internal fun ReferralBannerCard(callbacks: WalletHomeCallbacks) { ) Spacer(modifier = Modifier.height(16.dp)) BannerAction( - textRes = R.string.Learn_More, + text = stringResource(R.string.Learn_More), primary = true, onClick = callbacks::onReferralClicked, modifier = Modifier @@ -264,7 +350,7 @@ internal fun ReferralBannerCard(callbacks: WalletHomeCallbacks) { @Composable private fun BannerAction( - textRes: Int, + text: String, primary: Boolean = false, onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -283,16 +369,19 @@ private fun BannerAction( contentAlignment = Alignment.Center, ) { Text( - text = stringResource(textRes), + text = text, color = if (primary) Color.White else MixinAppTheme.colors.accent, fontSize = 14.sp, fontWeight = fontWeight, lineHeight = lineHeight?.sp ?: 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) } } -private enum class WalletHomeBannerPage { - ADD_WALLET, +private sealed class WalletHomeBannerPage { + data object AddWallet : WalletHomeBannerPage() + data class Dynamic(val banner: WalletHomeBanner) : WalletHomeBannerPage() } diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index a7a118ee9e..50eb38397e 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -151,6 +151,19 @@ object AnalyticsTracker { } } + fun trackWalletHomeAdBanner(trackingKey: String?, source: String) { + val key = trackingKey?.takeIf { it.isNotBlank() } ?: return + logEvent("wallet_home_ad_banner") { + putString("tracking_key", key) + putString("source", source) + } + } + + object WalletHomeAdBannerSource { + const val BACKGROUND = "wallet_home_ad_banner_background" + const val BUTTON = "wallet_home_ad_banner_button" + } + object AssetSource { const val WALLET_HOME = "wallet_home" const val TOKEN_LIST = "token_list" diff --git a/app/src/main/res/drawable/ic_deafult.xml b/app/src/main/res/drawable/ic_deafult.xml new file mode 100644 index 0000000000..49120d3e2a --- /dev/null +++ b/app/src/main/res/drawable/ic_deafult.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_log_debug.xml b/app/src/main/res/layout/fragment_log_debug.xml index 75bbc02edc..280fd15734 100644 --- a/app/src/main/res/layout/fragment_log_debug.xml +++ b/app/src/main/res/layout/fragment_log_debug.xml @@ -189,6 +189,18 @@ android:foreground="?android:attr/selectableItemBackground" android:textSize="16sp" /> + + 所有资产 隐藏的资产不计入统计 重置隐藏偏好 + 重置钱包首页 Banner 删除 Web3 交易数据 确定要删除所有 Web3 交易数据吗?此操作不可恢复。删除后系统将重新同步数据。 预览手机号提醒弹窗 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8909430af1..8c00267e43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2037,6 +2037,7 @@ Total Balance Hidden assets are not included in the statistics. Reset hidden preferences + Reset wallet home banners Delete Web3 Transaction Data Are you sure you want to delete all Web3 transaction data? This action cannot be undone. The data will be re-synchronized after deletion. Preview verify mobile reminder diff --git a/app/src/test/java/one/mixin/android/api/response/WalletHomeBannerTest.kt b/app/src/test/java/one/mixin/android/api/response/WalletHomeBannerTest.kt new file mode 100644 index 0000000000..8a65c4a4e0 --- /dev/null +++ b/app/src/test/java/one/mixin/android/api/response/WalletHomeBannerTest.kt @@ -0,0 +1,54 @@ +package one.mixin.android.api.response + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WalletHomeBannerTest { + @Test + fun bannerWithActionsUsesButtonStyle() { + assertTrue( + WalletHomeBanner( + actions = listOf(WalletHomeBannerAction(label = "Claim", action = "mixin://buy")), + ).hasButtonStyle, + ) + } + + @Test + fun bannerWithoutActionsUsesNoButtonStyle() { + assertFalse(WalletHomeBanner().hasButtonStyle) + assertFalse( + WalletHomeBanner( + actions = listOf(WalletHomeBannerAction(label = "Claim", action = null)), + ).hasButtonStyle, + ) + } + + @Test + fun visibleActionsOnlyUsesFirstValidAction() { + val banner = WalletHomeBanner( + actions = listOf( + WalletHomeBannerAction(label = null, action = "mixin://skip"), + WalletHomeBannerAction(label = "First", action = "mixin://first"), + WalletHomeBannerAction(label = "Second", action = "mixin://second"), + ), + ) + + assertEquals(listOf("First"), banner.visibleActions.map { it.label }) + } + + @Test + fun syncClosedBannerIdsKeepsOnlyRemoteBannerKeys() { + val remoteBanners = listOf( + WalletHomeBanner(bannerId = "remote-1"), + WalletHomeBanner(bannerId = "remote-2"), + ) + + assertEquals( + setOf("remote-1"), + setOf("remote-1", "removed").syncedWalletHomeClosedBannerIds(remoteBanners), + ) + } + +} diff --git a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt index be14101de9..4a654ebf91 100644 --- a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt +++ b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt @@ -4,6 +4,8 @@ import one.mixin.android.extension.PerpsTradeAction import one.mixin.android.extension.SpotTradeAction import one.mixin.android.extension.toPerpsTradeAction import one.mixin.android.extension.toSpotTradeAction +import one.mixin.android.ui.wallet.WalletHomeBannerActionTarget +import one.mixin.android.ui.wallet.toClassicWalletHomeBannerActionTarget import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -85,4 +87,47 @@ class PerpsTradeActionTest { assertNull(action.toPerpsTradeAction()) assertNull(action.toSpotTradeAction()) } + + @Test + fun classicBannerActionParsesSpotTradeTarget() { + val action = "https://mixin.one/swap?input=43d61dcd-e413-450d-80b8-101d5e903357&output=c6d0c728-2624-429b-8e0d-d9d19b6592fa&amount=1.2&referral=7000" + + val target = action.toClassicWalletHomeBannerActionTarget() + + assertEquals( + WalletHomeBannerActionTarget.SpotTrade( + SpotTradeAction( + input = "43d61dcd-e413-450d-80b8-101d5e903357", + output = "c6d0c728-2624-429b-8e0d-d9d19b6592fa", + amount = "1.2", + referral = "7000", + openLimit = false, + ), + ), + target, + ) + } + + @Test + fun classicBannerActionParsesPerpsAndBuyTargets() { + assertEquals( + WalletHomeBannerActionTarget.PerpsMarket("e015f42e-b0ff-38e7-87b1-7e8d46fea119"), + "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119".toClassicWalletHomeBannerActionTarget(), + ) + assertEquals( + WalletHomeBannerActionTarget.PerpsTab, + "https://mixin.one/trade?type=perps".toClassicWalletHomeBannerActionTarget(), + ) + assertEquals( + WalletHomeBannerActionTarget.Buy, + "https://mixin.one/buy".toClassicWalletHomeBannerActionTarget(), + ) + } + + @Test + fun classicBannerActionFallsBackToWebTarget() { + val url = "https://mixin.one/users/41d16c28-0c3a-493d-a2b4-b57875371abf" + + assertEquals(WalletHomeBannerActionTarget.Web(url), url.toClassicWalletHomeBannerActionTarget()) + } } diff --git a/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt new file mode 100644 index 0000000000..7e012bf1bf --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt @@ -0,0 +1,19 @@ +package one.mixin.android.ui.wallet + +import one.mixin.android.ui.wallet.home.WalletHomeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class WalletHomePreferencesTest { + @Test + fun dynamicBannerClosedKeyIsScopedByWalletTypeAndWalletId() { + val classicWalletA = walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, "wallet-a") + val classicWalletB = walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, "wallet-b") + val privacyWalletA = walletHomeDynamicBannerClosedKey(WalletHomeType.PRIVACY, "wallet-a") + + assertEquals("pref_wallet_home_dynamic_banner_closed:CLASSIC:wallet-a", classicWalletA) + assertNotEquals(classicWalletA, classicWalletB) + assertNotEquals(classicWalletA, privacyWalletA) + } +} diff --git a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeBuilderTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeBuilderTest.kt index 673a7d63ac..eb6c1c4e97 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeBuilderTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeBuilderTest.kt @@ -1,5 +1,8 @@ package one.mixin.android.ui.wallet.home +import one.mixin.android.api.response.WalletHomeBanner +import one.mixin.android.api.response.WalletHomeBannerAction +import one.mixin.android.api.response.visibleWalletHomeBanners import org.junit.Assert.assertEquals import org.junit.Test @@ -74,4 +77,158 @@ class WalletHomeBuilderTest { cards, ) } + + @Test + fun visibleDynamicBannerShowsBannerCard() { + val banners = listOf( + WalletHomeBanner( + bannerId = "banner-1", + title = "Promo", + actionUrl = "https://example.com", + status = "active", + ), + ).visibleWalletHomeBanners(closedBannerIds = emptySet()) + + val cards = WalletHomeBuilder.build( + walletType = WalletHomeType.PRIVACY, + hasAssetValue = true, + showBanner = banners.isNotEmpty(), + showReferral = false, + hasPositions = false, + hasTopMovers = false, + hasTransactions = false, + ) + + assertEquals( + listOf( + WalletHomeCardType.BALANCE, + WalletHomeCardType.BANNER, + WalletHomeCardType.TOKENS, + WalletHomeCardType.SUPPORT, + ), + cards, + ) + } + + @Test + fun classicWalletShowsDynamicBannerCard() { + val cards = WalletHomeBuilder.build( + walletType = WalletHomeType.CLASSIC, + hasAssetValue = true, + showBanner = true, + showReferral = false, + hasPositions = false, + hasTopMovers = false, + hasTransactions = false, + ) + + assertEquals( + listOf( + WalletHomeCardType.BALANCE, + WalletHomeCardType.BANNER, + WalletHomeCardType.TOKENS, + WalletHomeCardType.SUPPORT, + ), + cards, + ) + } + + @Test + fun closedDynamicBannerDoesNotShowBannerCard() { + val banners = listOf( + WalletHomeBanner( + bannerId = "banner-1", + title = "Promo", + actionUrl = "https://example.com", + status = "active", + ), + ).visibleWalletHomeBanners(closedBannerIds = setOf("banner-1")) + + val cards = WalletHomeBuilder.build( + walletType = WalletHomeType.PRIVACY, + hasAssetValue = true, + showBanner = banners.isNotEmpty(), + showReferral = false, + hasPositions = false, + hasTopMovers = false, + hasTransactions = false, + ) + + assertEquals( + listOf( + WalletHomeCardType.BALANCE, + WalletHomeCardType.TOKENS, + WalletHomeCardType.SUPPORT, + ), + cards, + ) + } + + @Test + fun visibleDynamicBannersPreferPriorityAndIgnoreInactive() { + val banners = listOf( + WalletHomeBanner( + bannerId = "low", + title = "Low", + actionUrl = "https://example.com/low", + status = "active", + priority = 1, + ), + WalletHomeBanner( + bannerId = "inactive", + title = "Inactive", + actionUrl = "https://example.com/inactive", + status = "inactive", + priority = 100, + ), + WalletHomeBanner( + bannerId = "closed", + title = "Closed", + actionUrl = "https://example.com/closed", + status = "active", + priority = 90, + ), + WalletHomeBanner( + bannerId = "high", + title = "High", + actionUrl = "https://example.com/high", + status = "active", + priority = 10, + ), + ).visibleWalletHomeBanners(closedBannerIds = setOf("closed")) + + assertEquals(listOf("high", "low"), banners.map { it.bannerId }) + } + + @Test + fun dynamicBannerWithActionsShowsBannerCard() { + val banners = listOf( + WalletHomeBanner( + bannerId = "banner-1", + title = "Promo", + status = "active", + actions = listOf(WalletHomeBannerAction(label = "Claim", action = "mixin://buy")), + ), + ).visibleWalletHomeBanners(closedBannerIds = emptySet()) + + val cards = WalletHomeBuilder.build( + walletType = WalletHomeType.PRIVACY, + hasAssetValue = true, + showBanner = banners.isNotEmpty(), + showReferral = false, + hasPositions = false, + hasTopMovers = false, + hasTransactions = false, + ) + + assertEquals( + listOf( + WalletHomeCardType.BALANCE, + WalletHomeCardType.BANNER, + WalletHomeCardType.TOKENS, + WalletHomeCardType.SUPPORT, + ), + cards, + ) + } } From 6a74fc44e59f36b50675c059c142487fee6e0d71 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 15:45:31 +0800 Subject: [PATCH 02/14] fix(wallet): persist home banner dismissal globally --- .../android/ui/setting/LogAndDebugFragment.kt | 11 +++-- .../ui/wallet/WalletHomeClassicFragment.kt | 42 ++++++++----------- .../ui/wallet/WalletHomePreferences.kt | 32 +++++++++++--- .../ui/wallet/WalletHomePrivacyFragment.kt | 28 ++++++------- .../ui/wallet/home/components/BannerCards.kt | 7 ++-- .../ui/wallet/WalletHomePreferencesTest.kt | 15 +++---- 6 files changed, 73 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt index b04b7a7a46..1d6b0e9f87 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt @@ -19,6 +19,7 @@ import one.mixin.android.R import one.mixin.android.databinding.FragmentLogDebugBinding import one.mixin.android.databinding.ViewCaptchaPreviewBottomBinding import one.mixin.android.db.DatabaseMonitor +import one.mixin.android.db.property.PropertyHelper.deleteKeyValue import one.mixin.android.db.property.PropertyHelper.findValueByKey import one.mixin.android.db.property.PropertyHelper.updateKeyValue import one.mixin.android.extension.alertDialogBuilder @@ -149,8 +150,10 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { } resetWalletHomeBanners.setOnClickListener { - resetWalletHomeBannerSharedPreferences() - toast(R.string.Reset_Wallet_Home_Banners) + lifecycleScope.launch { + resetWalletHomeBannerLocalState() + toast(R.string.Reset_Wallet_Home_Banners) + } } deleteWeb3Transactions.setOnClickListener { @@ -254,12 +257,13 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { editor.apply() } - private fun resetWalletHomeBannerSharedPreferences() { + private suspend fun resetWalletHomeBannerLocalState() { val editor = defaultSharedPreferences.edit() walletHomeBannerDebugSharedPreferenceKeys(defaultSharedPreferences.all.keys).forEach { key -> editor.remove(key) } editor.apply() + deleteKeyValue(PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED) } private fun shareLogsFile() { @@ -335,6 +339,7 @@ private fun walletHomeBannerDebugSharedPreferenceKeys(existingKeys: Set) add(PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED) add(PREF_WALLET_HOME_REFERRAL_CLOSED) add(PREF_WALLET_HOME_CASHBACK_BANNER_CLOSED) + add(Constants.Account.PREF_HAS_USED_ADD_WALLET) existingKeys .filter { it.startsWith("$PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED:") } .forEach(::add) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index 8f2e7afef0..efba4ff8f3 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -52,7 +52,6 @@ import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.openUrl import one.mixin.android.extension.putBoolean import one.mixin.android.extension.putInt -import one.mixin.android.extension.putStringSet import one.mixin.android.extension.toast import one.mixin.android.extension.withArgs import one.mixin.android.job.MixinJobManager @@ -142,6 +141,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var pendingTransactionCount: Int = 0 private var watchAddresses: List = emptyList() private var dynamicBanners: List = emptyList() + private var closedDynamicBannerIds: Set = emptySet() private val assetsAdapter by lazy { WalletWeb3TokenAdapter(false) } private var distance = 0 @@ -498,7 +498,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) fiatRate = fiatRate, ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) - val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds()) + val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds) val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val currentImportKeyAction = importKeyAction @@ -622,15 +622,13 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) .filter(String::isNotBlank) .distinct() - private fun closedDynamicBannerIds(): Set = - defaultSharedPreferences.getStringSet(dynamicBannerClosedKey(), emptySet()).orEmpty() - - private fun syncClosedDynamicBannerIds(remoteBanners: List) { - val closedBannerIds = closedDynamicBannerIds() + private suspend fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = findWalletHomeDynamicBannerClosedIds() val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) if (syncedClosedBannerIds != closedBannerIds) { - defaultSharedPreferences.putStringSet(dynamicBannerClosedKey(), syncedClosedBannerIds) + updateWalletHomeDynamicBannerClosedIds(syncedClosedBannerIds) } + closedDynamicBannerIds = syncedClosedBannerIds } private fun loadWalletHomeCache() { @@ -659,9 +657,6 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private fun classicWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.CLASSIC, walletId) - private fun dynamicBannerClosedKey(): String = - walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, walletId) - private val walletHomeCallbacks = object : WalletHomeCallbacks { override fun onAddWalletClicked() { AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) @@ -693,11 +688,12 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) } override fun onDynamicBannerClosed(banner: WalletHomeBanner) { - defaultSharedPreferences.putStringSet( - dynamicBannerClosedKey(), - closedDynamicBannerIds().toMutableSet().apply { add(banner.key) }, - ) - renderHome() + lifecycleScope.launch { + val closedIds = closedDynamicBannerIds.toMutableSet().apply { add(banner.key) } + updateWalletHomeDynamicBannerClosedIds(closedIds) + closedDynamicBannerIds = closedIds + renderHome() + } } override fun onReferralClicked() { @@ -833,16 +829,14 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) when (val target = url.toClassicWalletHomeBannerActionTarget()) { is WalletHomeBannerActionTarget.SpotTrade -> { val tab = if (target.action.openLimit) TradeFragment.TAB_ADVANCED else TradeFragment.TAB_SIMPLE - AnalyticsTracker.trackTradeStart(TradeWallet.WEB3, TradeSource.WALLET_HOME) - defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$walletId", tab) + AnalyticsTracker.trackTradeStart(TradeWallet.MAIN, TradeSource.WALLET_HOME) + defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}${Session.getAccountId().orEmpty()}", tab) SwapActivity.show( requireActivity(), target.action.input, target.action.output, target.action.amount, target.action.referral, - inMixin = false, - walletId = walletId, entrySource = TradeSource.WALLET_HOME, entryType = if (target.action.openLimit) { AnalyticsTracker.SpotTradeType.ADVANCED @@ -863,19 +857,17 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) ) } WalletHomeBannerActionTarget.PerpsTab -> { - AnalyticsTracker.trackTradeStart(TradeWallet.WEB3, TradeSource.WALLET_HOME) - defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}$walletId", TradeFragment.TAB_PERPETUAL) + AnalyticsTracker.trackTradeStart(TradeWallet.MAIN, TradeSource.WALLET_HOME) + defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}${Session.getAccountId().orEmpty()}", TradeFragment.TAB_PERPETUAL) SwapActivity.show( requireActivity(), - inMixin = false, - walletId = walletId, entrySource = TradeSource.WALLET_HOME, entryType = AnalyticsTracker.SpotTradeType.PERPETUAL, initialTab = TradeFragment.TAB_PERPETUAL, ) } WalletHomeBannerActionTarget.Buy -> { - WalletActivity.showBuy(requireActivity(), true, null, null, walletId) + WalletActivity.showBuy(requireActivity(), false, null, null) } is WalletHomeBannerActionTarget.Web -> { WebActivity.show(requireActivity(), target.url, null) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt index 280c0d2f3e..52a216dbd4 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePreferences.kt @@ -1,12 +1,34 @@ package one.mixin.android.ui.wallet -import one.mixin.android.ui.wallet.home.WalletHomeType +import com.google.gson.Gson +import one.mixin.android.db.property.PropertyHelper internal const val PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED = "pref_wallet_home_add_wallet_banner_closed" internal const val PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED = "pref_wallet_home_dynamic_banner_closed" internal const val PREF_WALLET_HOME_REFERRAL_CLOSED = "pref_wallet_home_referral_closed" -internal fun walletHomeDynamicBannerClosedKey( - walletType: WalletHomeType, - walletId: String, -) = "$PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED:${walletType.name}:$walletId" +private val walletHomePreferencesGson = Gson() + +internal fun walletHomeDynamicBannerClosedKey() = PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED + +internal suspend fun findWalletHomeDynamicBannerClosedIds(): Set { + val value = PropertyHelper.findValueByKey(walletHomeDynamicBannerClosedKey(), "") + if (value.isBlank()) return emptySet() + return runCatching { + walletHomePreferencesGson.fromJson(value, Array::class.java) + .orEmpty() + .filter(String::isNotBlank) + .toSet() + }.getOrDefault(emptySet()) +} + +internal suspend fun updateWalletHomeDynamicBannerClosedIds(closedIds: Set) { + if (closedIds.isEmpty()) { + PropertyHelper.deleteKeyValue(walletHomeDynamicBannerClosedKey()) + } else { + PropertyHelper.updateKeyValue( + walletHomeDynamicBannerClosedKey(), + walletHomePreferencesGson.toJson(closedIds.sorted()), + ) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt index 780f9de4cf..785ae2fcec 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt @@ -52,7 +52,6 @@ import one.mixin.android.extension.openAsUrlOrWeb import one.mixin.android.extension.openUrl import one.mixin.android.extension.toast import one.mixin.android.extension.putBoolean -import one.mixin.android.extension.putStringSet import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshSnapshotsJob import one.mixin.android.job.RefreshTokensJob @@ -138,6 +137,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var topMovers: List = emptyList() private var pendingDisplays: List = emptyList() private var dynamicBanners: List = emptyList() + private var closedDynamicBannerIds: Set = emptySet() private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() private var walletHomeDataState = WalletHomeDataState.EMPTY @@ -401,7 +401,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) fiatRate = fiatRate, ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) - val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds()) + val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds) val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val cards = WalletHomeBuilder.build( @@ -478,23 +478,18 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } } - private fun closedDynamicBannerIds(): Set = - defaultSharedPreferences.getStringSet(dynamicBannerClosedKey(), emptySet()).orEmpty() - - private fun syncClosedDynamicBannerIds(remoteBanners: List) { - val closedBannerIds = closedDynamicBannerIds() + private suspend fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = findWalletHomeDynamicBannerClosedIds() val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) if (syncedClosedBannerIds != closedBannerIds) { - defaultSharedPreferences.putStringSet(dynamicBannerClosedKey(), syncedClosedBannerIds) + updateWalletHomeDynamicBannerClosedIds(syncedClosedBannerIds) } + closedDynamicBannerIds = syncedClosedBannerIds } private fun privacyWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.PRIVACY, Session.getAccountId().orEmpty()) - private fun dynamicBannerClosedKey(): String = - walletHomeDynamicBannerClosedKey(WalletHomeType.PRIVACY, Session.getAccountId().orEmpty()) - private fun List.toWalletHomePositionSummary(): WalletHomePositionSummary? { if (isEmpty()) return null val totalMargin = positionMarginUsdTotal() @@ -549,11 +544,12 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } override fun onDynamicBannerClosed(banner: WalletHomeBanner) { - defaultSharedPreferences.putStringSet( - dynamicBannerClosedKey(), - closedDynamicBannerIds().toMutableSet().apply { add(banner.key) }, - ) - renderHome() + lifecycleScope.launch { + val closedIds = closedDynamicBannerIds.toMutableSet().apply { add(banner.key) } + updateWalletHomeDynamicBannerClosedIds(closedIds) + closedDynamicBannerIds = closedIds + renderHome() + } } override fun onReferralClicked() { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 6e16e7f858..4ea1034ad2 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -190,6 +190,8 @@ private fun DynamicBannerCard( onActionClick: (WalletHomeBanner, WalletHomeBannerAction) -> Unit, ) { val actions = banner.visibleActions + val hasDescription = actions.isEmpty() && banner.description?.isNotBlank() == true + val titleOnly = actions.isEmpty() && !hasDescription val iconShape = if (banner.hasButtonStyle) CircleShape else RoundedCornerShape(8.dp) val bottomPadding = if (banner.hasButtonStyle) 20.dp else 22.dp Row( @@ -223,9 +225,9 @@ private fun DynamicBannerCard( Text( text = banner.title.orEmpty(), color = MixinAppTheme.colors.textMinor, - fontSize = 16.sp, + fontSize = if (titleOnly) 14.sp else 16.sp, lineHeight = 20.sp, - fontWeight = FontWeight.W500, + fontWeight = if (titleOnly) FontWeight.W400 else FontWeight.W500, ) if (actions.isEmpty()) { banner.description?.takeIf { it.isNotBlank() }?.let { description -> @@ -249,7 +251,6 @@ private fun DynamicBannerCard( text = action.label.orEmpty(), onClick = { onActionClick(banner, action) }, modifier = Modifier.weight(1f, fill = false), - fontWeight = FontWeight.W400, ) } } diff --git a/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt index 7e012bf1bf..b6652cba18 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt @@ -1,19 +1,14 @@ package one.mixin.android.ui.wallet -import one.mixin.android.ui.wallet.home.WalletHomeType import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals import org.junit.Test class WalletHomePreferencesTest { @Test - fun dynamicBannerClosedKeyIsScopedByWalletTypeAndWalletId() { - val classicWalletA = walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, "wallet-a") - val classicWalletB = walletHomeDynamicBannerClosedKey(WalletHomeType.CLASSIC, "wallet-b") - val privacyWalletA = walletHomeDynamicBannerClosedKey(WalletHomeType.PRIVACY, "wallet-a") - - assertEquals("pref_wallet_home_dynamic_banner_closed:CLASSIC:wallet-a", classicWalletA) - assertNotEquals(classicWalletA, classicWalletB) - assertNotEquals(classicWalletA, privacyWalletA) + fun dynamicBannerClosedKeyIsGlobal() { + assertEquals( + PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED, + walletHomeDynamicBannerClosedKey(), + ) } } From 78b4d1793599776ce7b90b32b486aae92760e3d9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 15:48:22 +0800 Subject: [PATCH 03/14] fix(analytics): track wallet home banner by key --- .../java/one/mixin/android/util/analytics/AnalyticsTracker.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index 50eb38397e..e5bde80861 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -153,8 +153,7 @@ object AnalyticsTracker { fun trackWalletHomeAdBanner(trackingKey: String?, source: String) { val key = trackingKey?.takeIf { it.isNotBlank() } ?: return - logEvent("wallet_home_ad_banner") { - putString("tracking_key", key) + logEvent(trackingKey) { putString("source", source) } } From 14614a561c5f933821a6ecaa193f29b66faaac5d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 15:58:32 +0800 Subject: [PATCH 04/14] fix(wallet): handle dynamic banner title only style --- .../ui/wallet/home/components/BannerCards.kt | 15 +-- app/src/main/res/drawable/ic_deafult.xml | 94 ------------------- 2 files changed, 8 insertions(+), 101 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_deafult.xml diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 4ea1034ad2..d68cc48d87 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -190,8 +190,9 @@ private fun DynamicBannerCard( onActionClick: (WalletHomeBanner, WalletHomeBannerAction) -> Unit, ) { val actions = banner.visibleActions - val hasDescription = actions.isEmpty() && banner.description?.isNotBlank() == true - val titleOnly = actions.isEmpty() && !hasDescription + val description = banner.description?.takeIf { it.isNotBlank() } + val showDescription = actions.isEmpty() && description != null + val titleOnly = !showDescription val iconShape = if (banner.hasButtonStyle) CircleShape else RoundedCornerShape(8.dp) val bottomPadding = if (banner.hasButtonStyle) 20.dp else 22.dp Row( @@ -205,7 +206,7 @@ private fun DynamicBannerCard( if (iconUrl != null) { CoilImageCompat( model = iconUrl, - placeholder = R.drawable.ic_deafult, + placeholder = R.drawable.ic_avatar_place_holder, contentScale = ContentScale.Crop, modifier = Modifier .size(42.dp) @@ -213,7 +214,7 @@ private fun DynamicBannerCard( ) } else { Image( - painter = painterResource(id = R.drawable.ic_deafult), + painter = painterResource(id = R.drawable.ic_avatar_place_holder), contentDescription = null, modifier = Modifier .size(42.dp) @@ -229,8 +230,8 @@ private fun DynamicBannerCard( lineHeight = 20.sp, fontWeight = if (titleOnly) FontWeight.W400 else FontWeight.W500, ) - if (actions.isEmpty()) { - banner.description?.takeIf { it.isNotBlank() }?.let { description -> + if (showDescription) { + description.let { description -> Spacer(modifier = Modifier.height(6.dp)) Text( text = description, @@ -240,7 +241,7 @@ private fun DynamicBannerCard( fontWeight = FontWeight.W400, ) } - } else { + } else if (actions.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/res/drawable/ic_deafult.xml b/app/src/main/res/drawable/ic_deafult.xml deleted file mode 100644 index 49120d3e2a..0000000000 --- a/app/src/main/res/drawable/ic_deafult.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 9e16da73f94a8363032697d417519d7c11cf052c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 16:05:48 +0800 Subject: [PATCH 05/14] fix(wallet): animate banner height changes --- .../one/mixin/android/ui/wallet/home/components/BannerCards.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index d68cc48d87..5fa4d311dd 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -1,5 +1,7 @@ package one.mixin.android.ui.wallet.home.components +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -73,6 +75,7 @@ internal fun BannerPager( .fillMaxWidth() .padding(horizontal = 20.dp) .wrapContentHeight() + .animateContentSize(animationSpec = tween(durationMillis = 200)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor), ) { Column(modifier = Modifier.padding(horizontal = 20.dp)) { From d91cbd436338738d0931d4dc40b3f1b00564c446 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 16:29:03 +0800 Subject: [PATCH 06/14] fix(wallet): improve home ad banner clicks --- .../ui/wallet/home/components/BannerCards.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 5fa4d311dd..4ba6a9fc57 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -70,13 +70,28 @@ internal fun BannerPager( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { + val currentPage = pages.getOrNull(pagerState.currentPage) + val cardClickModifier = + when (currentPage) { + WalletHomeBannerPage.AddWallet -> Modifier.clickable { callbacks.onAddWalletClicked() } + is WalletHomeBannerPage.Dynamic -> { + if (currentPage.banner.actionUrl.isNullOrBlank()) { + Modifier + } else { + Modifier.clickable { callbacks.onDynamicBannerClicked(currentPage.banner) } + } + } + null -> Modifier + } Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp) .wrapContentHeight() .animateContentSize(animationSpec = tween(durationMillis = 200)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor), + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .then(cardClickModifier), ) { Column(modifier = Modifier.padding(horizontal = 20.dp)) { HorizontalPager( @@ -96,7 +111,6 @@ internal fun BannerPager( ) is WalletHomeBannerPage.Dynamic -> DynamicBannerCard( banner = bannerPage.banner, - onClick = callbacks::onDynamicBannerClicked, onActionClick = callbacks::onDynamicBannerActionClicked, ) } @@ -189,7 +203,6 @@ private fun BannerCard( @Composable private fun DynamicBannerCard( banner: WalletHomeBanner, - onClick: (WalletHomeBanner) -> Unit, onActionClick: (WalletHomeBanner, WalletHomeBannerAction) -> Unit, ) { val actions = banner.visibleActions @@ -201,8 +214,7 @@ private fun DynamicBannerCard( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 22.dp, end = 22.dp, bottom = bottomPadding) - .clickable { onClick(banner) }, + .padding(top = 22.dp, end = 22.dp, bottom = bottomPadding), verticalAlignment = Alignment.Top, ) { val iconUrl = banner.iconUrl?.takeIf { it.isNotBlank() } @@ -235,7 +247,7 @@ private fun DynamicBannerCard( ) if (showDescription) { description.let { description -> - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(4.dp)) Text( text = description, color = MixinAppTheme.colors.textAssist, From ae08a618adc897feac580a842213eb4f118a734c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 20:15:29 +0800 Subject: [PATCH 07/14] fix(wallet): preserve banner icon shape --- .../android/ui/wallet/home/components/BannerCards.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 4ba6a9fc57..80f0ce18fb 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -209,7 +209,6 @@ private fun DynamicBannerCard( val description = banner.description?.takeIf { it.isNotBlank() } val showDescription = actions.isEmpty() && description != null val titleOnly = !showDescription - val iconShape = if (banner.hasButtonStyle) CircleShape else RoundedCornerShape(8.dp) val bottomPadding = if (banner.hasButtonStyle) 20.dp else 22.dp Row( modifier = Modifier @@ -223,17 +222,13 @@ private fun DynamicBannerCard( model = iconUrl, placeholder = R.drawable.ic_avatar_place_holder, contentScale = ContentScale.Crop, - modifier = Modifier - .size(42.dp) - .clip(iconShape), + modifier = Modifier.size(42.dp), ) } else { Image( painter = painterResource(id = R.drawable.ic_avatar_place_holder), contentDescription = null, - modifier = Modifier - .size(42.dp) - .clip(iconShape), + modifier = Modifier.size(42.dp), ) } Spacer(modifier = Modifier.width(14.dp)) From 35133d396d823ba4a127ad1ce9922c63f2837642 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 21:38:51 +0800 Subject: [PATCH 08/14] fix(wallet): smooth banner rotation timing --- .../ui/wallet/home/components/BannerCards.kt | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 80f0ce18fb..8943372e76 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -26,7 +26,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import one.mixin.android.R import one.mixin.android.api.response.WalletHomeBanner import one.mixin.android.api.response.WalletHomeBannerAction @@ -51,20 +56,51 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.ui.wallet.home.WalletHomeCallbacks import one.mixin.android.ui.wallet.home.WalletHomeState +private const val LOCAL_BANNER_SHOW_DELAY_MILLIS = 2_000L +private const val BANNER_AUTO_SWITCH_DELAY_MILLIS = 3_000L + @OptIn(ExperimentalFoundationApi::class) @Composable internal fun BannerPager( state: WalletHomeState, callbacks: WalletHomeCallbacks, ) { - val pages = remember(state.showAddWalletBanner, state.dynamicBanners) { + var showLocalBanner by remember(state.showAddWalletBanner, state.dynamicBanners) { + mutableStateOf(!state.showAddWalletBanner || state.dynamicBanners.isNotEmpty()) + } + var showLocalBannerFirst by remember(state.showAddWalletBanner) { + mutableStateOf(false) + } + LaunchedEffect(state.showAddWalletBanner, state.dynamicBanners) { + if (!state.showAddWalletBanner) { + showLocalBanner = true + showLocalBannerFirst = false + } else if (state.dynamicBanners.isNotEmpty()) { + showLocalBanner = true + } else { + delay(LOCAL_BANNER_SHOW_DELAY_MILLIS) + showLocalBanner = true + showLocalBannerFirst = true + } + } + val showAddWalletBanner = state.showAddWalletBanner && showLocalBanner + val pages = remember(showAddWalletBanner, showLocalBannerFirst, state.dynamicBanners) { buildList { + if (showAddWalletBanner && showLocalBannerFirst) add(WalletHomeBannerPage.AddWallet) addAll(state.dynamicBanners.map(WalletHomeBannerPage::Dynamic)) - if (state.showAddWalletBanner) add(WalletHomeBannerPage.AddWallet) + if (showAddWalletBanner && !showLocalBannerFirst) add(WalletHomeBannerPage.AddWallet) } } if (pages.isEmpty()) return val pagerState = rememberPagerState(initialPage = 0) { pages.size } + LaunchedEffect(pages.size) { + if (pages.size <= 1) return@LaunchedEffect + while (true) { + delay(BANNER_AUTO_SWITCH_DELAY_MILLIS) + val nextPage = (pagerState.currentPage + 1) % pages.size + pagerState.animateScrollToPage(nextPage) + } + } Column( modifier = Modifier.fillMaxWidth(), From 18b5fc7c5ec18e165ae3a37f62ba87a6075a0dc0 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 24 Jun 2026 22:03:47 +0800 Subject: [PATCH 09/14] fix(wallet): pause banner auto scroll while dragging --- .../ui/wallet/home/components/BannerCards.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 8943372e76..07d407e407 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,6 +48,8 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import one.mixin.android.R import one.mixin.android.api.response.WalletHomeBanner import one.mixin.android.api.response.WalletHomeBannerAction @@ -96,9 +99,17 @@ internal fun BannerPager( LaunchedEffect(pages.size) { if (pages.size <= 1) return@LaunchedEffect while (true) { - delay(BANNER_AUTO_SWITCH_DELAY_MILLIS) - val nextPage = (pagerState.currentPage + 1) % pages.size - pagerState.animateScrollToPage(nextPage) + if (pagerState.isScrollInProgress) { + snapshotFlow { pagerState.isScrollInProgress }.first { !it } + } + val interrupted = withTimeoutOrNull(BANNER_AUTO_SWITCH_DELAY_MILLIS) { + snapshotFlow { pagerState.isScrollInProgress }.first { it } + true + } ?: false + if (!interrupted && !pagerState.isScrollInProgress) { + val nextPage = (pagerState.currentPage + 1) % pages.size + pagerState.animateScrollToPage(nextPage) + } } } From 3800c551c02b8adc2a2e8d36035c89baffbaca32 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 25 Jun 2026 13:12:37 +0800 Subject: [PATCH 10/14] fix(wallet): wait for banner request completion --- .../ui/wallet/WalletHomeClassicFragment.kt | 18 +++++++++-- .../ui/wallet/WalletHomePrivacyFragment.kt | 17 +++++++++-- .../android/ui/wallet/home/WalletHomeState.kt | 1 + .../ui/wallet/home/components/BannerCards.kt | 30 ++----------------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index efba4ff8f3..c064426413 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -141,6 +141,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var pendingTransactionCount: Int = 0 private var watchAddresses: List = emptyList() private var dynamicBanners: List = emptyList() + private var isDynamicBannerLoaded = false private var closedDynamicBannerIds: Set = emptySet() private val assetsAdapter by lazy { WalletWeb3TokenAdapter(false) } @@ -165,6 +166,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) field = value walletHomeDataState = WalletHomeDataState.EMPTY dynamicBanners = emptyList() + isDynamicBannerLoaded = false _walletId.value = value loadWalletHomeCache() } @@ -499,7 +501,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds) - val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner + val showBanner = isDynamicBannerLoaded && (visibleDynamicBanners.isNotEmpty() || showAddWalletBanner) val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val currentImportKeyAction = importKeyAction val pendingCount = walletHomePendingTransactionCount(pendingRawTransactionCount, pendingTransactionCount) @@ -533,6 +535,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) watchIndicator = if (isWatchWallet) walletHomeWatchIndicator(watchAddresses) else null, importKeyAction = currentImportKeyAction, showAddWalletBanner = showAddWalletBanner, + isDynamicBannerLoaded = isDynamicBannerLoaded, dynamicBanners = visibleDynamicBanners, showReferralBanner = showReferral, showImportSafetyFooter = !isLoading, @@ -609,9 +612,18 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) fun refreshWalletHomeBanners() { if (!isAdded || walletId.isEmpty()) return lifecycleScope.launch { - val remoteBanners = web3ViewModel.walletHomeBanners(walletHomeBannerChains()) - syncClosedDynamicBannerIds(remoteBanners) + val remoteBanners = runCatching { + web3ViewModel.walletHomeBanners(walletHomeBannerChains()) + }.onFailure { + Timber.w(it, "Fetch wallet home banners failed") + }.getOrDefault(emptyList()) + runCatching { + syncClosedDynamicBannerIds(remoteBanners) + }.onFailure { + Timber.w(it, "Sync wallet home banner closed ids failed") + } dynamicBanners = remoteBanners + isDynamicBannerLoaded = true renderHome() } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt index 785ae2fcec..0d507e46ed 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt @@ -137,6 +137,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var topMovers: List = emptyList() private var pendingDisplays: List = emptyList() private var dynamicBanners: List = emptyList() + private var isDynamicBannerLoaded = false private var closedDynamicBannerIds: Set = emptySet() private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() @@ -402,7 +403,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) ) val showAddWalletBanner = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_ADD_WALLET_BANNER_CLOSED, false) val visibleDynamicBanners = dynamicBanners.visibleWalletHomeBanners(closedDynamicBannerIds) - val showBanner = visibleDynamicBanners.isNotEmpty() || showAddWalletBanner + val showBanner = isDynamicBannerLoaded && (visibleDynamicBanners.isNotEmpty() || showAddWalletBanner) val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val cards = WalletHomeBuilder.build( walletType = WalletHomeType.PRIVACY, @@ -435,6 +436,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) pendingIndicator = pendingDisplays.toWalletHomePendingIndicator(), quoteColorReversed = defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false), showAddWalletBanner = showAddWalletBanner, + isDynamicBannerLoaded = isDynamicBannerLoaded, dynamicBanners = visibleDynamicBanners, showReferralBanner = showReferral, showBuyBadge = defaultSharedPreferences.getBoolean(PREF_HAS_USED_BUY, true), @@ -471,9 +473,18 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) fun refreshWalletHomeBanners() { lifecycleScope.launch { - val remoteBanners = walletViewModel.walletHomeBanners() - syncClosedDynamicBannerIds(remoteBanners) + val remoteBanners = runCatching { + walletViewModel.walletHomeBanners() + }.onFailure { + Timber.w(it, "Fetch wallet home banners failed") + }.getOrDefault(emptyList()) + runCatching { + syncClosedDynamicBannerIds(remoteBanners) + }.onFailure { + Timber.w(it, "Sync wallet home banner closed ids failed") + } dynamicBanners = remoteBanners + isDynamicBannerLoaded = true renderHome() } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt index cce87436ff..5889789d76 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt @@ -40,6 +40,7 @@ data class WalletHomeState( val hideActions: Boolean = false, val quoteColorReversed: Boolean = false, val showAddWalletBanner: Boolean = false, + val isDynamicBannerLoaded: Boolean = false, val dynamicBanners: List = emptyList(), val showReferralBanner: Boolean = false, val showBuyBadge: Boolean = false, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index 07d407e407..fa95f34cca 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -27,10 +27,7 @@ import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,7 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull import one.mixin.android.R @@ -59,7 +55,6 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.ui.wallet.home.WalletHomeCallbacks import one.mixin.android.ui.wallet.home.WalletHomeState -private const val LOCAL_BANNER_SHOW_DELAY_MILLIS = 2_000L private const val BANNER_AUTO_SWITCH_DELAY_MILLIS = 3_000L @OptIn(ExperimentalFoundationApi::class) @@ -68,30 +63,11 @@ internal fun BannerPager( state: WalletHomeState, callbacks: WalletHomeCallbacks, ) { - var showLocalBanner by remember(state.showAddWalletBanner, state.dynamicBanners) { - mutableStateOf(!state.showAddWalletBanner || state.dynamicBanners.isNotEmpty()) - } - var showLocalBannerFirst by remember(state.showAddWalletBanner) { - mutableStateOf(false) - } - LaunchedEffect(state.showAddWalletBanner, state.dynamicBanners) { - if (!state.showAddWalletBanner) { - showLocalBanner = true - showLocalBannerFirst = false - } else if (state.dynamicBanners.isNotEmpty()) { - showLocalBanner = true - } else { - delay(LOCAL_BANNER_SHOW_DELAY_MILLIS) - showLocalBanner = true - showLocalBannerFirst = true - } - } - val showAddWalletBanner = state.showAddWalletBanner && showLocalBanner - val pages = remember(showAddWalletBanner, showLocalBannerFirst, state.dynamicBanners) { + val showAddWalletBanner = state.showAddWalletBanner && state.isDynamicBannerLoaded + val pages = remember(showAddWalletBanner, state.dynamicBanners) { buildList { - if (showAddWalletBanner && showLocalBannerFirst) add(WalletHomeBannerPage.AddWallet) addAll(state.dynamicBanners.map(WalletHomeBannerPage::Dynamic)) - if (showAddWalletBanner && !showLocalBannerFirst) add(WalletHomeBannerPage.AddWallet) + if (showAddWalletBanner) add(WalletHomeBannerPage.AddWallet) } } if (pages.isEmpty()) return From d0356d99c5f891ec13b73b3834e4330dcbb91499 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 25 Jun 2026 17:17:15 +0800 Subject: [PATCH 11/14] fix(wallet): refine banner card layout --- .../ui/wallet/home/components/BannerCards.kt | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt index fa95f34cca..74dc506744 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/BannerCards.kt @@ -1,7 +1,5 @@ package one.mixin.android.ui.wallet.home.components -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -27,13 +25,18 @@ import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow 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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -72,6 +75,13 @@ internal fun BannerPager( } if (pages.isEmpty()) return val pagerState = rememberPagerState(initialPage = 0) { pages.size } + val density = LocalDensity.current + var bannerHeightPx by remember(pages) { mutableIntStateOf(0) } + val bannerHeightModifier = if (bannerHeightPx > 0) { + Modifier.height(with(density) { bannerHeightPx.toDp() }) + } else { + Modifier.wrapContentHeight() + } LaunchedEffect(pages.size) { if (pages.size <= 1) return@LaunchedEffect while (true) { @@ -110,8 +120,7 @@ internal fun BannerPager( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp) - .wrapContentHeight() - .animateContentSize(animationSpec = tween(durationMillis = 200)) + .then(bannerHeightModifier) .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .then(cardClickModifier), @@ -122,20 +131,30 @@ internal fun BannerPager( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), - verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top, ) { page -> - when (val bannerPage = pages[page]) { - WalletHomeBannerPage.AddWallet -> BannerCard( - iconRes = R.drawable.ic_wallet_home_add, - titleRes = R.string.wallet_home_add_wallet_banner_title, - descriptionRes = null, - ctaRes = R.string.add_wallet, - onClick = callbacks::onAddWalletClicked, - ) - is WalletHomeBannerPage.Dynamic -> DynamicBannerCard( - banner = bannerPage.banner, - onActionClick = callbacks::onDynamicBannerActionClicked, - ) + Box( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { size -> + bannerHeightPx = maxOf(bannerHeightPx, size.height) + }, + contentAlignment = Alignment.TopStart, + ) { + when (val bannerPage = pages[page]) { + WalletHomeBannerPage.AddWallet -> BannerCard( + iconRes = R.drawable.ic_wallet_home_add, + titleRes = R.string.wallet_home_add_wallet_banner_title, + descriptionRes = null, + ctaRes = R.string.add_wallet, + onClick = callbacks::onAddWalletClicked, + ) + is WalletHomeBannerPage.Dynamic -> DynamicBannerCard( + banner = bannerPage.banner, + onActionClick = callbacks::onDynamicBannerActionClicked, + ) + } } } } @@ -192,7 +211,7 @@ private fun BannerCard( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 20.dp, end = 22.dp, bottom = 20.dp), + .padding(top = 16.dp, end = 22.dp, bottom = 16.dp), verticalAlignment = Alignment.Top, ) { Image( @@ -204,10 +223,13 @@ private fun BannerCard( Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(titleRes), + modifier = Modifier.fillMaxWidth(), color = MixinAppTheme.colors.textMinor, fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) if (descriptionRes != null) { Spacer(modifier = Modifier.height(4.dp)) @@ -215,9 +237,11 @@ private fun BannerCard( text = stringResource(descriptionRes), color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) BannerAction(text = stringResource(ctaRes), onClick = onClick) } } @@ -232,11 +256,10 @@ private fun DynamicBannerCard( val description = banner.description?.takeIf { it.isNotBlank() } val showDescription = actions.isEmpty() && description != null val titleOnly = !showDescription - val bottomPadding = if (banner.hasButtonStyle) 20.dp else 22.dp Row( modifier = Modifier .fillMaxWidth() - .padding(top = 22.dp, end = 22.dp, bottom = bottomPadding), + .padding(top = 16.dp, end = 22.dp, bottom = 16.dp), verticalAlignment = Alignment.Top, ) { val iconUrl = banner.iconUrl?.takeIf { it.isNotBlank() } @@ -258,10 +281,13 @@ private fun DynamicBannerCard( Column(modifier = Modifier.weight(1f)) { Text( text = banner.title.orEmpty(), + modifier = Modifier.fillMaxWidth(), color = MixinAppTheme.colors.textMinor, fontSize = if (titleOnly) 14.sp else 16.sp, lineHeight = 20.sp, fontWeight = if (titleOnly) FontWeight.W400 else FontWeight.W500, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) if (showDescription) { description.let { description -> @@ -272,10 +298,12 @@ private fun DynamicBannerCard( fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } else if (actions.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, From 192fae36224952db87725eb02fb02e084eb35979 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 26 Jun 2026 12:33:24 +0800 Subject: [PATCH 12/14] fix(wallet): address ad banner review feedback --- app/src/main/java/one/mixin/android/di/AppModule.kt | 2 +- .../java/one/mixin/android/util/analytics/AnalyticsTracker.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/di/AppModule.kt b/app/src/main/java/one/mixin/android/di/AppModule.kt index 79661bc904..a5fa6f96b2 100644 --- a/app/src/main/java/one/mixin/android/di/AppModule.kt +++ b/app/src/main/java/one/mixin/android/di/AppModule.kt @@ -531,7 +531,7 @@ object AppModule { val sourceRequest = chain.request() val b = sourceRequest.newBuilder() b.addHeader("User-Agent", API_UA) - .addHeader("Accept-Language", Locale.getDefault().language + "-" + Locale.getDefault().country) + .addHeader("Accept-Language", Locale.getDefault().toLanguageTag()) .addHeader("Mixin-Device-Id", getStringDeviceId(resolver)) .addHeader(xRequestId, UUID.randomUUID().toString()) val botPublicKey = appContext.defaultSharedPreferences.getString(PREF_ROUTE_BOT_PK, null) diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index e5bde80861..b06c67e1ed 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -153,7 +153,7 @@ object AnalyticsTracker { fun trackWalletHomeAdBanner(trackingKey: String?, source: String) { val key = trackingKey?.takeIf { it.isNotBlank() } ?: return - logEvent(trackingKey) { + logEvent(key) { putString("source", source) } } From e2599c99e76f395f24f3fe31aec633aa41843986 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 25 Jun 2026 16:42:35 +0800 Subject: [PATCH 13/14] feat(wallet): add cash account module --- .gitignore | 1 + .../main/java/one/mixin/android/Constants.kt | 4 + .../mixin/android/api/response/CashAccount.kt | 10 + .../mixin/android/api/service/CashService.kt | 10 + .../android/db/property/PropertyHelper.kt | 25 ++ .../java/one/mixin/android/di/AppModule.kt | 46 +++ .../android/repository/CashRepository.kt | 30 ++ .../android/repository/TokenRepository.kt | 21 +- .../android/repository/UserRepository.kt | 3 + .../android/ui/address/AddressViewModel.kt | 5 + .../TransferDestinationInputFragment.kt | 32 ++ .../ui/address/component/DestinationMenu.kt | 10 +- .../page/TransferDestinationInputPage.kt | 34 +- .../android/ui/common/BottomSheetViewModel.kt | 2 +- .../ui/common/biometric/BiometricItem.kt | 8 +- .../android/ui/home/web3/Web3ViewModel.kt | 7 + .../sessionrequest/SessionRequestViewModel.kt | 5 +- ...AccountPreviewBottomSheetDialogFragment.kt | 171 ++++++++++ .../mixin/android/ui/wallet/InputFragment.kt | 318 ++++++++++++++++-- .../android/ui/wallet/TransactionInterface.kt | 2 +- ...lletBuyOptionsBottomSheetDialogFragment.kt | 245 ++++++++++++++ .../ui/wallet/WalletHomeAllTokensFragment.kt | 1 + .../ui/wallet/WalletHomeClassicFragment.kt | 57 +++- .../ui/wallet/WalletHomePrivacyFragment.kt | 82 ++++- .../ui/wallet/WalletTransferLabelStyle.kt | 38 +++ .../android/ui/wallet/WalletViewModel.kt | 10 +- .../ui/wallet/adapter/SnapshotHolder.kt | 3 +- .../ui/wallet/home/WalletHomeBalance.kt | 3 +- .../ui/wallet/home/WalletHomeBuilder.kt | 8 +- .../android/ui/wallet/home/WalletHomeCache.kt | 5 + .../ui/wallet/home/WalletHomeCashAccount.kt | 24 ++ .../android/ui/wallet/home/WalletHomeItem.kt | 1 + .../android/ui/wallet/home/WalletHomeState.kt | 2 + .../wallet/home/components/WalletHomeCard.kt | 89 +++++ .../widget/CashAccountTransferContent.kt | 71 ++++ .../wallet/transfer/widget/TransferContent.kt | 1 + .../transfer/widget/TransferContentItem.kt | 3 +- .../widget/TransferContentSafeReceiveItem.kt | 103 +----- .../util/analytics/AnalyticsTracker.kt | 1 + .../res/drawable/bg_round_wallet_green_tv.xml | 5 + .../main/res/drawable/ic_destination_cash.xml | 22 ++ .../drawable/ic_wallet_buy_bank_transfer.xml | 27 ++ .../main/res/drawable/ic_wallet_buy_card.xml | 22 ++ .../main/res/drawable/ic_wallet_home_cash.xml | 44 +++ ...ment_cash_account_preview_bottom_sheet.xml | 19 ++ .../view_cash_account_transfer_content.xml | 148 ++++++++ app/src/main/res/values-zh-rCN/strings.xml | 13 +- app/src/main/res/values/strings.xml | 14 +- 48 files changed, 1646 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/api/response/CashAccount.kt create mode 100644 app/src/main/java/one/mixin/android/api/service/CashService.kt create mode 100644 app/src/main/java/one/mixin/android/repository/CashRepository.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/CashAccountPreviewBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/WalletBuyOptionsBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/WalletTransferLabelStyle.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccount.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/CashAccountTransferContent.kt create mode 100644 app/src/main/res/drawable/bg_round_wallet_green_tv.xml create mode 100644 app/src/main/res/drawable/ic_destination_cash.xml create mode 100644 app/src/main/res/drawable/ic_wallet_buy_bank_transfer.xml create mode 100644 app/src/main/res/drawable/ic_wallet_buy_card.xml create mode 100644 app/src/main/res/drawable/ic_wallet_home_cash.xml create mode 100644 app/src/main/res/layout/fragment_cash_account_preview_bottom_sheet.xml create mode 100644 app/src/main/res/layout/view_cash_account_transfer_content.xml diff --git a/.gitignore b/.gitignore index 6491007978..b794db841f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,6 @@ CLAUDE.md agent.md .claude/ .codex/ +.codegraph/ .github/copilot-instructions.md .vscode/ diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 3d4887dd03..fb49559dfd 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -28,6 +28,8 @@ object Constants { const val WS_URL = "wss://blaze.mixin.one" const val Mixin_URL = "https://mixin-api.zeromesh.net/" const val Mixin_WS_URL = "wss://mixin-blaze.zeromesh.net" + const val CASH_URL = "https://api.cash.mixin.one" + const val CASH_HOME_URL = "https://cash.mixin.one" const val GIPHY_URL = "https://api.giphy.com/v1/" const val FOURSQUARE_URL = "https://api.foursquare.com/v2/" @@ -106,6 +108,8 @@ object Constants { const val PREF_MARKET_ORDER = "pref_market_order" const val PREF_INSCRIPTION_ORDER = "pref_inscription_order" const val PREF_ROUTE_BOT_PK = "pref_route_bot_pk" + const val PREF_CASH_BOT_PK = "pref_cash_bot_pk" + const val PREF_CASH_ACCOUNT = "pref_cash_account" const val PREF_REFERRAL_BOT_PK = "pref_referral_bot_pk" diff --git a/app/src/main/java/one/mixin/android/api/response/CashAccount.kt b/app/src/main/java/one/mixin/android/api/response/CashAccount.kt new file mode 100644 index 0000000000..a01de84072 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/CashAccount.kt @@ -0,0 +1,10 @@ +package one.mixin.android.api.response + +import com.google.gson.annotations.SerializedName + +data class CashAccount( + @SerializedName("balance") + val balance: String, + @SerializedName("min_amount") + val minAmount: String, +) diff --git a/app/src/main/java/one/mixin/android/api/service/CashService.kt b/app/src/main/java/one/mixin/android/api/service/CashService.kt new file mode 100644 index 0000000000..1a706583f6 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/service/CashService.kt @@ -0,0 +1,10 @@ +package one.mixin.android.api.service + +import one.mixin.android.api.MixinResponse +import one.mixin.android.api.response.CashAccount +import retrofit2.http.GET + +interface CashService { + @GET("account") + suspend fun account(): MixinResponse +} diff --git a/app/src/main/java/one/mixin/android/db/property/PropertyHelper.kt b/app/src/main/java/one/mixin/android/db/property/PropertyHelper.kt index c848f3e2e1..37f4caa5bb 100644 --- a/app/src/main/java/one/mixin/android/db/property/PropertyHelper.kt +++ b/app/src/main/java/one/mixin/android/db/property/PropertyHelper.kt @@ -8,6 +8,7 @@ import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_INSCRIPTION import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_TRANSCRIPT_ATTACHMENT import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_TRANSCRIPT_ATTACHMENT_LAST import one.mixin.android.Constants.Account.PREF_BACKUP +import one.mixin.android.Constants.Account.PREF_CASH_ACCOUNT import one.mixin.android.Constants.Account.PREF_CLEANUP_QUOTE_CONTENT import one.mixin.android.Constants.Account.PREF_CLEANUP_THUMB import one.mixin.android.Constants.Account.PREF_DUPLICATE_TRANSFER @@ -21,6 +22,7 @@ import one.mixin.android.Constants.Download.MOBILE_DEFAULT import one.mixin.android.Constants.Download.ROAMING_DEFAULT import one.mixin.android.Constants.Download.WIFI_DEFAULT import one.mixin.android.MixinApplication +import one.mixin.android.api.response.CashAccount import one.mixin.android.db.MixinDatabase import one.mixin.android.db.PropertyDao import one.mixin.android.extension.defaultSharedPreferences @@ -28,6 +30,7 @@ import one.mixin.android.extension.nowInUtc import one.mixin.android.job.ClearFts4Job.Companion.FTS_CLEAR import one.mixin.android.job.MigratedFts4Job.Companion.FTS_NEED_MIGRATED_LAST_ROW_ID import one.mixin.android.session.Session +import one.mixin.android.util.GsonHelper import one.mixin.android.vo.Property object PropertyHelper { @@ -148,6 +151,28 @@ object PropertyHelper { propertyDao.deletePropertyByKey(key) } + suspend fun updateCashAccount(account: CashAccount?) { + val value = runCatching { + account?.takeIf { it.balance.isNotBlank() && it.minAmount.isNotBlank() }?.let { + GsonHelper.customGson.toJson(it) + } + }.getOrNull() + if (value == null) { + deleteKeyValue(PREF_CASH_ACCOUNT) + } else { + updateKeyValue(PREF_CASH_ACCOUNT, value) + } + } + + suspend fun findCashAccount(): CashAccount? { + val value = findValueByKey(PREF_CASH_ACCOUNT, "") + if (value.isBlank()) return null + return runCatching { + val account = GsonHelper.customGson.fromJson(value, CashAccount::class.java) ?: return@runCatching null + account.takeIf { it.balance.isNotBlank() && it.minAmount.isNotBlank() } + }.getOrNull() + } + suspend fun findValueByKey( key: String, default: T, diff --git a/app/src/main/java/one/mixin/android/di/AppModule.kt b/app/src/main/java/one/mixin/android/di/AppModule.kt index a5fa6f96b2..bc240ae8a5 100644 --- a/app/src/main/java/one/mixin/android/di/AppModule.kt +++ b/app/src/main/java/one/mixin/android/di/AppModule.kt @@ -32,10 +32,12 @@ import okhttp3.logging.HttpLoggingInterceptor import one.mixin.android.BuildConfig import one.mixin.android.Constants import one.mixin.android.Constants.ALLOW_INTERVAL +import one.mixin.android.Constants.API.CASH_URL import one.mixin.android.Constants.API.FOURSQUARE_URL import one.mixin.android.Constants.API.GIPHY_URL import one.mixin.android.Constants.API.Mixin_URL import one.mixin.android.Constants.API.URL +import one.mixin.android.Constants.Account.PREF_CASH_BOT_PK import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK import one.mixin.android.Constants.DNS @@ -52,6 +54,7 @@ import one.mixin.android.api.service.AccountService import one.mixin.android.api.service.AddressService import one.mixin.android.api.service.AssetService import one.mixin.android.api.service.AuthorizationService +import one.mixin.android.api.service.CashService import one.mixin.android.api.service.CircleService import one.mixin.android.api.service.ContactService import one.mixin.android.api.service.ConversationService @@ -596,6 +599,49 @@ object AppModule { return retrofit.create(ReferralService::class.java) } + @Singleton + @Provides + fun provideCashService( + resolver: ContentResolver, + httpLoggingInterceptor: HttpLoggingInterceptor?, + @ApplicationContext appContext: Context, + ): CashService { + val builder = OkHttpClient.Builder() + builder.connectTimeout(15, TimeUnit.SECONDS) + builder.writeTimeout(15, TimeUnit.SECONDS) + builder.readTimeout(15, TimeUnit.SECONDS) + builder.dns(DNS) + val client = + builder.apply { + httpLoggingInterceptor?.let { interceptor -> + addNetworkInterceptor(interceptor) + } + addInterceptor { chain -> + val sourceRequest = chain.request() + val b = sourceRequest.newBuilder() + b.addHeader("User-Agent", API_UA) + .addHeader("Accept-Language", Locale.getDefault().language) + .addHeader("Mixin-Device-Id", getStringDeviceId(resolver)) + .addHeader(xRequestId, UUID.randomUUID().toString()) + val botPublicKey = appContext.defaultSharedPreferences.getString(PREF_CASH_BOT_PK, null) + if (botPublicKey.isNullOrBlank()) return@addInterceptor chain.proceed(b.build()) + val (ts, signature) = Session.getBotSignature(botPublicKey, sourceRequest) + b.addHeader(mrAccessTimestamp, ts.toString()) + b.addHeader(mrAccessSign, signature) + val request = b.build() + return@addInterceptor chain.proceed(request) + } + }.build() + val retrofit = + Retrofit.Builder() + .baseUrl(CASH_URL) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) + .client(client) + .build() + return retrofit.create(CashService::class.java) + } + @Provides @Singleton fun provideCallState() = CallStateLiveData() diff --git a/app/src/main/java/one/mixin/android/repository/CashRepository.kt b/app/src/main/java/one/mixin/android/repository/CashRepository.kt new file mode 100644 index 0000000000..7e94cec21b --- /dev/null +++ b/app/src/main/java/one/mixin/android/repository/CashRepository.kt @@ -0,0 +1,30 @@ +package one.mixin.android.repository + +import one.mixin.android.Constants.MIXIN_CASH_USER_ID +import one.mixin.android.api.MixinResponse +import one.mixin.android.api.response.CashAccount +import one.mixin.android.api.service.CashService +import one.mixin.android.db.property.PropertyHelper +import one.mixin.android.util.ErrorHandler +import javax.inject.Inject + +class CashRepository + @Inject + constructor( + private val cashService: CashService, + private val userRepository: UserRepository, + ) { + suspend fun account(): MixinResponse { + userRepository.getBotPublicKey(MIXIN_CASH_USER_ID, false) + val response = cashService.account() + if (response.errorCode != ErrorHandler.AUTHENTICATION) { + if (response.isSuccess) PropertyHelper.updateCashAccount(response.data) + return response + } + + userRepository.getBotPublicKey(MIXIN_CASH_USER_ID, true) + return cashService.account().also { + if (it.isSuccess) PropertyHelper.updateCashAccount(it.data) + } + } + } diff --git a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt index f47e4e71bc..882384fff1 100644 --- a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt @@ -39,6 +39,7 @@ import one.mixin.android.api.response.RouteTickerResponse import one.mixin.android.api.response.TransactionResponse import one.mixin.android.api.response.WithdrawalResponse import one.mixin.android.api.response.web3.ParsedTx +import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.WalletOutput import one.mixin.android.api.service.AddressService import one.mixin.android.api.service.AssetService @@ -465,8 +466,13 @@ class TokenRepository ) = safeSnapshotDao.snapshotLocal(assetId, snapshotId) - fun findAddressByDestination(receiver: String, tag: String, chainId: String?) = if (chainId == null) addressDao.findAddressByDestination(receiver, tag) - else addressDao.findAddressByDestination(receiver, tag, chainId) + suspend fun findAddressByDestination(receiver: String, tag: String, chainId: String?): String? { + return if (chainId == null) { + addressDao.findAddressByDestination(receiver, tag) + } else { + addressDao.findAddressByDestination(receiver, tag, chainId) + } + } fun insertSnapshot(snapshot: SafeSnapshot) = safeSnapshotDao.insert(snapshot) @@ -577,11 +583,11 @@ class TokenRepository val receiver = item.withdrawal.receiver val index: Int = receiver.indexOf(":") if (index == -1) { - item.label = addressDao.findAddressByDestination(receiver, "") + item.label = findAddressByDestination(receiver, "", null) } else { val destination: String = receiver.substring(0, index) val tag: String = receiver.substring(index + 1) - item.label = addressDao.findAddressByDestination(destination, tag) + item.label = findAddressByDestination(destination, tag, null) } } item @@ -1532,6 +1538,13 @@ class TokenRepository suspend fun getSwapToken(address: String) = routeService.getSwapToken(address) + suspend fun web3Quote( + inputMint: String, + outputMint: String, + amount: String, + source: String = "web3", + ): MixinResponse = routeService.web3Quote(inputMint, outputMint, amount, source) + suspend fun transaction(hash: String, chainId: String) = routeService.transaction(hash,chainId) suspend fun getPendingRawTransactions(walletId: String) = web3RawTransactionDao.getPendingRawTransactions( diff --git a/app/src/main/java/one/mixin/android/repository/UserRepository.kt b/app/src/main/java/one/mixin/android/repository/UserRepository.kt index 1aa581b5e1..0e3eb8659a 100644 --- a/app/src/main/java/one/mixin/android/repository/UserRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/UserRepository.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.map import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import one.mixin.android.Constants.Account.PREF_CASH_BOT_PK import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK +import one.mixin.android.Constants.MIXIN_CASH_USER_ID import one.mixin.android.Constants.RouteConfig.REFERRAL_BOT_USER_ID import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.MixinApplication @@ -366,6 +368,7 @@ class UserRepository when (botId) { ROUTE_BOT_USER_ID -> PREF_ROUTE_BOT_PK REFERRAL_BOT_USER_ID -> PREF_REFERRAL_BOT_PK + MIXIN_CASH_USER_ID -> PREF_CASH_BOT_PK else -> return } diff --git a/app/src/main/java/one/mixin/android/ui/address/AddressViewModel.kt b/app/src/main/java/one/mixin/android/ui/address/AddressViewModel.kt index d6af0542a4..b4bcdde5e9 100644 --- a/app/src/main/java/one/mixin/android/ui/address/AddressViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/address/AddressViewModel.kt @@ -3,6 +3,8 @@ package one.mixin.android.ui.address import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.map +import one.mixin.android.api.response.CashAccount +import one.mixin.android.db.property.PropertyHelper import one.mixin.android.job.MixinJobManager import one.mixin.android.repository.AccountRepository import one.mixin.android.repository.TokenRepository @@ -28,6 +30,9 @@ class AddressViewModel suspend fun getSafeWalletsByChainId(chainId: String) = web3Repository.getSafeWalletsByChainId(chainId) + suspend fun findCashAccount(): CashAccount? = + PropertyHelper.findCashAccount() + suspend fun validateExternalAddress( assetId: String, chain: String, destination: String, tag: String? ) = accountRepository.validateExternalAddress(assetId, chain, destination, tag) diff --git a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt index a1c31b4873..b20ad2343d 100644 --- a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt @@ -386,6 +386,9 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres }.show(parentFragmentManager, WalletListBottomSheetDialogFragment.TAG) }, + toCashAccount = { + navigateToCashAccount() + }, toAddAddress = { AnalyticsTracker.trackAddressBookAddStart() navController.navigate(TransferDestination.Address.name) @@ -827,6 +830,35 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres } } + private fun navigateToCashAccount() { + requireView().hideKeyboard() + lifecycleScope.launch(CoroutineExceptionHandler { _, error -> + Timber.e(error) + }) { + val cashAccount = viewModel.findCashAccount() + val tokenToSend = token + if (cashAccount == null || tokenToSend == null) { + toast(R.string.Alert_Not_Support) + return@launch + } + + AnalyticsTracker.trackAssetSendRecipient(AnalyticsTracker.AssetSendRecipientType.CASH_ACCOUNT) + navigateToInputFragmentWithBundle(Bundle().apply { + putParcelable(InputFragment.ARGS_TOKEN, tokenToSend) + putCashAccountArgs(cashAccount.balance, cashAccount.minAmount) + }) + } + } + + private fun Bundle.putCashAccountArgs( + balance: String, + minAmount: String, + ) { + putBoolean(InputFragment.ARGS_CASH_ACCOUNT_TRANSFER, true) + putString(InputFragment.ARGS_CASH_BALANCE, balance) + putString(InputFragment.ARGS_CASH_MIN_AMOUNT, minAmount) + } + private fun navigateToInputFragmentWithBundle(bundle: Bundle) { findNavController().navigate(R.id.action_transfer_destination_to_input, bundle) } diff --git a/app/src/main/java/one/mixin/android/ui/address/component/DestinationMenu.kt b/app/src/main/java/one/mixin/android/ui/address/component/DestinationMenu.kt index f4ae03af4e..0502205797 100644 --- a/app/src/main/java/one/mixin/android/ui/address/component/DestinationMenu.kt +++ b/app/src/main/java/one/mixin/android/ui/address/component/DestinationMenu.kt @@ -67,7 +67,9 @@ fun DestinationMenu( onClick: () -> Unit = {}, free: Boolean = false, isPrivacy: Boolean = false, + badge: String? = null, ) { + val badgeText = badge ?: if (free) stringResource(R.string.FREE) else null Row( modifier = Modifier .clickable(onClick = onClick) @@ -82,7 +84,7 @@ fun DestinationMenu( modifier = Modifier.padding(8.dp), painter = painterResource(icon), contentDescription = null, - tint = MixinAppTheme.colors.icon + tint = Color.Unspecified ) Spacer(modifier = Modifier.width(16.dp)) Column { @@ -93,16 +95,16 @@ fun DestinationMenu( lineHeight = 19.sp, color = MixinAppTheme.colors.textPrimary ) - if (free) { + if (badgeText != null) { Spacer(modifier = Modifier.width(6.dp)) Text( - stringResource(R.string.FREE), + badgeText, color = Color.White, fontSize = 12.sp, lineHeight = 16.sp, modifier = Modifier .background( - color = MixinAppTheme.colors.accent, + color = if (badge == null) MixinAppTheme.colors.accent else MixinAppTheme.colors.green, shape = RoundedCornerShape(4.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) diff --git a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt index 2e03a2bf8a..c8cf7d242a 100644 --- a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt +++ b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt @@ -85,6 +85,7 @@ fun TransferDestinationInputPage( toAddAddress: () -> Unit, toContact: () -> Unit, toWallet: (String?) -> Unit, + toCashAccount: () -> Unit, onSend: (String) -> Unit, onDeleteAddress: (Address) -> Unit, onAddressClick: (Address) -> Unit, @@ -97,6 +98,7 @@ fun TransferDestinationInputPage( var walletDisplayName by remember { mutableStateOf(null) } var hasSafeWallet by remember { mutableStateOf(false) } var safeWalletChainId by remember { mutableStateOf(null) } + var hasCashAccount by remember { mutableStateOf(false) } var text by remember(contentText) { mutableStateOf(contentText) } val clipboardManager = LocalClipboard.current @@ -115,10 +117,22 @@ fun TransferDestinationInputPage( } LaunchedEffect(token, web3Token) { - val chainId = token?.chainId ?: web3Token?.chainId ?: return@LaunchedEffect - val safeWallets = viewModel.getSafeWalletsByChainId(chainId) - hasSafeWallet = safeWallets.isNotEmpty() - safeWalletChainId = safeWallets.firstOrNull()?.safeChainId + if (token == null && web3Token == null) { + hasSafeWallet = false + safeWalletChainId = null + hasCashAccount = false + return@LaunchedEffect + } + val chainId = token?.chainId ?: web3Token?.chainId + if (chainId == null) { + hasSafeWallet = false + safeWalletChainId = null + } else { + val safeWallets = viewModel.getSafeWalletsByChainId(chainId) + hasSafeWallet = safeWallets.isNotEmpty() + safeWalletChainId = safeWallets.firstOrNull()?.safeChainId + } + hasCashAccount = token != null && viewModel.findCashAccount() != null } LaunchedEffect(addressShown) { @@ -333,6 +347,18 @@ fun TransferDestinationInputPage( ) Spacer(modifier = Modifier.height(16.dp)) } + if (hasCashAccount) { + DestinationMenu( + icon = R.drawable.ic_destination_cash, + title = stringResource(R.string.Cash_Account), + subTile = stringResource(R.string.send_to_cash_account_description), + onClick = { + toCashAccount.invoke() + }, + badge = stringResource(R.string.cash_account_apy) + ) + Spacer(modifier = Modifier.height(16.dp)) + } if (web3Token != null) { DestinationMenu( R.drawable.ic_destination_wallet, diff --git a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 4ca2568de3..5a6a01f6e6 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt @@ -1778,7 +1778,7 @@ class BottomSheetViewModel return@withContext tokenRepository.refreshInscription(inscriptionHash) } - fun findAddressByDestination(receiver: String, tag: String, chainId: String?) = tokenRepository.findAddressByDestination(receiver, tag, chainId) + suspend fun findAddressByDestination(receiver: String, tag: String, chainId: String?) = tokenRepository.findAddressByDestination(receiver, tag, chainId) suspend fun checkMarketById(id: String): MarketItem? = withContext(Dispatchers.IO) { tokenRepository.checkMarketById(id) diff --git a/app/src/main/java/one/mixin/android/ui/common/biometric/BiometricItem.kt b/app/src/main/java/one/mixin/android/ui/common/biometric/BiometricItem.kt index 2748f150d1..6a0dd0fc55 100644 --- a/app/src/main/java/one/mixin/android/ui/common/biometric/BiometricItem.kt +++ b/app/src/main/java/one/mixin/android/ui/common/biometric/BiometricItem.kt @@ -45,6 +45,9 @@ class TransferBiometricItem( var trace: Trace?, val returnTo: String?, override var reference: String?, + val cashReceiveAmount: String? = null, + val cashReceiveSymbol: String? = null, + val cashBalance: String? = null, ) : AssetBiometricItem(asset, traceId, amount, memo, state, reference) fun buildEmptyTransferBiometricItem(user: User, token: TokenItem? = null) = @@ -58,8 +61,11 @@ fun buildTransferBiometricItem( memo: String?, returnTo: String?, reference: String? = null, + cashReceiveAmount: String? = null, + cashReceiveSymbol: String? = null, + cashBalance: String? = null, ) = - TransferBiometricItem(listOf(user), 1.toByte(), traceId ?: UUID.randomUUID().toString(), token, amount, memo, PaymentStatus.pending.name, null, returnTo, reference) + TransferBiometricItem(listOf(user), 1.toByte(), traceId ?: UUID.randomUUID().toString(), token, amount, memo, PaymentStatus.pending.name, null, returnTo, reference, cashReceiveAmount, cashReceiveSymbol, cashBalance) @Parcelize open class AddressTransferBiometricItem( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt index 063a7f6fd6..5290af448d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt @@ -103,6 +103,13 @@ class Web3ViewModel @Inject constructor( suspend fun findMarketItemByAssetId(assetId: String) = tokenRepository.findMarketItemByAssetId(assetId) + suspend fun web3Quote( + inputMint: String, + outputMint: String, + amount: String, + source: String, + ) = tokenRepository.web3Quote(inputMint, outputMint, amount, source) + fun web3TokensExcludeHidden(walletId: String) = web3Repository.web3TokensExcludeHidden(walletId) fun walletHomeWeb3TokenPreview( diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestViewModel.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestViewModel.kt index c1943e6384..29165cf718 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/sessionrequest/SessionRequestViewModel.kt @@ -137,9 +137,8 @@ class SessionRequestViewModel return@withContext Triple(wallet.name, walletIndex, null) } if (chainId != null) { - val address = tokenRepository.matchAddress(destination, chainId) - if (address != null) { - return@withContext Triple(address.label, 0, null) // Address label + tokenRepository.findAddressByDestination(destination, "", chainId)?.let { label -> + return@withContext Triple(label, 0, null) // Address label } } return@withContext null diff --git a/app/src/main/java/one/mixin/android/ui/wallet/CashAccountPreviewBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/CashAccountPreviewBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..f7d29f2188 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/CashAccountPreviewBottomSheetDialogFragment.kt @@ -0,0 +1,171 @@ +package one.mixin.android.ui.wallet + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.databinding.FragmentCashAccountPreviewBottomSheetBinding +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.nowInUtc +import one.mixin.android.extension.numberFormat2 +import one.mixin.android.extension.putLong +import one.mixin.android.extension.updatePinCheck +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinBottomSheetDialogFragment +import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment +import one.mixin.android.ui.common.biometric.BiometricInfo +import one.mixin.android.ui.common.biometric.TransferBiometricItem +import one.mixin.android.ui.wallet.transfer.data.TransferStatus +import one.mixin.android.util.ErrorHandler +import one.mixin.android.util.analytics.AnalyticsTracker +import one.mixin.android.util.viewBinding +import one.mixin.android.vo.Fiats +import one.mixin.android.vo.Trace +import one.mixin.android.widget.BottomSheet +import java.math.BigDecimal + +@AndroidEntryPoint +class CashAccountPreviewBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { + companion object { + const val TAG = "CashAccountPreviewBottomSheetDialogFragment" + private const val ARGS_TRANSFER = "args_transfer" + + fun newInstance(item: TransferBiometricItem) = + CashAccountPreviewBottomSheetDialogFragment().withArgs { + putParcelable(ARGS_TRANSFER, item) + } + } + + private val item: TransferBiometricItem by lazy { + requireArguments().getParcelableCompat(ARGS_TRANSFER, TransferBiometricItem::class.java)!! + } + + private val binding by viewBinding(FragmentCashAccountPreviewBottomSheetBinding::inflate) + private var isSuccess = false + private var callback: Callback? = null + private var dismissNotified = false + + @SuppressLint("RestrictedApi") + override fun setupDialog( + dialog: Dialog, + style: Int, + ) { + super.setupDialog(dialog, style) + contentView = binding.root + dialog.setCanceledOnTouchOutside(false) + (dialog as BottomSheet).apply { + setCustomView(contentView) + } + binding.content.render(item) + binding.content.setOnCloseClickListener { + notifyDismiss(false) + dismiss() + } + binding.bottom.updateStatus(TransferStatus.AWAITING_CONFIRMATION) + binding.bottom.setOnClickListener( + { + notifyDismiss(false) + dismiss() + }, + { + showPin() + }, + { + notifyDismiss(isSuccess) + dismiss() + }, + ) + } + + private fun showPin() { + PinInputBottomSheetDialogFragment.newInstance(biometricInfo = getBiometricInfo(), from = 1) + .setOnPinComplete { pin -> + lifecycleScope.launch( + CoroutineExceptionHandler { _, error -> + ErrorHandler.handleError(error) + binding.bottom.updateStatus(TransferStatus.AWAITING_CONFIRMATION) + }, + ) { + binding.bottom.updateStatus(TransferStatus.IN_PROGRESS) + val asset = requireNotNull(item.asset) + val receiverIds = item.users.map { it.userId } + val response = withContext(Dispatchers.IO) { + bottomViewModel.kernelTransaction( + asset.assetId, + receiverIds, + item.threshold, + item.amount, + pin, + item.traceId, + item.memo, + item.reference, + ) + } + if (response.isSuccess) { + bottomViewModel.insertTrace( + Trace( + item.traceId, + asset.assetId, + item.amount, + receiverIds.firstOrNull(), + null, + null, + null, + nowInUtc(), + ), + ) + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + context?.updatePinCheck() + AnalyticsTracker.trackAssetSendEnd() + isSuccess = true + binding.bottom.updateStatus(TransferStatus.SUCCESSFUL) + } else { + ErrorHandler.handleMixinError(response.errorCode, response.errorDescription) + binding.bottom.updateStatus(TransferStatus.AWAITING_CONFIRMATION) + } + } + }.showNow(parentFragmentManager, PinInputBottomSheetDialogFragment.TAG) + } + + private fun getBiometricInfo(): BiometricInfo { + val asset = requireNotNull(item.asset) + val fiatAmount = (item.amount.toBigDecimalOrNull() ?: BigDecimal.ZERO) * asset.priceFiat() + return BiometricInfo( + getString(R.string.cash_account_add_cash), + getString(R.string.Cash_Account), + "${item.amount} ${asset.symbol} (≈ ${Fiats.getSymbol()}${fiatAmount.numberFormat2()})", + ) + } + + override fun onDismiss(dialog: DialogInterface) { + notifyDismiss(isSuccess) + super.onDismiss(dialog) + } + + private fun notifyDismiss(success: Boolean) { + if (dismissNotified) return + dismissNotified = true + callback?.onDismiss(success) + callback = null + } + + fun setCallback(cb: Callback) { + callback = cb + } + + open class Callback { + open fun onDismiss(success: Boolean) {} + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt index ac2a67a66a..56c5cee704 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt @@ -33,6 +33,7 @@ import one.mixin.android.api.request.web3.SubmitGaslessTxRequest import one.mixin.android.api.request.web3.WEB3_FEE_TYPE_FREE import one.mixin.android.api.response.PaymentStatus import one.mixin.android.api.response.web3.EthGaslessTxPayload +import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.databinding.FragmentInputBinding import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.buildTransaction @@ -131,6 +132,12 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC const val ARGS_TOKEN = "args_token" const val ARGS_BIOMETRIC_ITEM = "args_biometric_item" + const val ARGS_CASH_ACCOUNT_TRANSFER = "args_cash_account_transfer" + const val ARGS_CASH_BALANCE = "args_cash_balance" + const val ARGS_CASH_MIN_AMOUNT = "args_cash_min_amount" + private const val CASH_ACCOUNT_QUOTE_DELAY_MS = 350L + private const val CASH_ACCOUNT_QUOTE_SOURCE = "mixin" + private const val CASH_ACCOUNT_RECEIVE_SYMBOL = "USD" enum class TransferType { USER, @@ -184,6 +191,18 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC requireArguments().getString(ARGS_TO_ADDRESS_TAG) ?: (assetBiometricItem as? WithdrawBiometricItem)?.address?.tag } + private val isCashAccountTransfer by lazy { + requireArguments().getBoolean(ARGS_CASH_ACCOUNT_TRANSFER, false) + } + + private val cashBalance by lazy { + requireNotNull(requireArguments().getString(ARGS_CASH_BALANCE)) + } + + private val cashMinAmount by lazy { + requireNotNull(requireArguments().getString(ARGS_CASH_MIN_AMOUNT)) + } + private val currencyName by lazy { Fiats.getAccountCurrencyAppearance() } @@ -209,6 +228,12 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private var currentNote: String? = null + private var cashQuoteJob: Job? = null + private var cashQuoteAmount: String? = null + private var cashQuote: QuoteResult? = null + private var cashQuoteError: String? = null + private var cashQuoteLoading = false + private var solanaRecipientAccountState = SolanaRecipientAccountState.EXISTS @Inject @@ -225,6 +250,8 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC override fun onDestroyView() { btcFeeRecalculateJob?.cancel() btcFeeRecalculateJob = null + cashQuoteJob?.cancel() + cashQuoteJob = null if (dialog.isShowing) { dialog.dismiss() } @@ -535,7 +562,14 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC binding.titleTextView.setText(R.string.Network_Fee) } } + if (isCashAccountTransfer) { + applyCashAccountInfo() + } continueVa.setOnClickListener { + if (isCashAccountTransfer) { + prepareCashAccountTransfer(currentInputAmount()) + return@setOnClickListener + } when { transferType == TransferType.ADDRESS || (transferType == TransferType.BIOMETRIC_ITEM && assetBiometricItem is WithdrawBiometricItem)-> { val toAddress = requireNotNull(toAddress) @@ -748,6 +782,10 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private fun applyFeeUi() { + if (isCashAccountTransfer) { + applyCashAccountInfo() + return + } val binding = bindingOrNull() ?: return val hasFeeText: Boolean = binding.contentTextView.text.toString().isNotEmpty() val showFee: Boolean = isFeeWaived && hasFeeText @@ -760,10 +798,38 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC binding.feeTv.isVisible = showFee } + private fun cashAccountBalanceText(): String { + val amount = cashBalance + .toBigDecimalOrNull() + ?.numberFormat2() + ?: cashBalance + return "$amount USD" + } + + private fun applyCashAccountInfo() { + val binding = bindingOrNull() ?: return + binding.titleTextView.setText(R.string.cash_balance) + binding.feeTv.isVisible = true + binding.feeTv.setText(R.string.cash_account_apy) + binding.feeTv.setBackgroundResource(R.drawable.bg_round_wallet_green_tv) + binding.feeTv.setOnClickListener(null) + binding.contentTextView.isVisible = true + binding.contentTextView.text = cashAccountBalanceText() + binding.contentTextView.paintFlags = binding.contentTextView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + binding.loadingProgressBar.isVisible = false + binding.iconImageView.isVisible = false + binding.infoLinearLayout.setOnClickListener(null) + } + private var addressLabel:String? = null private fun initTitle() { binding.apply { + if (isCashAccountTransfer) { + titleView.setLabel(getString(R.string.Send_To_Title), getString(R.string.Cash_Account), "") + addressLabel = getString(R.string.Cash_Account) + return + } when (transferType) { TransferType.USER -> { titleView.setSubTitle(getString(R.string.Send_To_Title), user) @@ -880,6 +946,86 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } } + private fun cashMinimumReceiveAmount(): BigDecimal = + cashMinAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + + private fun cashQuoteReceiveAmount(quote: QuoteResult? = cashQuote): BigDecimal? = + quote?.outAmount?.toBigDecimalOrNull() + + private fun isCurrentCashQuoteBelowMinimum(amount: String): Boolean { + if (!isCashAccountTransfer || cashQuoteAmount != amount) return false + val receiveAmount = cashQuoteReceiveAmount() ?: return false + return receiveAmount < cashMinimumReceiveAmount() + } + + private fun cashQuoteUnavailableText(amount: String): String { + val receiveAmount = cashQuoteReceiveAmount() + val minimumAmount = cashMinimumReceiveAmount() + return when { + cashQuoteLoading || cashQuoteAmount != amount -> getString(R.string.calculating) + !cashQuoteError.isNullOrBlank() -> cashQuoteError!! + receiveAmount != null && receiveAmount < minimumAmount -> + getString(R.string.cash_account_minimum_receive, minimumAmount.numberFormat8(), CASH_ACCOUNT_RECEIVE_SYMBOL) + else -> getString(R.string.no_available_quotes_found) + } + } + + private fun resetCashAccountQuote() { + cashQuoteJob?.cancel() + cashQuoteJob = null + cashQuoteAmount = null + cashQuote = null + cashQuoteError = null + cashQuoteLoading = false + } + + private fun scheduleCashAccountQuote(amount: String) { + if (!isCashAccountTransfer) return + val amountValue = amount.toBigDecimalOrNull() + if (amountValue == null || amountValue <= BigDecimal.ZERO) { + resetCashAccountQuote() + return + } + if (cashQuoteAmount == amount && (cashQuoteLoading || cashQuote != null || cashQuoteError != null)) return + + cashQuoteJob?.cancel() + cashQuoteAmount = amount + cashQuote = null + cashQuoteError = null + cashQuoteLoading = true + cashQuoteJob = viewLifecycleOwner.lifecycleScope.launch { + delay(CASH_ACCOUNT_QUOTE_DELAY_MS) + val quote = runCatching { + requestCashAccountQuote(amount) + }.onFailure { error -> + if (cashQuoteAmount == amount) { + cashQuoteError = ErrorHandler.getErrorMessage(error) + } + }.getOrNull() + if (cashQuoteAmount != amount) return@launch + cashQuote = quote + cashQuoteLoading = false + if (!viewDestroyed()) { + updateUI() + } + } + } + + private suspend fun requestCashAccountQuote(amount: String): QuoteResult? { + val sourceToken = token ?: return null + val response = withContext(Dispatchers.IO) { + web3ViewModel.web3Quote( + inputMint = sourceToken.assetId, + outputMint = Constants.AssetId.USDC_ASSET_SOL_ID, + amount = amount, + source = CASH_ACCOUNT_QUOTE_SOURCE, + ) + } + if (response.isSuccess) return response.data + cashQuoteError = response.errorDescription + return null + } + private fun shouldOfferLegacyWeb3FeeOption(): Boolean { return BuildConfig.DEBUG } @@ -1127,6 +1273,10 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private fun updateWeb3FeeDisplay() { val binding = bindingOrNull() ?: return val token = web3Token ?: return + if (isCashAccountTransfer) { + applyCashAccountInfo() + return + } if (binding.loadingProgressBar.isVisible) return val feeOptions = web3FeeOptions() if (hasGaslessFeeSelection()) { @@ -1288,6 +1438,9 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } if (value == "0") { + if (isCashAccountTransfer) { + resetCashAccountQuote() + } insufficientBalance.isVisible = false insufficientFeeBalance.isVisible = false insufficientFunds.isVisible = false @@ -1302,6 +1455,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC value } scheduleRefreshBtcFeeIfNeeded(v) + scheduleCashAccountQuote(v) if (isReverse && (v == "0" || BigDecimal(v) == BigDecimal.ZERO)) { insufficientBalance.isVisible = false insufficientFeeBalance.isVisible = false @@ -1309,7 +1463,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC continueVa.isEnabled = false continueTv.textColor = requireContext().getColor(R.color.wallet_text_gray) updateAddText() - } else if (BigDecimal(v) <= BigDecimal.ZERO){ + } else if (BigDecimal(v) <= BigDecimal.ZERO) { insufficientBalance.isVisible = false insufficientFeeBalance.isVisible = false insufficientFunds.isVisible = false @@ -1323,6 +1477,14 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC addTv.text = "${getString(R.string.Add)} ${token?.symbol ?: web3Token?.symbol ?: ""}" continueVa.isEnabled = false continueTv.textColor = requireContext().getColor(R.color.wallet_text_gray) + } else if (isCashAccountTransfer && isCurrentCashQuoteBelowMinimum(v)) { + insufficientBalance.isVisible = false + insufficientFeeBalance.isVisible = false + insufficientFunds.isVisible = true + insufficientFunds.text = cashQuoteUnavailableText(v) + addTv.text = "" + continueVa.isEnabled = false + continueTv.textColor = requireContext().getColor(R.color.wallet_text_gray) } else if (transferType != TransferType.WEB3 && (currentFee != null && feeTokensExtra == null || (currentFee?.token?.assetId == token?.assetId && BigDecimal(v).add(currentFee?.fee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) > (feeTokensExtra?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO)) || (currentFee?.token?.assetId != token?.assetId && (currentFee?.fee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) > (feeTokensExtra?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO))) @@ -1670,6 +1832,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private suspend fun refreshFee() { + if (isCashAccountTransfer) return when (transferType) { TransferType.ADDRESS -> { refreshFee(token!!) @@ -1749,6 +1912,10 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private fun setFeeLoading(isLoading: Boolean) { val binding = bindingOrNull() ?: return + if (isCashAccountTransfer) { + applyCashAccountInfo() + return + } binding.loadingProgressBar.isVisible = isLoading binding.contentTextView.isVisible = !isLoading } @@ -1811,6 +1978,92 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC setFeeLoading(false) } + private fun prepareCashAccountTransfer(amount: String) { + viewLifecycleOwner.lifecycleScope.launch( + CoroutineExceptionHandler { _, error -> + ErrorHandler.handleError(error) + if (alertDialog.isShowing) { + alertDialog.dismiss() + } + }, + ) { + val sourceToken = token ?: return@launch + alertDialog.show() + val quote = try { + requestCashAccountQuote(amount) + } finally { + if (alertDialog.isShowing) { + alertDialog.dismiss() + } + } + val receiveAmount = cashQuoteReceiveAmount(quote) + if (quote == null || receiveAmount == null || receiveAmount < cashMinimumReceiveAmount()) { + cashQuoteAmount = amount + cashQuote = quote + cashQuoteLoading = false + if (receiveAmount != null && receiveAmount < cashMinimumReceiveAmount()) { + cashQuoteError = null + } + updateUI() + return@launch + } + cashQuoteAmount = amount + cashQuote = quote + cashQuoteError = null + cashQuoteLoading = false + val cashBot = withContext(Dispatchers.IO) { + web3ViewModel.refreshUser(Constants.MIXIN_CASH_USER_ID) + } + if (cashBot == null) { + toast(R.string.Data_error) + return@launch + } + val biometricItem = buildTransferBiometricItem( + user = cashBot, + token = sourceToken, + amount = amount, + traceId = null, + memo = null, + returnTo = null, + cashReceiveAmount = quote.outAmount, + cashReceiveSymbol = CASH_ACCOUNT_RECEIVE_SYMBOL, + cashBalance = cashBalance, + ) + prepareCashAccountCheck(biometricItem) + } + } + + private fun prepareCashAccountCheck(item: TransferBiometricItem) { + viewLifecycleOwner.lifecycleScope.launch { + val rawTransaction = web3ViewModel.firstUnspentTransaction() + if (rawTransaction != null) { + WaitingBottomSheetDialogFragment.newInstance() + .showNow(parentFragmentManager, WaitingBottomSheetDialogFragment.TAG) + } else { + checkUtxo(item.amount) { + viewLifecycleOwner.lifecycleScope.launch { + val asset = item.asset ?: return@launch + val pair = web3ViewModel.findLatestTrace(item.users.first().userId, null, null, item.amount, asset.assetId) + if (pair.second) { + return@launch + } + item.trace = pair.first + AnalyticsTracker.trackAssetSendPreview() + CashAccountPreviewBottomSheetDialogFragment.newInstance(item).apply { + setCallback(object : CashAccountPreviewBottomSheetDialogFragment.Callback() { + override fun onDismiss(success: Boolean) { + if (success) { + finishTransferFlow() + } + } + }) + }.show(parentFragmentManager, CashAccountPreviewBottomSheetDialogFragment.TAG) + } + } + } + } + } + private fun prepareCheck(item: BiometricItem) { viewLifecycleOwner.lifecycleScope.launch { val amount = item.amount @@ -1889,42 +2142,45 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC setCallback(object : TransferBottomSheetDialogFragment.Callback() { override fun onDismiss(success: Boolean) { if (success) { - val navController = findNavController() - val backStackEntryCount = parentFragmentManager.backStackEntryCount - - val currentDestination = navController.currentDestination?.id - val startDestination = navController.graph.startDestinationId - val isStartDestination = currentDestination == startDestination || backStackEntryCount <= 1 - - if (isStartDestination) { - requireActivity().finish() - } else { - parentFragmentManager.apply { - var foundTransferDestFragment = false - val fragmentCount = backStackEntryCount - for (i in 0 until fragmentCount) { - val topFragment = fragments.lastOrNull() - if (topFragment is TransferDestinationInputFragment) { - // Found TransferDestinationInputFragment, pop it too - popBackStackImmediate() - foundTransferDestFragment = true - break - } else { - popBackStackImmediate() - } - } - - if (!foundTransferDestFragment) { - popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - } - } + finishTransferFlow() } } }) }.show(parentFragmentManager, TransferBottomSheetDialogFragment.TAG) } + private fun finishTransferFlow() { + val navController = findNavController() + val backStackEntryCount = parentFragmentManager.backStackEntryCount + + val currentDestination = navController.currentDestination?.id + val startDestination = navController.graph.startDestinationId + val isStartDestination = currentDestination == startDestination || backStackEntryCount <= 1 + + if (isStartDestination) { + requireActivity().finish() + } else { + parentFragmentManager.apply { + var foundTransferDestFragment = false + val fragmentCount = backStackEntryCount + for (i in 0 until fragmentCount) { + val topFragment = fragments.lastOrNull() + if (topFragment is TransferDestinationInputFragment) { + popBackStackImmediate() + foundTransferDestFragment = true + break + } else { + popBackStackImmediate() + } + } + + if (!foundTransferDestFragment) { + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + } + } + } + private suspend fun refreshWeb3Fees(t: Web3TokenItem) { setFeeLoading(true) try { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TransactionInterface.kt b/app/src/main/java/one/mixin/android/ui/wallet/TransactionInterface.kt index 09e49e3524..1d92b2de56 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TransactionInterface.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TransactionInterface.kt @@ -440,7 +440,7 @@ interface TransactionInterface { val start = fullText.lastIndexOf(label) val end = start + label.length - val backgroundColor: Int = Color.parseColor("#8DCC99") + val backgroundColor = Color.parseColor(WalletTransferLabelStyle.backgroundColorHex(label)) val backgroundColorSpan = RoundBackgroundColorSpan(backgroundColor, Color.WHITE) spannableString.setSpan(RelativeSizeSpan(0.8f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannableString.setSpan(backgroundColorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletBuyOptionsBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletBuyOptionsBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..4e834fa605 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletBuyOptionsBottomSheetDialogFragment.kt @@ -0,0 +1,245 @@ +package one.mixin.android.ui.wallet + +import android.os.Bundle +import android.view.View +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.dp as px +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground + +@AndroidEntryPoint +class WalletBuyOptionsBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "WalletBuyOptionsBottomSheetDialogFragment" + private const val ARGS_WALLET_NAME = "args_wallet_name" + private const val ARGS_WALLET_ICON_RES = "args_wallet_icon_res" + + fun newInstance( + walletName: String, + @DrawableRes walletIconRes: Int = 0, + ) = WalletBuyOptionsBottomSheetDialogFragment().apply { + arguments = Bundle().apply { + putString(ARGS_WALLET_NAME, walletName) + putInt(ARGS_WALLET_ICON_RES, walletIconRes) + } + } + } + + private var onGooglePayOrCard: (() -> Unit)? = null + private var onBankTransfer: (() -> Unit)? = null + + fun setOnGooglePayOrCard(callback: () -> Unit): WalletBuyOptionsBottomSheetDialogFragment { + onGooglePayOrCard = callback + return this + } + + fun setOnBankTransfer(callback: () -> Unit): WalletBuyOptionsBottomSheetDialogFragment { + onBankTransfer = callback + return this + } + + override fun getTheme() = R.style.AppTheme_Dialog + + @Composable + override fun ComposeContent() { + MixinAppTheme { + WalletBuyOptionsSheet( + walletName = requireArguments().getString(ARGS_WALLET_NAME).orEmpty(), + walletIconRes = requireArguments().getInt(ARGS_WALLET_ICON_RES), + onClose = { dismiss() }, + onGooglePayOrCard = { + dismiss() + onGooglePayOrCard?.invoke() + }, + onBankTransfer = { + dismiss() + onBankTransfer?.invoke() + }, + ) + } + } + + override fun getBottomSheetHeight(view: View): Int = 400.px + + override fun showError(error: String) = Unit +} + +@Composable +private fun WalletBuyOptionsSheet( + walletName: String, + @DrawableRes walletIconRes: Int, + onClose: () -> Unit, + onGooglePayOrCard: () -> Unit, + onBankTransfer: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MixinAppTheme.colors.backgroundWindow) + .padding(horizontal = 20.dp) + .padding(top = 24.dp, bottom = 20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.Buy), + color = MixinAppTheme.colors.textPrimary, + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.W600, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = walletName, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + if (walletIconRes != 0) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(walletIconRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(18.dp), + ) + } + } + } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundGrayLight) + .clickable(onClick = onClose), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_wallet_close), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(14.dp), + ) + } + } + Spacer(modifier = Modifier.height(28.dp)) + WalletBuyOptionItem( + iconRes = R.drawable.ic_wallet_buy_card, + title = stringResource(R.string.wallet_buy_option_google_pay_or_card), + description = stringResource(R.string.wallet_buy_option_google_pay_or_card_desc), + onClick = onGooglePayOrCard, + ) + Spacer(modifier = Modifier.height(12.dp)) + WalletBuyOptionItem( + iconRes = R.drawable.ic_wallet_buy_bank_transfer, + title = stringResource(R.string.wallet_buy_option_bank_transfer), + description = stringResource(R.string.wallet_buy_option_bank_transfer_desc), + showBadge = true, + onClick = onBankTransfer, + ) + } +} + +@Composable +private fun WalletBuyOptionItem( + @DrawableRes iconRes: Int, + title: String, + description: String, + showBadge: Boolean = false, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 82.dp) + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.background) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + color = MixinAppTheme.colors.textMinor, + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.W400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (showBadge) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(5.dp)) + .background(MixinAppTheme.colors.green) + .padding(horizontal = 6.dp, vertical = 1.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.wallet_buy_option_new), + color = Color.White, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W500, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeAllTokensFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeAllTokensFragment.kt index c4197f03be..3b7714d2d0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeAllTokensFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeAllTokensFragment.kt @@ -488,6 +488,7 @@ class WalletHomeAllTokensFragment : BaseFragment() { override fun onBannerClosed() = Unit override fun onReferralClicked() = Unit override fun onReferralClosed() = Unit + override fun onCashClicked() = Unit override fun onSupportClicked() = Unit override fun onHelpCenterClicked() = Unit override fun onBuyClicked() { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index c064426413..331e3f4406 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.wallet import android.annotation.SuppressLint +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -252,12 +253,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) ViewWalletFragmentHeaderBinding.bind(layoutInflater.inflate(R.layout.view_wallet_fragment_header, coinsRv, false)).apply { sendReceiveView.enableBuy() sendReceiveView.buy.setOnClickListener { - lifecycleScope.launch { - val wallet = web3ViewModel.findWalletById(walletId) - val chainId = web3ViewModel.getAddresses(walletId).first().chainId - if (showImportKeyReminderIfNeeded(wallet?.toWeb3Wallet(), chainId)) return@launch - WalletActivity.showBuy(requireActivity(), true, null, null, walletId) - } + showBuyOptionsBottomSheet() } sendReceiveView.send.setOnClickListener { lifecycleScope.launch { @@ -669,6 +665,51 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) private fun classicWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.CLASSIC, walletId) + private fun showBuyOptionsBottomSheet() { + WalletBuyOptionsBottomSheetDialogFragment.newInstance( + walletName = getString(R.string.Common_Wallet), + ) + .setOnGooglePayOrCard(::openClassicBuy) + .setOnBankTransfer { openCashHome(addBank = true) } + .showNow(parentFragmentManager, WalletBuyOptionsBottomSheetDialogFragment.TAG) + } + + private fun openClassicBuy() { + lifecycleScope.launch { + val wallet = web3ViewModel.findWalletById(walletId) + val chainId = web3ViewModel.getAddresses(walletId).first().chainId + if (showImportKeyReminderIfNeeded(wallet?.toWeb3Wallet(), chainId)) return@launch + WalletActivity.showBuy(requireActivity(), true, null, null, walletId) + } + } + + private fun openCashHome(addBank: Boolean = false) { + lifecycleScope.launch { + val app = web3ViewModel.findOrSyncApp(Constants.MIXIN_CASH_USER_ID) + val url = cashHomeUrl(app?.homeUri, addBank) + if (app == null) { + WebActivity.show(requireActivity(), url = url, app = null, conversationId = null) + } else { + WebActivity.show(requireActivity(), url = url, app = app, conversationId = null) + } + } + } + + private fun cashHomeUrl( + homeUri: String?, + addBank: Boolean, + ): String { + val url = homeUri.takeUnless { it.isNullOrBlank() } ?: Constants.API.CASH_HOME_URL + return if (addBank) { + Uri.parse(url).buildUpon() + .appendQueryParameter("action", "add-cash-bank") + .build() + .toString() + } else { + url + } + } + private val walletHomeCallbacks = object : WalletHomeCallbacks { override fun onAddWalletClicked() { AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) @@ -721,6 +762,8 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + override fun onCashClicked() = Unit + override fun onSupportClicked() { lifecycleScope.launch { val user = web3ViewModel.refreshUser(Constants.TEAM_MIXIN_USER_ID) @@ -879,7 +922,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) ) } WalletHomeBannerActionTarget.Buy -> { - WalletActivity.showBuy(requireActivity(), false, null, null) + showBuyOptionsBottomSheet() } is WalletHomeBannerActionTarget.Web -> { WebActivity.show(requireActivity(), target.url, null) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt index 6858ee9eae..1b2b243317 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomePrivacyFragment.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.wallet import android.annotation.SuppressLint +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -82,6 +83,7 @@ import one.mixin.android.ui.wallet.home.WalletHomeSection import one.mixin.android.ui.wallet.home.WalletHomeState import one.mixin.android.ui.wallet.home.WalletHomeBalanceHandoff import one.mixin.android.ui.wallet.home.WalletHomeBalanceSnapshot +import one.mixin.android.ui.wallet.home.WalletHomeCashAccount import one.mixin.android.ui.wallet.home.WalletHomeType import one.mixin.android.ui.wallet.home.calculateWalletHomeBtcTotal import one.mixin.android.ui.wallet.home.calculateWalletHomeTokenFiat @@ -90,7 +92,9 @@ import one.mixin.android.ui.wallet.home.formatWalletHomeBtcTotal import one.mixin.android.ui.wallet.home.getWalletHomeCacheState import one.mixin.android.ui.wallet.home.positionMarginUsdTotal import one.mixin.android.ui.wallet.home.putWalletHomeCache +import one.mixin.android.ui.wallet.home.toWalletHomeCashAccount import one.mixin.android.ui.wallet.home.toWalletHomePendingIndicator +import one.mixin.android.ui.wallet.home.walletHomeCashBalanceUsd import one.mixin.android.ui.wallet.home.walletHomeCacheKey import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment.Companion.TYPE_FROM_RECEIVE import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment.Companion.TYPE_FROM_SEND @@ -145,6 +149,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) private var isDynamicBannerLoaded = false private var closedDynamicBannerIds: Set = emptySet() private var perpsPositionsRefreshJob: Job? = null + private var cashAccount: WalletHomeCashAccount? = null private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() private var walletHomeDataState = WalletHomeDataState.EMPTY @@ -205,6 +210,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) _walletId.value = Session.getAccountId().orEmpty() refreshBitcoinPrice() refreshWalletHomeBanners() + refreshCashAccount() binding.apply { _headBinding = @@ -212,12 +218,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) sendReceiveView.isVisible = true sendReceiveView.enableBuy() sendReceiveView.buy.setOnClickListener { - lifecycleScope.launch { - WalletActivity.showBuy(requireActivity(), false, null, null) - defaultSharedPreferences.putBoolean(PREF_HAS_USED_BUY, false) - RxBus.publish(BadgeEvent(PREF_HAS_USED_BUY)) - sendReceiveView.buyBadge.isVisible = false - } + showBuyOptionsBottomSheet() } sendReceiveView.send.setOnClickListener { if ( @@ -390,10 +391,12 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) totalUsd = BigDecimal.valueOf(tokenSummary.totalUsd), fiatRate = fiatRate, ) + val currentCashAccount = cashAccount val totalFiat = calculateWalletHomeTotalFiat( tokenFiat = tokenFiat, positionUsd = positions.positionMarginUsdTotal(), fiatRate = fiatRate, + cashUsd = walletHomeCashBalanceUsd(currentCashAccount), ) val tokenBtc = calculateWalletHomeBtcTotal( tokenFiat = tokenFiat, @@ -417,6 +420,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) showBanner = showBanner, showReferral = showReferral, hasPositions = positions.isNotEmpty(), + hasCashAccount = currentCashAccount != null, hasTopMovers = topMovers.isNotEmpty(), hasTransactions = recentSnapshots.isNotEmpty(), hasPendingIndicator = pendingDisplays.isNotEmpty(), @@ -433,6 +437,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) privacyTransactions = recentSnapshots.take(WalletHomeSection.PREVIEW_LIMIT), positions = positions.take(WalletHomeSection.PREVIEW_LIMIT), positionSummary = positions.toWalletHomePositionSummary(), + cashAccount = currentCashAccount, totalTokenCount = tokenSummary.tokenCount, totalTransactionCount = recentSnapshots.size, totalPositionCount = positions.size, @@ -471,6 +476,23 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } } + private fun refreshCashAccount() { + lifecycleScope.launch { + runCatching { + walletViewModel.cashAccount() + }.onSuccess { response -> + if (response.isSuccess) { + cashAccount = response.data.toWalletHomeCashAccount() + renderHome() + } else { + Timber.w("Fetch cash account failed code=%s message=%s", response.errorCode, response.errorDescription) + } + }.onFailure { + Timber.w(it, "Fetch cash account failed") + } + } + } + private fun homeBitcoinPriceUsd(): BigDecimal? = tokenSummary.bitcoinPriceUsd ?.toBigDecimalOrNull() @@ -582,6 +604,10 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + override fun onCashClicked() { + openCashHome() + } + override fun onSupportClicked() { lifecycleScope.launch { val user = walletViewModel.refreshUser(Constants.TEAM_MIXIN_USER_ID) @@ -598,7 +624,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } override fun onBuyClicked() { - _headBinding?.sendReceiveView?.buy?.performClick() + showBuyOptionsBottomSheet() renderHome() } @@ -743,6 +769,48 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) AddWalletBottomSheetDialogFragment.newInstance().showNow(parentFragmentManager, AddWalletBottomSheetDialogFragment.TAG) } + private fun showBuyOptionsBottomSheet() { + WalletBuyOptionsBottomSheetDialogFragment.newInstance( + walletName = getString(R.string.Privacy_Wallet), + walletIconRes = R.drawable.ic_wallet_privacy, + ) + .setOnGooglePayOrCard { + WalletActivity.showBuy(requireActivity(), false, null, null) + defaultSharedPreferences.putBoolean(PREF_HAS_USED_BUY, false) + RxBus.publish(BadgeEvent(PREF_HAS_USED_BUY)) + _headBinding?.sendReceiveView?.buyBadge?.isVisible = false + } + .setOnBankTransfer { openCashHome(addBank = true) } + .showNow(parentFragmentManager, WalletBuyOptionsBottomSheetDialogFragment.TAG) + } + + private fun openCashHome(addBank: Boolean = false) { + lifecycleScope.launch { + val app = walletViewModel.findOrSyncApp(Constants.MIXIN_CASH_USER_ID) + val url = cashHomeUrl(app?.homeUri, addBank) + if (app == null) { + WebActivity.show(requireActivity(), url = url, app = null, conversationId = null) + } else { + WebActivity.show(requireActivity(), url = url, app = app, conversationId = null) + } + } + } + + private fun cashHomeUrl( + homeUri: String?, + addBank: Boolean, + ): String { + val url = homeUri.takeUnless { it.isNullOrBlank() } ?: Constants.API.CASH_HOME_URL + return if (addBank) { + Uri.parse(url).buildUpon() + .appendQueryParameter("action", "add-cash-bank") + .build() + .toString() + } else { + url + } + } + override fun onResume() { super.onResume() _walletId.value = Session.getAccountId().orEmpty() diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletTransferLabelStyle.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletTransferLabelStyle.kt new file mode 100644 index 0000000000..2b04b3b5c0 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletTransferLabelStyle.kt @@ -0,0 +1,38 @@ +package one.mixin.android.ui.wallet + +enum class WalletTransferLabelKind { + ADDRESS, + COMMON_WALLET, + CASH, +} + +object WalletTransferLabelStyle { + private const val CASH_ACCOUNT_LABEL = "Cash Account" + private const val CASH_BACKGROUND = "#AEE666" + private const val ADDRESS_BACKGROUND = "#66DDAA" + private const val COMMON_WALLET_BACKGROUND = "#66DDAA" + + fun resolve( + label: String?, + toWallet: Boolean = false, + ): WalletTransferLabelKind? { + if (label.isNullOrBlank()) return null + return when { + label.trim().equals(CASH_ACCOUNT_LABEL, ignoreCase = true) -> WalletTransferLabelKind.CASH + toWallet -> WalletTransferLabelKind.COMMON_WALLET + else -> WalletTransferLabelKind.ADDRESS + } + } + + fun backgroundColorHex(kind: WalletTransferLabelKind): String = + when (kind) { + WalletTransferLabelKind.ADDRESS -> ADDRESS_BACKGROUND + WalletTransferLabelKind.COMMON_WALLET -> COMMON_WALLET_BACKGROUND + WalletTransferLabelKind.CASH -> CASH_BACKGROUND + } + + fun backgroundColorHex( + label: String?, + toWallet: Boolean = false, + ): String = resolve(label, toWallet)?.let(::backgroundColorHex) ?: ADDRESS_BACKGROUND +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt index 73002de9d1..d7edb20ece 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletViewModel.kt @@ -27,6 +27,7 @@ import one.mixin.android.RxBus import one.mixin.android.api.MixinResponse import one.mixin.android.api.request.RouteTickerRequest import one.mixin.android.api.request.web3.WalletRequest +import one.mixin.android.api.response.CashAccount import one.mixin.android.api.response.ExportRequest import one.mixin.android.api.response.RouteTickerResponse import one.mixin.android.crypto.CryptoWalletHelper @@ -44,6 +45,7 @@ import one.mixin.android.job.RefreshTokensJob import one.mixin.android.job.RefreshTopAssetsJob import one.mixin.android.job.RefreshUserJob import one.mixin.android.repository.AccountRepository +import one.mixin.android.repository.CashRepository import one.mixin.android.repository.ReferralRepository import one.mixin.android.repository.TokenRepository import one.mixin.android.repository.UserRepository @@ -70,6 +72,7 @@ class WalletViewModel internal constructor( private val userRepository: UserRepository, private val accountRepository: AccountRepository, + private val cashRepository: CashRepository, private val web3Repository: Web3Repository, private val tokenRepository: TokenRepository, private val referralRepository: ReferralRepository, @@ -108,6 +111,11 @@ internal constructor( fun walletHomeTokenSummary() = tokenRepository.walletHomeTokenSummary() + suspend fun cashAccount(): MixinResponse = + withContext(Dispatchers.IO) { + cashRepository.account() + } + suspend fun assetItemsNotHiddenRaw(): List = withContext(Dispatchers.IO){ return@withContext tokenRepository.assetItemsNotHiddenRaw() } @@ -129,7 +137,7 @@ internal constructor( fun recentSnapshotsLimit() = tokenRepository.recentSnapshotsLimit() - fun findAddressByReceiver(receiver: String, tag: String, chainId: String?) = tokenRepository.findAddressByDestination(receiver, tag, chainId) + suspend fun findAddressByReceiver(receiver: String, tag: String, chainId: String?) = tokenRepository.findAddressByDestination(receiver, tag, chainId) suspend fun snapshotLocal( assetId: String, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/adapter/SnapshotHolder.kt b/app/src/main/java/one/mixin/android/ui/wallet/adapter/SnapshotHolder.kt index 4b9244cd69..f50213e238 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/adapter/SnapshotHolder.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/adapter/SnapshotHolder.kt @@ -22,6 +22,7 @@ import one.mixin.android.extension.numberFormat import one.mixin.android.extension.textColor import one.mixin.android.extension.timeAgoDay import one.mixin.android.ui.common.recyclerview.NormalHolder +import one.mixin.android.ui.wallet.WalletTransferLabelStyle import one.mixin.android.vo.SnapshotItem import one.mixin.android.vo.safe.SafeSnapshotType import one.mixin.android.widget.linktext.RoundBackgroundColorSpan @@ -126,7 +127,7 @@ open class SnapshotHolder( val start = fullText.lastIndexOf(label) val end = start + label.length - val backgroundColor: Int = Color.parseColor("#8DCC99") + val backgroundColor = Color.parseColor(WalletTransferLabelStyle.backgroundColorHex(label)) val backgroundColorSpan = RoundBackgroundColorSpan(backgroundColor, Color.WHITE) spannableString.setSpan(RelativeSizeSpan(0.8f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannableString.setSpan(backgroundColorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBalance.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBalance.kt index b562cdf2c2..e020c13fd8 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBalance.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBalance.kt @@ -8,7 +8,8 @@ internal fun calculateWalletHomeTotalFiat( tokenFiat: BigDecimal, positionUsd: BigDecimal, fiatRate: BigDecimal, -): BigDecimal = tokenFiat + positionUsd.multiply(fiatRate) + cashUsd: BigDecimal = BigDecimal.ZERO, +): BigDecimal = tokenFiat + positionUsd.add(cashUsd).multiply(fiatRate) internal fun calculateWalletHomeTokenFiat( totalUsd: BigDecimal, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBuilder.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBuilder.kt index 1d3bd8b741..04c225832b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBuilder.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeBuilder.kt @@ -7,6 +7,7 @@ object WalletHomeBuilder { showBanner: Boolean, showReferral: Boolean, hasPositions: Boolean, + hasCashAccount: Boolean = false, hasTopMovers: Boolean, hasTransactions: Boolean, hasImportKeyAction: Boolean = false, @@ -17,8 +18,13 @@ object WalletHomeBuilder { val cards = mutableListOf() - cards += if (hasAssetValue || hasImportKeyAction || hasPendingIndicator) WalletHomeCardType.BALANCE else WalletHomeCardType.EMPTY_GUIDE + cards += if (hasAssetValue || hasImportKeyAction || hasPendingIndicator || hasCashAccount) { + WalletHomeCardType.BALANCE + } else { + WalletHomeCardType.EMPTY_GUIDE + } if (showBanner) cards += WalletHomeCardType.BANNER + if (walletType == WalletHomeType.PRIVACY && hasCashAccount) cards += WalletHomeCardType.CASH val showTopMovers = walletType == WalletHomeType.PRIVACY && hasTopMovers if (walletType == WalletHomeType.PRIVACY && hasPositions) cards += WalletHomeCardType.POSITIONS diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCache.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCache.kt index ce97aecd9b..af275427cf 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCache.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCache.kt @@ -21,6 +21,7 @@ data class WalletHomeCache( val web3Transactions: List = emptyList(), val totalTokenCount: Int, val totalTransactionCount: Int, + val cashAccount: WalletHomeCashAccount? = null, val isWatchWallet: Boolean = false, val watchAddresses: List? = null, val pendingIndicator: WalletHomePendingIndicator? = null, @@ -36,6 +37,7 @@ data class WalletHomeCache( showBanner = false, showReferral = false, hasPositions = false, + hasCashAccount = cashAccount != null, hasTopMovers = false, hasTransactions = totalTransactionCount > 0, hasImportKeyAction = cachedImportKeyAction != null, @@ -53,6 +55,7 @@ data class WalletHomeCache( web3Tokens = web3Tokens, privacyTransactions = privacyTransactions, web3Transactions = web3Transactions, + cashAccount = cashAccount, totalTokenCount = totalTokenCount, totalTransactionCount = totalTransactionCount, isWatchWallet = isWatchWallet, @@ -93,6 +96,7 @@ fun SharedPreferences.putWalletHomeCache( state.totalTransactionCount == 0 && state.pendingIndicator == null && state.importKeyAction == null && + state.cashAccount == null && state.watchIndicator == null ) return val cache = WalletHomeCache( @@ -106,6 +110,7 @@ fun SharedPreferences.putWalletHomeCache( web3Transactions = state.web3Transactions.take(WalletHomeSection.PREVIEW_LIMIT), totalTokenCount = state.totalTokenCount, totalTransactionCount = state.totalTransactionCount, + cashAccount = state.cashAccount, isWatchWallet = state.isWatchWallet, watchAddresses = watchAddresses, pendingIndicator = state.pendingIndicator, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccount.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccount.kt new file mode 100644 index 0000000000..5db96fa4f2 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccount.kt @@ -0,0 +1,24 @@ +package one.mixin.android.ui.wallet.home + +import one.mixin.android.api.response.CashAccount +import one.mixin.android.extension.numberFormat2 +import java.math.BigDecimal + +data class WalletHomeCashAccount( + val balanceUsd: BigDecimal, +) { + val balanceAmountText: String + get() = balanceUsd.numberFormat2() +} + +internal fun CashAccount?.toWalletHomeCashAccount(): WalletHomeCashAccount? { + val account = this ?: return null + + return WalletHomeCashAccount( + balanceUsd = account.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO, + ) +} + +internal fun walletHomeCashBalanceUsd( + account: WalletHomeCashAccount?, +): BigDecimal = account?.balanceUsd ?: BigDecimal.ZERO diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeItem.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeItem.kt index 4b90153425..75827e9e6c 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeItem.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeItem.kt @@ -15,6 +15,7 @@ enum class WalletHomeCardType { EMPTY_GUIDE, BALANCE, BANNER, + CASH, POSITIONS, TOP_MOVERS, TOKENS, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt index 5889789d76..4d4af137df 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeState.kt @@ -28,6 +28,7 @@ data class WalletHomeState( val web3Transactions: List = emptyList(), val positions: List = emptyList(), val positionSummary: WalletHomePositionSummary? = null, + val cashAccount: WalletHomeCashAccount? = null, val totalTokenCount: Int = 0, val totalTransactionCount: Int = 0, val totalPositionCount: Int = 0, @@ -70,6 +71,7 @@ interface WalletHomeCallbacks { fun onDynamicBannerClosed(banner: WalletHomeBanner) = Unit fun onReferralClicked() fun onReferralClosed() + fun onCashClicked() fun onSupportClicked() fun onHelpCenterClicked() fun onBuyClicked() diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt index 31078f3a40..c729ba46a1 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt @@ -1,5 +1,7 @@ package one.mixin.android.ui.wallet.home.components +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,8 +22,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R @@ -34,6 +40,7 @@ import one.mixin.android.ui.wallet.home.PrivacyTokenRecycler import one.mixin.android.ui.wallet.home.PrivacyTransactionRecycler import one.mixin.android.ui.wallet.home.WalletHomeCallbacks import one.mixin.android.ui.wallet.home.WalletHomeCardType +import one.mixin.android.ui.wallet.home.WalletHomeCashAccount import one.mixin.android.ui.wallet.home.WalletHomeImportKeyAction import one.mixin.android.ui.wallet.home.WalletHomePositionSummary import one.mixin.android.ui.wallet.home.WalletHomeSection @@ -55,6 +62,7 @@ internal fun WalletHomeCard( return } } + if (card == WalletHomeCardType.CASH && state.cashAccount == null) return val contentPadding = when { card.hasSelfPaddedItems() -> Modifier @@ -67,6 +75,13 @@ internal fun WalletHomeCard( .padding(horizontal = 20.dp) .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .then( + if (card == WalletHomeCardType.CASH && state.cashAccount != null) { + Modifier.clickable { callbacks.onCashClicked() } + } else { + Modifier + }, + ) .then(contentPadding), ) { when (card) { @@ -77,6 +92,10 @@ internal fun WalletHomeCard( contentHorizontalPadding = 20.dp, ) WalletHomeCardType.BANNER -> Unit + WalletHomeCardType.CASH -> CashAccountCard( + cashAccount = state.cashAccount, + callbacks = callbacks, + ) WalletHomeCardType.POSITIONS -> SectionCard( title = stringResource(R.string.positions_count, state.totalPositionCount), showViewAll = WalletHomeSection.hasMore(state.totalPositionCount), @@ -172,6 +191,76 @@ internal fun WalletHomeCard( } } +@Composable +private fun CashAccountCard( + cashAccount: WalletHomeCashAccount?, + callbacks: WalletHomeCallbacks, +) { + if (cashAccount == null) return + + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_wallet_home_cash), + contentDescription = null, + modifier = Modifier.size(42.dp), + ) + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.cash_balance), + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.W400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(10.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_gray_right), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp).offset(x = 4.dp), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.W600)) { + append(cashAccount.balanceAmountText) + } + append(" ") + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.W500)) { + append("USD") + } + }, + color = MixinAppTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.cash_account_apy), + color = Color(0xFF5ECF72), + fontSize = 14.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.W400, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + @Composable private fun PositionSummaryHeader( summary: WalletHomePositionSummary?, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/CashAccountTransferContent.kt b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/CashAccountTransferContent.kt new file mode 100644 index 0000000000..cc187e983a --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/CashAccountTransferContent.kt @@ -0,0 +1,71 @@ +package one.mixin.android.ui.wallet.transfer.widget + +import android.content.Context +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import one.mixin.android.R +import one.mixin.android.databinding.ViewCashAccountTransferContentBinding +import one.mixin.android.extension.numberFormat2 +import one.mixin.android.extension.numberFormat8 +import one.mixin.android.ui.common.biometric.TransferBiometricItem +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +class CashAccountTransferContent : LinearLayout { + private val binding: ViewCashAccountTransferContentBinding + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + orientation = VERTICAL + binding = ViewCashAccountTransferContentBinding.inflate(LayoutInflater.from(context), this) + } + + fun render(item: TransferBiometricItem) { + val asset = item.asset ?: return + val receiveAmount = item.cashReceiveAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val balance = item.cashBalance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val symbol = item.cashReceiveSymbol.orEmpty() + val receiveText = "+${receiveAmount.numberFormat2()} $symbol" + val totalBalanceText = "${balance.plus(receiveAmount).numberFormat2()} $symbol" + val description = context.getString(R.string.cash_account_preview_description, receiveText, totalBalanceText) + val fiatAmount = (item.amount.toBigDecimalOrNull() ?: BigDecimal.ZERO) * asset.priceFiat() + + binding.receiveAmount.text = receiveText + binding.description.text = description.highlightAmounts(receiveText, totalBalanceText) + binding.payWithValue.text = context.getString( + R.string.cash_account_preview_pay_with_value, + item.amount.numberFormat8(), + asset.symbol, + Fiats.getSymbol(), + fiatAmount.numberFormat2(), + ) + binding.feeValue.text = "${Fiats.getSymbol()}0" + } + + fun setOnCloseClickListener(listener: OnClickListener) { + binding.close.setOnClickListener(listener) + } + + private fun String.highlightAmounts(vararg amounts: String): SpannableString { + val spannable = SpannableString(this) + val green = ContextCompat.getColor(context, R.color.wallet_green) + amounts.forEach { amount -> + val start = indexOf(amount) + if (start >= 0) { + spannable.setSpan( + ForegroundColorSpan(green), + start, + start + amount.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + return spannable + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContent.kt b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContent.kt index 96a2ac3079..13eaa7821a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContent.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContent.kt @@ -182,6 +182,7 @@ class TransferContent : LinearLayout { memo.isVisible = true memo.setContent(R.string.Memo, transferBiometricItem.memo ?: "") } + token.isVisible = false val tokenItem = transferBiometricItem.asset!! network.setContent(R.string.network, tokenItem.chainName ?: getChainNetwork(assetId = tokenItem.assetId, tokenItem.chainId, tokenItem.assetKey) ?: "") diff --git a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentItem.kt b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentItem.kt index df95631b7f..4e838a963b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentItem.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentItem.kt @@ -15,6 +15,7 @@ import one.mixin.android.R import one.mixin.android.databinding.ItemTransferContentBinding import one.mixin.android.extension.dp import one.mixin.android.extension.loadImage +import one.mixin.android.ui.wallet.WalletTransferLabelStyle import one.mixin.android.vo.safe.TokenItem import one.mixin.android.widget.CoilRoundedHexagonTransformation import one.mixin.android.widget.linktext.RoundBackgroundColorSpan @@ -103,7 +104,7 @@ class TransferContentItem : RelativeLayout { val start = fullText.lastIndexOf(label) val end = start + label.length - val backgroundColor: Int = if (toWallet) Color.parseColor("#B34B7CDD") else Color.parseColor("#8DCC99") + val backgroundColor = Color.parseColor(WalletTransferLabelStyle.backgroundColorHex(label, toWallet)) val backgroundColorSpan = RoundBackgroundColorSpan(backgroundColor, Color.WHITE) spannableString.setSpan(RelativeSizeSpan(0.8f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannableString.setSpan(backgroundColorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentSafeReceiveItem.kt b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentSafeReceiveItem.kt index 8b3ec03096..c8454b112b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentSafeReceiveItem.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/transfer/widget/TransferContentSafeReceiveItem.kt @@ -2,14 +2,10 @@ package one.mixin.android.ui.wallet.transfer.widget import android.annotation.SuppressLint import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF +import android.graphics.Color +import android.text.Spannable import android.text.SpannableString -import android.text.Spanned -import android.text.style.LineHeightSpan -import android.text.style.ReplacementSpan +import android.text.style.RelativeSizeSpan import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout @@ -18,9 +14,10 @@ import one.mixin.android.R import one.mixin.android.api.response.SafeTransactionRecipient import one.mixin.android.databinding.ItemTransferRecipientBinding import one.mixin.android.databinding.ItemTransferSafeReceiveContentBinding -import one.mixin.android.extension.colorAttr import one.mixin.android.extension.dp import one.mixin.android.extension.layoutInflater +import one.mixin.android.ui.wallet.WalletTransferLabelStyle +import one.mixin.android.widget.linktext.RoundBackgroundColorSpan class TransferContentSafeReceiveItem : LinearLayout { private val _binding: ItemTransferSafeReceiveContentBinding @@ -58,87 +55,15 @@ class TransferContentSafeReceiveItem : LinearLayout { } private fun createRecipientSpannable(label: String?, address: String): SpannableString { - val topBottomPadding = 2.5f.dp - val borderWidth = 1.dp - val leftRightPadding = 10.dp - val radius = 16.dp.toFloat() - val borderColor = context.colorAttr(R.attr.bg_window) - val textAssist = context.colorAttr(R.attr.text_assist) - val textMinor = context.colorAttr(R.attr.text_minor) - - return if (label != null) { - val labelText = "$label" - val spannableString = SpannableString("$labelText $address") - - val backgroundSpan = object : ReplacementSpan(), LineHeightSpan { - override fun getSize( - paint: Paint, - text: CharSequence, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - paint.getTextBounds(text.toString(), start, end, Rect()) - return paint.measureText(text, start, end).toInt() + leftRightPadding * 2 - } - - override fun draw( - canvas: Canvas, - text: CharSequence, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - val textWidth = paint.measureText(text, start, end) - - val rect = RectF( - x + borderWidth / 2, - y + paint.ascent() - topBottomPadding - borderWidth, - x + textWidth + leftRightPadding * 2, - y + paint.descent() + topBottomPadding + borderWidth - ) - - paint.color = borderColor - canvas.drawRoundRect(rect, radius, radius, paint) - - paint.style = Paint.Style.STROKE - paint.color = textAssist - paint.strokeWidth = borderWidth.toFloat() - canvas.drawRoundRect(rect, radius, radius, paint) - - paint.style = Paint.Style.FILL - paint.color = textAssist - canvas.drawText(text, start, end, x + leftRightPadding, y.toFloat(), paint) - } - - override fun chooseHeight( - text: CharSequence, - start: Int, - end: Int, - spanstartv: Int, - v: Int, - fm: Paint.FontMetricsInt - ) { - val originalHeight = fm.descent - fm.ascent - val totalPadding = (topBottomPadding + borderWidth) * 2 - - if (originalHeight < totalPadding) { - val extraPadding = totalPadding - originalHeight - - fm.ascent -= extraPadding / 2 - fm.descent += extraPadding / 2 - - fm.top = fm.ascent - fm.bottom = fm.descent - } - } - } - - spannableString.setSpan(backgroundSpan, 0, labelText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return if (!label.isNullOrBlank()) { + val fullText = "$address $label" + val spannableString = SpannableString(fullText) + val start = fullText.lastIndexOf(label) + val end = start + label.length + val backgroundColor = Color.parseColor(WalletTransferLabelStyle.backgroundColorHex(label)) + val backgroundColorSpan = RoundBackgroundColorSpan(backgroundColor, Color.WHITE) + spannableString.setSpan(RelativeSizeSpan(0.8f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannableString.setSpan(backgroundColorSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannableString } else { SpannableString(address) diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index b06c67e1ed..ed65e23554 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -232,6 +232,7 @@ object AnalyticsTracker { const val WALLET = "wallet" const val ADDRESS_BOOK = "address_book" const val CONTACT = "contact" + const val CASH_ACCOUNT = "cash_account" } fun trackAddressBookAddStart() { diff --git a/app/src/main/res/drawable/bg_round_wallet_green_tv.xml b/app/src/main/res/drawable/bg_round_wallet_green_tv.xml new file mode 100644 index 0000000000..af7c6fcb10 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_wallet_green_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_destination_cash.xml b/app/src/main/res/drawable/ic_destination_cash.xml new file mode 100644 index 0000000000..9036a0e184 --- /dev/null +++ b/app/src/main/res/drawable/ic_destination_cash.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_wallet_buy_bank_transfer.xml b/app/src/main/res/drawable/ic_wallet_buy_bank_transfer.xml new file mode 100644 index 0000000000..7ca92d1bfd --- /dev/null +++ b/app/src/main/res/drawable/ic_wallet_buy_bank_transfer.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_wallet_buy_card.xml b/app/src/main/res/drawable/ic_wallet_buy_card.xml new file mode 100644 index 0000000000..08a72bf09e --- /dev/null +++ b/app/src/main/res/drawable/ic_wallet_buy_card.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_wallet_home_cash.xml b/app/src/main/res/drawable/ic_wallet_home_cash.xml new file mode 100644 index 0000000000..349f2fcccf --- /dev/null +++ b/app/src/main/res/drawable/ic_wallet_home_cash.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_cash_account_preview_bottom_sheet.xml b/app/src/main/res/layout/fragment_cash_account_preview_bottom_sheet.xml new file mode 100644 index 0000000000..72069284cd --- /dev/null +++ b/app/src/main/res/layout/fragment_cash_account_preview_bottom_sheet.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_cash_account_transfer_content.xml b/app/src/main/res/layout/view_cash_account_transfer_content.xml new file mode 100644 index 0000000000..cf30333b39 --- /dev/null +++ b/app/src/main/res/layout/view_cash_account_transfer_content.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9c55ffb1e0..424d245738 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1435,6 +1435,7 @@ 当前转账使用的是旧网络 免费 转到我地址薄中的地址,地址受到 PIN 保护,避免地址钓鱼攻击。 + 轻松出售加密货币,账户余额自动赚取年化收益。 使用加密网络发送到我的另一个钱包。 转给我的 Mixin 好友,转账隐私、零手续费且秒到账。 转到 Mixin 好友的普通钱包,转账快捷又方便。 @@ -1518,7 +1519,6 @@ 正在删除地址,请稍候。 通过 PIN 发送 指定地址将收到 - 对方将收到 地址已添加 地址已删除 地址已修改 @@ -1963,6 +1963,7 @@ 可用余额:%1$s Mixin 联系人 地址簿 + 法币账户 需要 %1$s 以支付 %2$s 网络费用 转给 普通钱包 @@ -2525,12 +2526,22 @@ 添加资金 立即充值,开始交易并赚取收益 快捷买币 + Google Pay 或银行卡 + 使用 Google Pay、信用卡或借记卡,立刻购买加密货币。 + 银行转账 + 向法币账户充值,之后可随时购买加密货币。 链上充值 通过私钥、助记词或观察地址添加钱包 购买加密货币,获得 100% 手续费返现 立即领取 邀请返佣 邀请好友可获得高达 60% 的交易返利和会员佣金。 + Cash Balance + 3.5% APY + 最低到账金额为 %1$s %2$s + Add Cash + 你的法币账户预计收到 %1$s,总余额将增至 %2$s。 + %1$s %2$s(%3$s%4$s) 热门涨跌 代币 支持 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c00267e43..3f2edd5376 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1467,6 +1467,7 @@ Current transfer is using legacy network FREE Send to an address in your Address Book, PIN-protected to prevent phishing. + Sell crypto easily and automatically earn APY on your balance. Send to my other wallet using a crypto network. Send to your Mixin friends with privacy, zero fees, and instant delivery. Send to your friends’ Common Wallets quickly and easily. @@ -1550,7 +1551,6 @@ The request to delete the address is currently under verification by the Mixin server. Please wait a moment. Send by PIN Address will receive - Receiver will receive Address Added Address Deleted Address Edited @@ -2008,6 +2008,7 @@ Available: %1$s Mixin Contact Address Book + Cash Account You need %1$s on the %2$s network to cover the network fee Send To For your security, enter your PIN @@ -2602,12 +2603,23 @@ Fund your wallet Fund your wallet to start trading and earning Buy Crypto + Google Pay or Card + Use Google Pay, credit or debit card to buy crypto instantly. + Bank Transfer + Add money to your Cash Account, then buy crypto anytime. + NEW Receive Crypto Add Wallets via Private Key, Phrase, or Watch Address Buy crypto with 100% fee cashback Claim Now Referral Invite and get up to 60% trading fee and exclusive revenue sharing. + Cash Balance + 3.5% APY + Minimum received amount is %1$s %2$s + Add Cash + Your Cash Account is expected to receive %1$s, and the total balance will increase to %2$s. + %1$s %2$s (%3$s%4$s) Top Movers Tokens Support From 6bc52d6c6d17f732df469139bf92bf439a707a12 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 25 Jun 2026 19:50:51 +0800 Subject: [PATCH 14/14] feat(wallet): refine cash account send flow --- .../page/TransferDestinationInputPage.kt | 24 +-- .../mixin/android/ui/wallet/InputFragment.kt | 140 +++++++++++++++--- .../one/mixin/android/widget/TitleView.kt | 2 + app/src/main/res/drawable/bg_label.xml | 4 +- .../res/drawable/bg_label_cash_account.xml | 10 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 149 insertions(+), 35 deletions(-) create mode 100644 app/src/main/res/drawable/bg_label_cash_account.xml diff --git a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt index c8cf7d242a..cd69373e85 100644 --- a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt +++ b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt @@ -324,6 +324,18 @@ fun TransferDestinationInputPage( ) Spacer(modifier = Modifier.height(16.dp)) } + if (hasCashAccount) { + DestinationMenu( + icon = R.drawable.ic_destination_cash, + title = stringResource(R.string.Cash_Account), + subTile = stringResource(R.string.send_to_cash_account_description), + onClick = { + toCashAccount.invoke() + }, + badge = stringResource(R.string.cash_account_apy) + ) + Spacer(modifier = Modifier.height(16.dp)) + } if (token != null) { DestinationMenu( R.drawable.ic_destination_contact, @@ -347,18 +359,6 @@ fun TransferDestinationInputPage( ) Spacer(modifier = Modifier.height(16.dp)) } - if (hasCashAccount) { - DestinationMenu( - icon = R.drawable.ic_destination_cash, - title = stringResource(R.string.Cash_Account), - subTile = stringResource(R.string.send_to_cash_account_description), - onClick = { - toCashAccount.invoke() - }, - badge = stringResource(R.string.cash_account_apy) - ) - Spacer(modifier = Modifier.height(16.dp)) - } if (web3Token != null) { DestinationMenu( R.drawable.ic_destination_wallet, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt index 56c5cee704..3902bc40ea 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.withContext import one.mixin.android.BuildConfig import one.mixin.android.Constants import one.mixin.android.R +import one.mixin.android.api.MixinResponse import one.mixin.android.api.request.web3.GaslessFeeRequest import one.mixin.android.api.request.web3.GaslessTxRequest import one.mixin.android.api.request.web3.SubmitGaslessTxRequest @@ -87,6 +88,7 @@ import one.mixin.android.util.GsonHelper import one.mixin.android.util.analytics.AnalyticsTracker import one.mixin.android.util.analytics.AnalyticsTracker.TradeSource import one.mixin.android.util.analytics.AnalyticsTracker.TradeWallet +import one.mixin.android.util.getMixinErrorStringByCode import one.mixin.android.util.viewBinding import one.mixin.android.vo.Address import one.mixin.android.vo.Fiats @@ -233,6 +235,12 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private var cashQuote: QuoteResult? = null private var cashQuoteError: String? = null private var cashQuoteLoading = false + private var cashQuoteReviewing = false + + private data class CashAccountQuoteResult( + val quote: QuoteResult? = null, + val errorText: String? = null, + ) private var solanaRecipientAccountState = SolanaRecipientAccountState.EXISTS @@ -826,7 +834,12 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC private fun initTitle() { binding.apply { if (isCashAccountTransfer) { - titleView.setLabel(getString(R.string.Send_To_Title), getString(R.string.Cash_Account), "") + titleView.setLabel( + getString(R.string.Send_To_Title), + getString(R.string.Cash_Account), + "", + labelBackgroundRes = R.drawable.bg_label_cash_account, + ) addressLabel = getString(R.string.Cash_Account) return } @@ -958,6 +971,11 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC return receiveAmount < cashMinimumReceiveAmount() } + private fun shouldShowCurrentCashQuoteUnavailable(amount: String): Boolean { + if (!isCashAccountTransfer || cashQuoteAmount != amount) return false + return !cashQuoteError.isNullOrBlank() || isCurrentCashQuoteBelowMinimum(amount) + } + private fun cashQuoteUnavailableText(amount: String): String { val receiveAmount = cashQuoteReceiveAmount() val minimumAmount = cashMinimumReceiveAmount() @@ -977,10 +995,11 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC cashQuote = null cashQuoteError = null cashQuoteLoading = false + cashQuoteReviewing = false } private fun scheduleCashAccountQuote(amount: String) { - if (!isCashAccountTransfer) return + if (!isCashAccountTransfer || cashQuoteReviewing) return val amountValue = amount.toBigDecimalOrNull() if (amountValue == null || amountValue <= BigDecimal.ZERO) { resetCashAccountQuote() @@ -995,7 +1014,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC cashQuoteLoading = true cashQuoteJob = viewLifecycleOwner.lifecycleScope.launch { delay(CASH_ACCOUNT_QUOTE_DELAY_MS) - val quote = runCatching { + val result = runCatching { requestCashAccountQuote(amount) }.onFailure { error -> if (cashQuoteAmount == amount) { @@ -1003,7 +1022,8 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } }.getOrNull() if (cashQuoteAmount != amount) return@launch - cashQuote = quote + cashQuote = result?.quote + cashQuoteError = result?.errorText cashQuoteLoading = false if (!viewDestroyed()) { updateUI() @@ -1011,8 +1031,8 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } } - private suspend fun requestCashAccountQuote(amount: String): QuoteResult? { - val sourceToken = token ?: return null + private suspend fun requestCashAccountQuote(amount: String): CashAccountQuoteResult { + val sourceToken = token ?: return CashAccountQuoteResult(errorText = getString(R.string.Data_error)) val response = withContext(Dispatchers.IO) { web3ViewModel.web3Quote( inputMint = sourceToken.assetId, @@ -1021,9 +1041,61 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC source = CASH_ACCOUNT_QUOTE_SOURCE, ) } - if (response.isSuccess) return response.data - cashQuoteError = response.errorDescription - return null + if (response.isSuccess) { + return response.data?.let { CashAccountQuoteResult(quote = it) } + ?: CashAccountQuoteResult(errorText = getString(R.string.Data_error)) + } + return CashAccountQuoteResult(errorText = cashQuoteErrorText(response, sourceToken, amount)) + } + + private fun cashQuoteErrorText( + response: MixinResponse, + sourceToken: TokenItem, + amount: String, + ): String { + return when (response.errorCode) { + ErrorHandler.INVALID_QUOTE_AMOUNT -> { + val min = cashQuoteExtraDataValue(response.error?.extra, "min") + val max = cashQuoteExtraDataValue(response.error?.extra, "max") + val amountValue = amount.toBigDecimalOrNull() + val minValue = min?.toBigDecimalOrNull() + val maxValue = max?.toBigDecimalOrNull() + when { + min != null && minValue != null && (amountValue == null || amountValue < minValue) -> + getString(R.string.cash_account_minimum_send, min.numberFormat8(), sourceToken.symbol) + max != null && maxValue != null && amountValue != null && amountValue > maxValue -> + getString(R.string.cash_account_maximum_send, max.numberFormat8(), sourceToken.symbol) + min != null && max.isNullOrBlank() -> + getString(R.string.cash_account_minimum_send, min.numberFormat8(), sourceToken.symbol) + max != null -> + getString(R.string.cash_account_maximum_send, max.numberFormat8(), sourceToken.symbol) + else -> getString(R.string.error_invalid_quote_amount) + } + } + ErrorHandler.NO_AVAILABLE_QUOTE -> getString(R.string.error_no_available_quote) + ErrorHandler.INVALID_SWAP -> getString(R.string.error_invalid_swap) + else -> requireContext().getMixinErrorStringByCode( + response.errorCode, + response.error?.description ?: response.errorDescription, + ) + } + } + + private fun cashQuoteExtraDataValue( + extra: JsonElement?, + key: String, + ): String? { + val data = extra + ?.takeIf { it.isJsonObject } + ?.asJsonObject + ?.get("data") + ?.takeIf { it.isJsonObject } + ?.asJsonObject + ?: return null + return data.get(key) + ?.takeUnless { it.isJsonNull } + ?.asString + ?.takeIf { it.isNotBlank() } } private fun shouldOfferLegacyWeb3FeeOption(): Boolean { @@ -1477,7 +1549,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC addTv.text = "${getString(R.string.Add)} ${token?.symbol ?: web3Token?.symbol ?: ""}" continueVa.isEnabled = false continueTv.textColor = requireContext().getColor(R.color.wallet_text_gray) - } else if (isCashAccountTransfer && isCurrentCashQuoteBelowMinimum(v)) { + } else if (isCashAccountTransfer && shouldShowCurrentCashQuoteUnavailable(v)) { insufficientBalance.isVisible = false insufficientFeeBalance.isVisible = false insufficientFunds.isVisible = true @@ -1538,6 +1610,20 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC updateWeb3FeeDisplay() } applyFeeUi() + applyCashAccountReviewButtonState() + } + + private fun applyCashAccountReviewButtonState() { + val binding = bindingOrNull() ?: return + if (!isCashAccountTransfer) { + binding.continueVa.displayedChild = 0 + return + } + binding.continueTv.setText(R.string.Review) + binding.continueVa.displayedChild = if (cashQuoteReviewing) 1 else 0 + if (cashQuoteReviewing) { + binding.continueVa.isEnabled = false + } } private fun scheduleRefreshBtcFeeIfNeeded(amount: String) { @@ -1979,27 +2065,38 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private fun prepareCashAccountTransfer(amount: String) { + if (cashQuoteReviewing) return viewLifecycleOwner.lifecycleScope.launch( CoroutineExceptionHandler { _, error -> - ErrorHandler.handleError(error) - if (alertDialog.isShowing) { - alertDialog.dismiss() - } + cashQuoteAmount = amount + cashQuote = null + cashQuoteError = ErrorHandler.getErrorMessage(error) + cashQuoteLoading = false + cashQuoteReviewing = false + updateUI() }, ) { val sourceToken = token ?: return@launch - alertDialog.show() - val quote = try { - requestCashAccountQuote(amount) - } finally { - if (alertDialog.isShowing) { - alertDialog.dismiss() - } + cashQuoteJob?.cancel() + cashQuoteAmount = amount + cashQuote = null + cashQuoteError = null + cashQuoteLoading = false + cashQuoteReviewing = true + updateUI() + val result = requestCashAccountQuote(amount) + if (cashQuoteAmount != amount) { + cashQuoteReviewing = false + updateUI() + return@launch } + cashQuoteReviewing = false + val quote = result.quote val receiveAmount = cashQuoteReceiveAmount(quote) if (quote == null || receiveAmount == null || receiveAmount < cashMinimumReceiveAmount()) { cashQuoteAmount = amount cashQuote = quote + cashQuoteError = result.errorText cashQuoteLoading = false if (receiveAmount != null && receiveAmount < cashMinimumReceiveAmount()) { cashQuoteError = null @@ -2011,6 +2108,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC cashQuote = quote cashQuoteError = null cashQuoteLoading = false + updateUI() val cashBot = withContext(Dispatchers.IO) { web3ViewModel.refreshUser(Constants.MIXIN_CASH_USER_ID) } diff --git a/app/src/main/java/one/mixin/android/widget/TitleView.kt b/app/src/main/java/one/mixin/android/widget/TitleView.kt index 31d5113f8e..3ead165a12 100644 --- a/app/src/main/java/one/mixin/android/widget/TitleView.kt +++ b/app/src/main/java/one/mixin/android/widget/TitleView.kt @@ -181,6 +181,7 @@ class TitleView(context: Context, attrs: AttributeSet) : RelativeLayout(context, label: String?, content: String, index: Int = 0, + @DrawableRes labelBackgroundRes: Int = R.drawable.bg_label, ) { binding.titleTv.setTextOnly(title) if (index != 0) { @@ -196,6 +197,7 @@ class TitleView(context: Context, attrs: AttributeSet) : RelativeLayout(context, binding.subTitleTv.isVisible = false binding.labelTitleTv.isVisible = true binding.labelTitleTv.text = label + binding.labelTitleTv.setBackgroundResource(labelBackgroundRes) } else { binding.subTitleTv.isVisible = true binding.subTitleTv.setTextOnly(content) diff --git a/app/src/main/res/drawable/bg_label.xml b/app/src/main/res/drawable/bg_label.xml index c764c8a81d..05e086f9ac 100644 --- a/app/src/main/res/drawable/bg_label.xml +++ b/app/src/main/res/drawable/bg_label.xml @@ -1,10 +1,10 @@ - + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/bg_label_cash_account.xml b/app/src/main/res/drawable/bg_label_cash_account.xml new file mode 100644 index 0000000000..d3a846000a --- /dev/null +++ b/app/src/main/res/drawable/bg_label_cash_account.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 424d245738..aa60f95519 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2539,6 +2539,8 @@ Cash Balance 3.5% APY 最低到账金额为 %1$s %2$s + 错误 10614: 输入金额至少为 %1$s %2$s,请重新输入。 + 错误 10614: 输入金额最多为 %1$s %2$s,请重新输入。 Add Cash 你的法币账户预计收到 %1$s,总余额将增至 %2$s。 %1$s %2$s(%3$s%4$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f2edd5376..bc3c92ec39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2617,6 +2617,8 @@ Cash Balance 3.5% APY Minimum received amount is %1$s %2$s + ERROR 10614: Minimum amount is %1$s %2$s. + ERROR 10614: Maximum amount is %1$s %2$s. Add Cash Your Cash Account is expected to receive %1$s, and the total balance will increase to %2$s. %1$s %2$s (%3$s%4$s)