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..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) + .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/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..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 @@ -39,6 +40,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 +145,17 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { } resetTpslGuide.setOnClickListener { - resetDebugSharedPreferences() + resetHiddenDebugSharedPreferences() toast(R.string.Reset_TpSl_Guide) } + resetWalletHomeBanners.setOnClickListener { + lifecycleScope.launch { + resetWalletHomeBannerLocalState() + toast(R.string.Reset_Wallet_Home_Banners) + } + } + deleteWeb3Transactions.setOnClickListener { context?.let { ctx -> alertDialogBuilder() @@ -240,36 +249,22 @@ 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 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() { val dialog = @@ -317,3 +312,35 @@ 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) + 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/WalletFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletFragment.kt index 7ee8ba1583..a6ce95ca06 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 @@ -492,6 +492,22 @@ class WalletFragment : BaseFragment(R.layout.fragment_wallet) { } else { update() } + 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() { 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..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 @@ -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,7 @@ 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.toast import one.mixin.android.extension.withArgs import one.mixin.android.job.MixinJobManager @@ -62,6 +67,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 +140,9 @@ 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 var isDynamicBannerLoaded = false + private var closedDynamicBannerIds: Set = emptySet() private val assetsAdapter by lazy { WalletWeb3TokenAdapter(false) } private var distance = 0 @@ -155,6 +165,8 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) if (value != field) { field = value walletHomeDataState = WalletHomeDataState.EMPTY + dynamicBanners = emptyList() + isDynamicBannerLoaded = false _walletId.value = value loadWalletHomeCache() } @@ -233,6 +245,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 +500,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 = isDynamicBannerLoaded && (visibleDynamicBanners.isNotEmpty() || showAddWalletBanner) val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val currentImportKeyAction = importKeyAction val pendingCount = walletHomePendingTransactionCount(pendingRawTransactionCount, pendingTransactionCount) @@ -521,6 +535,8 @@ 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, ) @@ -593,6 +609,40 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) renderHome() } + fun refreshWalletHomeBanners() { + if (!isAdded || walletId.isEmpty()) return + lifecycleScope.launch { + 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() + } + } + + private suspend fun walletHomeBannerChains(): List = + web3ViewModel.getAddresses(walletId) + .map { it.chainId } + .filter(String::isNotBlank) + .distinct() + + private suspend fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = findWalletHomeDynamicBannerClosedIds() + val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) + if (syncedClosedBannerIds != closedBannerIds) { + updateWalletHomeDynamicBannerClosedIds(syncedClosedBannerIds) + } + closedDynamicBannerIds = syncedClosedBannerIds + } + private fun loadWalletHomeCache() { if (!isAdded || walletId.isEmpty()) return walletHomeDataState = WalletHomeDataState.EMPTY @@ -629,6 +679,35 @@ 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) { + lifecycleScope.launch { + val closedIds = closedDynamicBannerIds.toMutableSet().apply { add(banner.key) } + updateWalletHomeDynamicBannerClosedIds(closedIds) + closedDynamicBannerIds = closedIds + renderHome() + } + } + override fun onReferralClicked() { lifecycleScope.launch { web3ViewModel.findOrSyncApp(INTERNAL_REFERRAL_ID)?.let { app -> @@ -758,6 +837,56 @@ 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.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, + 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.MAIN, TradeSource.WALLET_HOME) + defaultSharedPreferences.putInt("${TradeFragment.PREF_TRADE_SELECTED_TAB_PREFIX}${Session.getAccountId().orEmpty()}", TradeFragment.TAB_PERPETUAL) + SwapActivity.show( + requireActivity(), + entrySource = TradeSource.WALLET_HOME, + entryType = AnalyticsTracker.SpotTradeType.PERPETUAL, + initialTab = TradeFragment.TAB_PERPETUAL, + ) + } + WalletHomeBannerActionTarget.Buy -> { + WalletActivity.showBuy(requireActivity(), false, null, null) + } + 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 +926,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) lifecycleScope.launch { refreshWalletHomeMetadata(walletId) } + refreshWalletHomeBanners() } refreshJob = PendingTransactionRefreshHelper.startRefreshData( fragment = this, @@ -815,6 +945,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..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,4 +1,34 @@ package one.mixin.android.ui.wallet +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" + +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 834ce96212..6858ee9eae 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 @@ -35,6 +35,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 @@ -47,6 +51,7 @@ 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 @@ -136,6 +141,9 @@ 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 var isDynamicBannerLoaded = false + private var closedDynamicBannerIds: Set = emptySet() private var perpsPositionsRefreshJob: Job? = null private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() @@ -196,6 +204,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 = @@ -399,7 +408,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 = isDynamicBannerLoaded && (visibleDynamicBanners.isNotEmpty() || showAddWalletBanner) val showReferral = !defaultSharedPreferences.getBoolean(PREF_WALLET_HOME_REFERRAL_CLOSED, false) val cards = WalletHomeBuilder.build( walletType = WalletHomeType.PRIVACY, @@ -432,6 +442,8 @@ 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), showSwapBadge = defaultSharedPreferences.getBoolean(PREF_HAS_USED_SWAP, true), @@ -465,6 +477,32 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) ?.takeIf { it > BigDecimal.ZERO } ?: bitcoinPriceUsd + fun refreshWalletHomeBanners() { + lifecycleScope.launch { + 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() + } + } + + private suspend fun syncClosedDynamicBannerIds(remoteBanners: List) { + val closedBannerIds = findWalletHomeDynamicBannerClosedIds() + val syncedClosedBannerIds = closedBannerIds.syncedWalletHomeClosedBannerIds(remoteBanners) + if (syncedClosedBannerIds != closedBannerIds) { + updateWalletHomeDynamicBannerClosedIds(syncedClosedBannerIds) + } + closedDynamicBannerIds = syncedClosedBannerIds + } private fun privacyWalletHomeCacheKey(): String = walletHomeCacheKey(WalletHomeType.PRIVACY, Session.getAccountId().orEmpty()) @@ -494,7 +532,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() { @@ -502,6 +540,35 @@ 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) { + lifecycleScope.launch { + val closedIds = closedDynamicBannerIds.toMutableSet().apply { add(banner.key) } + updateWalletHomeDynamicBannerClosedIds(closedIds) + closedDynamicBannerIds = closedIds + renderHome() + } + } + override fun onReferralClicked() { lifecycleScope.launch { walletViewModel.findOrSyncApp(INTERNAL_REFERRAL_ID)?.let { app -> @@ -672,6 +739,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() @@ -695,6 +766,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) jobManager.addJobInBackground(RefreshSnapshotsJob()) jobManager.addJobInBackground(SyncOutputJob()) refreshAllPendingDeposit() + refreshWalletHomeBanners() } else { stopPerpsPositionsRefresh() } 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..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 @@ -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,8 @@ 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, val showSwapBadge: Boolean = false, @@ -61,6 +65,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..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 @@ -24,68 +24,137 @@ 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.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 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 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 +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 import one.mixin.android.ui.wallet.home.WalletHomeState +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) { + val showAddWalletBanner = state.showAddWalletBanner && state.isDynamicBannerLoaded + val pages = remember(showAddWalletBanner, state.dynamicBanners) { buildList { - if (state.showAddWalletBanner) add(WalletHomeBannerPage.ADD_WALLET) + addAll(state.dynamicBanners.map(WalletHomeBannerPage::Dynamic)) + if (showAddWalletBanner) add(WalletHomeBannerPage.AddWallet) } } 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) { + 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) + } + } + } Column( 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() - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor), + .then(bannerHeightModifier) + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .then(cardClickModifier), ) { - Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { HorizontalPager( state = pagerState, modifier = Modifier .fillMaxWidth() .wrapContentHeight(), - verticalAlignment = Alignment.CenterVertically, + beyondViewportPageCount = pages.size, + verticalAlignment = Alignment.Top, ) { page -> - when (pages[page]) { - WalletHomeBannerPage.ADD_WALLET -> 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, - ) + 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, + ) + } } } } @@ -98,8 +167,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 +211,7 @@ private fun BannerCard( Row( modifier = Modifier .fillMaxWidth() - .padding(end = 22.dp), + .padding(top = 16.dp, end = 22.dp, bottom = 16.dp), verticalAlignment = Alignment.Top, ) { Image( @@ -153,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)) @@ -164,10 +237,86 @@ private fun BannerCard( text = stringResource(descriptionRes), color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } - Spacer(modifier = Modifier.height(12.dp)) - BannerAction(textRes = ctaRes, onClick = onClick) + Spacer(modifier = Modifier.height(8.dp)) + BannerAction(text = stringResource(ctaRes), onClick = onClick) + } + } +} + +@Composable +private fun DynamicBannerCard( + banner: WalletHomeBanner, + onActionClick: (WalletHomeBanner, WalletHomeBannerAction) -> Unit, +) { + val actions = banner.visibleActions + val description = banner.description?.takeIf { it.isNotBlank() } + val showDescription = actions.isEmpty() && description != null + val titleOnly = !showDescription + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, end = 22.dp, bottom = 16.dp), + verticalAlignment = Alignment.Top, + ) { + val iconUrl = banner.iconUrl?.takeIf { it.isNotBlank() } + if (iconUrl != null) { + CoilImageCompat( + model = iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + contentScale = ContentScale.Crop, + modifier = Modifier.size(42.dp), + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_avatar_place_holder), + contentDescription = null, + modifier = Modifier.size(42.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + 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 -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W400, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } else if (actions.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.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), + ) + } + } + } } } } @@ -246,7 +395,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 +413,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 +432,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..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 @@ -151,6 +151,18 @@ object AnalyticsTracker { } } + fun trackWalletHomeAdBanner(trackingKey: String?, source: String) { + val key = trackingKey?.takeIf { it.isNotBlank() } ?: return + logEvent(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/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..b6652cba18 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/wallet/WalletHomePreferencesTest.kt @@ -0,0 +1,14 @@ +package one.mixin.android.ui.wallet + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WalletHomePreferencesTest { + @Test + fun dynamicBannerClosedKeyIsGlobal() { + assertEquals( + PREF_WALLET_HOME_DYNAMIC_BANNER_CLOSED, + walletHomeDynamicBannerClosedKey(), + ) + } +} 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, + ) + } }