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 9796e1ec9e..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" @@ -536,13 +540,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/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/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/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/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/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 2c3e07af43..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 @@ -531,7 +534,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) @@ -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/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/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..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 @@ -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) { @@ -310,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, 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 3a2aeeffc9..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 @@ -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,8 +99,17 @@ 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) + 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/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/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..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,12 +27,14 @@ 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 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 @@ -86,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 @@ -131,6 +134,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 +193,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 +230,18 @@ 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 cashQuoteReviewing = false + + private data class CashAccountQuoteResult( + val quote: QuoteResult? = null, + val errorText: String? = null, + ) + private var solanaRecipientAccountState = SolanaRecipientAccountState.EXISTS @Inject @@ -225,6 +258,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 +570,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 +790,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 +806,43 @@ 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), + "", + labelBackgroundRes = R.drawable.bg_label_cash_account, + ) + addressLabel = getString(R.string.Cash_Account) + return + } when (transferType) { TransferType.USER -> { titleView.setSubTitle(getString(R.string.Send_To_Title), user) @@ -880,6 +959,145 @@ 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 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() + 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 + cashQuoteReviewing = false + } + + private fun scheduleCashAccountQuote(amount: String) { + if (!isCashAccountTransfer || cashQuoteReviewing) 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 result = runCatching { + requestCashAccountQuote(amount) + }.onFailure { error -> + if (cashQuoteAmount == amount) { + cashQuoteError = ErrorHandler.getErrorMessage(error) + } + }.getOrNull() + if (cashQuoteAmount != amount) return@launch + cashQuote = result?.quote + cashQuoteError = result?.errorText + cashQuoteLoading = false + if (!viewDestroyed()) { + updateUI() + } + } + } + + 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, + outputMint = Constants.AssetId.USDC_ASSET_SOL_ID, + amount = amount, + source = CASH_ACCOUNT_QUOTE_SOURCE, + ) + } + 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 { return BuildConfig.DEBUG } @@ -1127,6 +1345,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 +1510,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 +1527,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 +1535,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 +1549,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 && shouldShowCurrentCashQuoteUnavailable(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))) @@ -1376,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) { @@ -1670,6 +1918,7 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } private suspend fun refreshFee() { + if (isCashAccountTransfer) return when (transferType) { TransferType.ADDRESS -> { refreshFee(token!!) @@ -1749,6 +1998,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 +2064,104 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC setFeeLoading(false) } + private fun prepareCashAccountTransfer(amount: String) { + if (cashQuoteReviewing) return + viewLifecycleOwner.lifecycleScope.launch( + CoroutineExceptionHandler { _, error -> + cashQuoteAmount = amount + cashQuote = null + cashQuoteError = ErrorHandler.getErrorMessage(error) + cashQuoteLoading = false + cashQuoteReviewing = false + updateUI() + }, + ) { + val sourceToken = token ?: return@launch + 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 + } + updateUI() + return@launch + } + cashQuoteAmount = amount + cashQuote = quote + cashQuoteError = null + cashQuoteLoading = false + updateUI() + 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 +2240,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/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/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/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..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 @@ -30,6 +31,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 +52,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 +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,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 +166,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,18 +246,14 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) super.onViewCreated(view, savedInstanceState) Timber.e("onViewCreated called in WalletHomeClassicFragment") refreshBitcoinPrice() + refreshWalletHomeBanners() binding.apply { _headBinding = 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 { @@ -487,7 +496,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 +531,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 +605,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 @@ -619,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) @@ -629,6 +720,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 -> @@ -642,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) @@ -758,6 +880,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 -> { + showBuyOptionsBottomSheet() + } + 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 +969,7 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) lifecycleScope.launch { refreshWalletHomeMetadata(walletId) } + refreshWalletHomeBanners() } refreshJob = PendingTransactionRefreshHelper.startRefreshData( fragment = this, @@ -815,6 +988,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..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 @@ -35,6 +36,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 +52,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 @@ -77,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 @@ -85,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 @@ -136,7 +145,11 @@ 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 var cashAccount: WalletHomeCashAccount? = null private val assetsAdapter by lazy { WalletAssetAdapter(false) } private val perpetualViewModel by viewModels() private var walletHomeDataState = WalletHomeDataState.EMPTY @@ -196,6 +209,8 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) Timber.e("onViewCreated called in WalletHomePrivacyFragment") _walletId.value = Session.getAccountId().orEmpty() refreshBitcoinPrice() + refreshWalletHomeBanners() + refreshCashAccount() binding.apply { _headBinding = @@ -203,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 ( @@ -381,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, @@ -399,7 +411,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, @@ -407,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(), @@ -423,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, @@ -432,6 +447,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), @@ -459,12 +476,55 @@ 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() ?.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 +554,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 +562,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 -> @@ -515,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) @@ -531,7 +624,7 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } override fun onBuyClicked() { - _headBinding?.sendReceiveView?.buy?.performClick() + showBuyOptionsBottomSheet() renderHome() } @@ -672,6 +765,52 @@ class WalletHomePrivacyFragment : BaseFragment(R.layout.fragment_privacy_wallet) } } + private fun showAddWalletDialog() { + 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() @@ -695,6 +834,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/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 a98474f311..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,8 @@ 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 import one.mixin.android.repository.Web3Repository @@ -69,8 +72,10 @@ 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, private val assetRepository: AssetRepository, private val jobManager: MixinJobManager, private val pinCipher: PinCipher, @@ -106,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() } @@ -127,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, @@ -332,6 +342,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/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 13709f9c3b..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 @@ -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 @@ -26,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, @@ -38,6 +41,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,8 +66,12 @@ 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 onCashClicked() fun onSupportClicked() fun onHelpCenterClicked() fun onBuyClicked() 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/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 a7a118ee9e..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 @@ -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" @@ -220,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/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/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/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" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 485f905b57..aa60f95519 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 网络费用 转给 普通钱包 @@ -1992,6 +1993,7 @@ 所有资产 隐藏的资产不计入统计 重置隐藏偏好 + 重置钱包首页 Banner 删除 Web3 交易数据 确定要删除所有 Web3 交易数据吗?此操作不可恢复。删除后系统将重新同步数据。 预览手机号提醒弹窗 @@ -2524,12 +2526,24 @@ 添加资金 立即充值,开始交易并赚取收益 快捷买币 + Google Pay 或银行卡 + 使用 Google Pay、信用卡或借记卡,立刻购买加密货币。 + 银行转账 + 向法币账户充值,之后可随时购买加密货币。 链上充值 通过私钥、助记词或观察地址添加钱包 购买加密货币,获得 100% 手续费返现 立即领取 邀请返佣 邀请好友可获得高达 60% 的交易返利和会员佣金。 + 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 8909430af1..bc3c92ec39 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 @@ -2037,6 +2038,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 @@ -2601,12 +2603,25 @@ 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 + 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) Top Movers Tokens Support 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, + ) + } }