From d130988aabb57288ce0ee26786db9c13ebac56fd Mon Sep 17 00:00:00 2001 From: Meng Date: Thu, 20 Nov 2025 08:38:55 +0800 Subject: [PATCH 1/2] fix: sidebar and switch dialog --- app/build.gradle | 5 +- app/proguard-rules.pro | 1 + .../dialog/profile/ProfileSwitchDialog.kt | 340 +++++++++ .../dialog/profile/ProfileSwitchViewModel.kt | 153 ++++ .../wallet/page/main/MainActivity.kt | 11 +- .../page/main/drawer/DrawerLayoutViewModel.kt | 244 ++++++ .../wallet/page/main/model/WalletItemData.kt | 20 + .../main/presenter/DrawerLayoutContent.kt | 719 ++++++++++++++++++ .../presenter/ProfileFragmentPresenter.kt | 9 +- .../wallet/view/FadeAnimationBackground.kt | 6 +- .../page/wallet/view/LinkedAccountSection.kt | 216 ++++++ .../page/wallet/view/ProfileItemSection.kt | 193 +++++ .../page/wallet/view/WalletAccountSection.kt | 177 +++++ .../page/wallet/view/WalletItemSection.kt | 52 ++ .../wallet/viewmodel/ProfileItemViewModel.kt | 128 ++++ app/src/main/res/drawable/ic_add_profile.xml | 10 + app/src/main/res/drawable/ic_copy_address.xml | 14 + app/src/main/res/drawable/ic_link.xml | 17 + .../main/res/drawable/ic_recover_profile.xml | 10 + .../main/res/drawable/ic_switch_profile.xml | 80 ++ app/src/main/res/layout/activity_main.xml | 8 +- .../res/layout/layout_profile_user_info.xml | 19 +- app/src/main/res/values-night/colors.xml | 2 + app/src/main/res/values/colors.xml | 5 +- app/src/main/res/values/strings.xml | 4 + 25 files changed, 2407 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt create mode 100644 app/src/main/res/drawable/ic_add_profile.xml create mode 100644 app/src/main/res/drawable/ic_copy_address.xml create mode 100644 app/src/main/res/drawable/ic_link.xml create mode 100644 app/src/main/res/drawable/ic_recover_profile.xml create mode 100644 app/src/main/res/drawable/ic_switch_profile.xml diff --git a/app/build.gradle b/app/build.gradle index 48dbfc4f2..01430973c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -394,11 +394,14 @@ dependencies { implementation 'androidx.compose.ui:ui:1.7.8' implementation 'androidx.compose.ui:ui-tooling-preview:1.7.8' implementation 'androidx.compose.foundation:foundation:1.7.8' - implementation 'androidx.compose.material:material:1.7.8' + implementation 'androidx.compose.material3:material3-android:1.3.2' implementation 'androidx.compose.runtime:runtime-livedata:1.7.8' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4' implementation 'androidx.compose.ui:ui-tooling:1.7.8' + implementation "androidx.constraintlayout:constraintlayout-compose:1.1.0" + implementation("io.coil-kt.coil3:coil-compose:3.0.4") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") /** Google Drive **/ implementation('com.google.api-client:google-api-client:1.23.0') { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 69c876a3c..b44f58dd0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -197,6 +197,7 @@ -keep class org.onflow.flow.models.** { *; } -keep enum org.onflow.flow.** { *; } +-dontwarn coil3.PlatformContext -dontwarn java.lang.management.RuntimeMXBean -dontwarn com.google.devtools.build.android.desugar.runtime.ThrowableExtension -dontwarn com.google.protobuf.nano.CodedOutputByteBufferNano diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt new file mode 100644 index 000000000..e652408d1 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt @@ -0,0 +1,340 @@ +package com.flowfoundation.wallet.page.dialog.profile + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.fragment.app.FragmentManager +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.account.Account +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.model.LocalSwitchAccount +import com.flowfoundation.wallet.manager.app.isTestnet +import com.flowfoundation.wallet.page.restore.WalletRestoreActivity +import com.flowfoundation.wallet.page.walletcreate.WALLET_CREATE_STEP_USERNAME +import com.flowfoundation.wallet.page.walletcreate.WalletCreateActivity +import com.flowfoundation.wallet.page.wallet.view.ProfileItemSection +import com.flowfoundation.wallet.utils.getActivityFromContext +import com.flowfoundation.wallet.utils.logd +import com.flowfoundation.wallet.utils.uiScope +import com.flowfoundation.wallet.widgets.DialogType +import com.flowfoundation.wallet.widgets.ProgressDialog +import com.flowfoundation.wallet.widgets.SwitchNetworkDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class ProfileSwitchDialog: BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + ProfileSwitchContent( + onDismiss = { dismiss() } + ) + } + } + } + + companion object { + fun show(fragmentManager: FragmentManager) { + logd("ProfileSwitchDialog", "show() called") + ProfileSwitchDialog().showNow(fragmentManager, "ProfileSwitchDialog") + } + } +} + +@Composable +private fun ProfileSwitchContent( + onDismiss: () -> Unit +) { + val context = LocalContext.current + val activity = remember { getActivityFromContext(context) as FragmentActivity } + val viewModel = remember { ViewModelProvider(activity)[ProfileSwitchViewModel::class.java] } + + val switchItemList by viewModel.switchItemList.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + logd("ProfileSwitchDialog", "Starting to load switch account list") + viewModel.loadSwitchAccountList() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.deep_bg), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(18.dp) + ) { + // Header + Text( + text = stringResource(R.string.profiles), + color = colorResource(id = R.color.text_1), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + textAlign = TextAlign.Center + ) + + // Account List + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading...", + color = colorResource(id = R.color.text_2) + ) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f, false) + ) { + itemsIndexed(switchItemList) { index, switchItem -> + when (switchItem) { + is SwitchItemData.ProfileItem -> { + ProfileItemSection( + profile = switchItem.data.account, + isSelected = switchItem.data.account.isActive, + onProfileClick = { profileId -> + handleAccountSwitch(context, switchItem.data.account, onDismiss) + }, + avatarList = switchItem.data.avatarList, + balanceMap = switchItem.data.balanceMap + ) + } + is SwitchItemData.LocalSwitchItem -> { + LocalSwitchAccountItem( + account = switchItem.account, + onClick = { + handleLocalAccountSwitch(context, switchItem.account, onDismiss) + } + ) + } + } + + // Add divider between items (except for last item) + if (index < switchItemList.size - 1) { + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + } + + // Bottom Actions + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 18.dp) + ) { + // Create New Profile Button + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + logd("ProfileSwitchDialog", "Create new profile clicked") + if (isTestnet()) { + SwitchNetworkDialog(context, DialogType.CREATE).show() + } else { + WalletCreateActivity.launch(context, step = WALLET_CREATE_STEP_USERNAME) + onDismiss() + } + } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add_profile), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Create a new profile", + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier.fillMaxWidth() + ) + + // Recover Existing Profile Button + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + logd("ProfileSwitchDialog", "Recover existing profile clicked") + WalletRestoreActivity.launch(context) + onDismiss() + } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_recover_profile), + contentDescription = null, + tint = colorResource(id = R.color.text_1), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Recover an existing profile", + color = colorResource(id = R.color.text_1), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} + +@Composable +private fun LocalSwitchAccountItem( + account: LocalSwitchAccount, + onClick: () -> Unit +) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(18.dp) + .clickable(onClick = onClick) + ) { + val (icon, name, address) = createRefs() + + // Placeholder icon + Icon( + painter = painterResource(id = R.drawable.ic_placeholder), + contentDescription = "Account Icon", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top, 8.dp) + start.linkTo(parent.start) + } + .size(40.dp) + ) + + // Username + Text( + text = account.username, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(icon.top) + start.linkTo(icon.end, 12.dp) + end.linkTo(parent.end, 12.dp) + width = Dimension.fillToConstraints + } + ) + + // Address + Text( + text = account.address, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 4.dp) + start.linkTo(name.start) + } + ) + } +} + +private fun handleAccountSwitch(context: android.content.Context, account: Account, onDismiss: () -> Unit) { + if (isTestnet()) { + SwitchNetworkDialog(context, DialogType.SWITCH).show() + } else { + val progressDialog = ProgressDialog(context) + progressDialog.show() + AccountManager.switch(account) { + uiScope { + progressDialog.dismiss() + onDismiss() + } + } + } +} + +private fun handleLocalAccountSwitch(context: android.content.Context, account: LocalSwitchAccount, onDismiss: () -> Unit) { + if (isTestnet()) { + SwitchNetworkDialog(context, DialogType.SWITCH).show() + } else { + val progressDialog = ProgressDialog(context) + progressDialog.show() + AccountManager.switch(account) { + uiScope { + progressDialog.dismiss() + onDismiss() + } + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt new file mode 100644 index 000000000..2aee28a7a --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt @@ -0,0 +1,153 @@ +package com.flowfoundation.wallet.page.dialog.profile + +import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.manager.account.Account +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.model.LocalSwitchAccount +import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.network.retrofitApi +import com.flowfoundation.wallet.page.wallet.viewmodel.AvatarData +import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.ioScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.math.BigDecimal + +// Data class to represent a profile item with all its data +data class ProfileItemData( + val account: Account, + val avatarList: List, + val balanceMap: Map +) + +// Sealed class for different item types in the switch list +sealed class SwitchItemData { + data class ProfileItem(val data: ProfileItemData) : SwitchItemData() + data class LocalSwitchItem(val account: LocalSwitchAccount) : SwitchItemData() +} + +class ProfileSwitchViewModel : ViewModel() { + + private val _switchItemList = MutableStateFlow>(emptyList()) + val switchItemList: StateFlow> = _switchItemList.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } + + // Cache for verified COA addresses and their avatar data per profile + private val verifiedCoaAvatarsMap = mutableMapOf>() + + fun loadSwitchAccountList() { + ioScope { + val rawList = AccountManager.getSwitchAccountList() + val processedList = mutableListOf() + + rawList.forEach { item -> + when (item) { + is Account -> { + val profileData = fetchProfileData(item) + processedList.add(SwitchItemData.ProfileItem(profileData)) + } + is LocalSwitchAccount -> { + processedList.add(SwitchItemData.LocalSwitchItem(item)) + } + } + } + + _switchItemList.value = processedList + _isLoading.value = false + } + } + + private suspend fun fetchProfileData(profile: Account): ProfileItemData { + val wallet = profile.wallet ?: return ProfileItemData(profile, emptyList(), emptyMap()) + val addressList = mutableListOf() + val avatars = mutableListOf() + val address = wallet.walletAddress() ?: return ProfileItemData(profile, emptyList(), emptyMap()) + val profileId = wallet.id + + // Initialize cache for this profile if not exists + if (profileId !in verifiedCoaAvatarsMap) { + verifiedCoaAvatarsMap[profileId] = mutableMapOf() + } + val verifiedCoaAvatars = verifiedCoaAvatarsMap[profileId]!! + + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + + // Add main account emoji + avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) + addressList.add(address) + + // Add child accounts + WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> + addressList.add(childAccount.address) + if (childAccount.icon.isNotEmpty()) { + avatars.add(AvatarData.Icon(childAccount.icon)) + } else { + val childEmojiInfo = AccountEmojiManager.getEmojiByAddress(childAccount.address) + avatars.add(AvatarData.Emoji(childEmojiInfo.emojiId)) + } + } + + var pendingCoaAddress: String? = null + val coaAddress = profile.evmAddressData?.evmAddressMap?.get(address) + coaAddress?.let { coa -> + addressList.add(coa) + // Add to pending list for verification if not already verified + if (coa !in verifiedCoaAvatars) { + pendingCoaAddress = coa + } else { + // Add directly to avatars if already verified + avatars.add(verifiedCoaAvatars[coa]!!) + } + } + + // Fetch balances + val balanceMap = cadenceGetAllFlowBalance(addressList) ?: emptyMap() + val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> + "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" + } + + // Check if COA address should be added to avatars + pendingCoaAddress?.let { coaAddress -> + val coaBalance = balanceMap[coaAddress] + val hasBalance = coaBalance != null && coaBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(coaAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add COA address to avatar list + if (coaAddress !in verifiedCoaAvatars) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(coaAddress) + val avatarData = AvatarData.Emoji(emojiInfo.emojiId) + verifiedCoaAvatars[coaAddress] = avatarData + avatars.add(avatarData) + } + } else { + // Remove COA address from avatar list if it no longer has assets + if (coaAddress in verifiedCoaAvatars) { + val avatarToRemove = verifiedCoaAvatars[coaAddress] + verifiedCoaAvatars.remove(coaAddress) + avatars.remove(avatarToRemove) + } + } + } + + return ProfileItemData(profile, avatars, formattedBalanceMap) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt index 6132ee930..d2fb775c1 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt @@ -22,6 +22,7 @@ import com.flowfoundation.wallet.page.main.model.MainDrawerLayoutModel import com.flowfoundation.wallet.page.main.presenter.DrawerLayoutPresenter import com.flowfoundation.wallet.page.main.presenter.MainContentPresenter import com.flowfoundation.wallet.BuildConfig +import com.flowfoundation.wallet.page.main.presenter.setupDrawerLayoutCompose import com.flowfoundation.wallet.page.others.NotificationPermissionActivity import com.flowfoundation.wallet.page.window.WindowFrame import com.flowfoundation.wallet.utils.debug.fragments.debugViewer.DebugViewerDataSource @@ -61,10 +62,11 @@ class MainActivity : BaseActivity() { windowInsets } contentPresenter = MainContentPresenter(this, binding) - drawerLayoutPresenter = DrawerLayoutPresenter(binding.drawerLayout, binding.drawerLayoutContent) + setupDrawerLayoutCompose(binding.drawerLayout) + binding.drawerLayout.close() viewModel = ViewModelProvider(this)[MainActivityViewModel::class.java].apply { changeTabLiveData.observe(this@MainActivity) { contentPresenter.bind(MainContentModel(onChangeTab = it)) } - openDrawerLayoutLiveData.observe(this@MainActivity) { drawerLayoutPresenter.bind(MainDrawerLayoutModel(openDrawer = it)) } + openDrawerLayoutLiveData.observe(this@MainActivity) { binding.drawerLayout.open() } } uiScope { isRegistered = isRegistered() @@ -72,7 +74,7 @@ class MainActivity : BaseActivity() { firebaseInformationCheck() } contentPresenter.checkAndShowContent() - + // Navigate to target tab if specified if (targetTabIndex >= 0) { val targetTab = HomeTab.values().find { it.index == targetTabIndex } @@ -113,7 +115,6 @@ class MainActivity : BaseActivity() { if (isRegistered != isRegistered()) { contentPresenter.checkAndShowContent() } - drawerLayoutPresenter.bind(MainDrawerLayoutModel(refreshData = true)) } } @@ -170,4 +171,4 @@ class MainActivity : BaseActivity() { fun getInstance() = INSTANCE } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt new file mode 100644 index 000000000..cc47334dc --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt @@ -0,0 +1,244 @@ +package com.flowfoundation.wallet.page.main.drawer + +import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.OnWalletDataUpdate +import com.flowfoundation.wallet.manager.account.WalletFetcher +import com.flowfoundation.wallet.manager.app.NETWORK_NAME_MAINNET +import com.flowfoundation.wallet.manager.app.NETWORK_NAME_TESTNET +import com.flowfoundation.wallet.manager.app.chainNetWorkString +import com.flowfoundation.wallet.manager.childaccount.ChildAccount +import com.flowfoundation.wallet.manager.childaccount.ChildAccountList +import com.flowfoundation.wallet.manager.childaccount.ChildAccountUpdateListenerCallback +import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.emoji.OnEmojiUpdate +import com.flowfoundation.wallet.manager.evm.EVMWalletManager +import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.network.model.NftCollectionsResponse +import com.flowfoundation.wallet.network.model.WalletListData +import com.flowfoundation.wallet.network.retrofitApi +import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.network.model.UserInfoData +import com.flowfoundation.wallet.page.main.model.LinkedAccountData +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.wallet.toAddress +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.onflow.flow.ChainId +import java.math.BigDecimal + +class DrawerLayoutViewModel : ViewModel(), ChildAccountUpdateListenerCallback, OnWalletDataUpdate, OnEmojiUpdate { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _showEvmLayout = MutableStateFlow(false) + val showEvmLayout: StateFlow = _showEvmLayout.asStateFlow() + + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() + + private val _balanceMap = MutableStateFlow>(emptyMap()) + val balanceMap: StateFlow> = _balanceMap.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } + + // Cache for verified EVM addresses that should be included in linkedAccounts + private val verifiedEvmAddresses = mutableSetOf() + + init { + ChildAccountList.addAccountUpdateListener(this) + WalletFetcher.addListener(this) + AccountEmojiManager.addListener(this) + } + + fun loadData() { + loadEvmStatus() + refreshWalletList() + } + + private fun loadEvmStatus() { + _showEvmLayout.value = EVMWalletManager.showEVMEnablePage() + } + + fun refreshWalletList(refreshBalance: Boolean = false) { + ioScope { + _userInfo.value = AccountManager.userInfo() ?: return@ioScope + val wallet = WalletManager.wallet() ?: return@ioScope + val walletAddresses = wallet.accounts.mapNotNull { (chainId, accounts) -> + val isCurrentChain = when (chainNetWorkString()) { + NETWORK_NAME_MAINNET -> chainId == ChainId.Mainnet + NETWORK_NAME_TESTNET -> chainId == ChainId.Testnet + else -> false + } + if (isCurrentChain) { + accounts.map { it.address.toAddress() } + } else { + null + } + }.flatten() + val addressList = mutableListOf() + val accounts = mutableListOf() + val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping + val eoaAddress = WalletManager.getEOAAddressCached() + if (eoaAddress != null) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoaAddress) + addressList.add(eoaAddress) + accounts.add( + WalletAccountData( + address = eoaAddress, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == eoaAddress, + isEOAAccount = true + ) + ) + } + walletAddresses.forEach { address -> + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + val linkedAccounts = mutableListOf() + WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> + addressList.add(childAccount.address) + linkedAccounts.add( + LinkedAccountData( + address = childAccount.address, + name = childAccount.name, + icon = childAccount.icon, + emojiId = AccountEmojiManager.getEmojiByAddress(childAccount.address).emojiId, + isSelected = WalletManager.selectedWalletAddress() == childAccount.address, + isCOAAccount = false + ) + ) + } + EVMWalletManager.getEVMAddress()?.let { evmAddress -> + addressList.add(evmAddress) + // Add to pending list for verification if not already verified + if (evmAddress !in verifiedEvmAddresses) { + pendingEvmAddresses.add(Pair(evmAddress, address)) + } else { + // Add directly to linkedAccounts if already verified + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + linkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + } + } + accounts.add( + WalletAccountData( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == address, + linkedAccounts = linkedAccounts + ) + ) + addressList.add(address) + } + _accounts.value = accounts + if (refreshBalance) { + fetchAllBalances(addressList, pendingEvmAddresses) + } + } + } + + private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { + ioScope { + val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope + val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> + "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" + } + _balanceMap.value = formattedBalanceMap + + // Check each pending EVM address + pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> + val evmBalance = balanceMap[evmAddress] + val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(evmAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add EVM address to linked accounts + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + // Check if EVM address already exists in linked accounts + val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + if (!alreadyExists) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + // Add to verified cache for future refreshWalletList calls + verifiedEvmAddresses.add(evmAddress) + } + } else { + // Remove EVM address from linked accounts if it no longer has assets + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + if (existingLinkedAccount != null) { + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.removeAll { it.address == evmAddress } + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + } + // Remove from verified cache + verifiedEvmAddresses.remove(evmAddress) + } + } + } + } + + override fun onChildAccountUpdate(parentAddress: String, accounts: List) { + refreshWalletList(true) + } + + override fun onWalletDataUpdate(wallet: WalletListData) { + refreshWalletList(true) + } + + override fun onEmojiUpdate(userName: String, address: String, emojiId: Int, emojiName: String) { + refreshWalletList() + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt new file mode 100644 index 000000000..bec45f37c --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt @@ -0,0 +1,20 @@ +package com.flowfoundation.wallet.page.main.model + + +data class WalletAccountData( + val address: String, + val name: String, + val emojiId: Int, + val isSelected: Boolean, + val linkedAccounts: List = emptyList(), + val isEOAAccount: Boolean = false +) + +data class LinkedAccountData( + val address: String, + val name: String, + val icon: String? = null, + val emojiId: Int, + val isSelected: Boolean, + val isCOAAccount: Boolean +) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt new file mode 100644 index 000000000..fc0ddee04 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt @@ -0,0 +1,719 @@ +package com.flowfoundation.wallet.page.main.presenter + +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.app.doNetworkChangeTask +import com.flowfoundation.wallet.manager.emoji.model.Emoji +import com.flowfoundation.wallet.manager.evm.EVMWalletManager +import com.flowfoundation.wallet.manager.key.CryptoProviderManager +import com.flowfoundation.wallet.manager.nft.NftCollectionStateManager +import com.flowfoundation.wallet.manager.staking.StakingManager +import com.flowfoundation.wallet.manager.token.FungibleTokenListManager +import com.flowfoundation.wallet.manager.transaction.TransactionStateManager +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.network.clearWebViewCache +import com.flowfoundation.wallet.network.model.UserInfoData +import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog +import com.flowfoundation.wallet.page.evm.EnableEVMActivity +import com.flowfoundation.wallet.page.main.MainActivity +import com.flowfoundation.wallet.page.main.drawer.DrawerLayoutViewModel +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.page.restore.WalletRestoreActivity +import com.flowfoundation.wallet.page.wallet.view.LinkedAccountSection +import com.flowfoundation.wallet.page.wallet.view.WalletAccountSection +import com.flowfoundation.wallet.utils.Env +import com.flowfoundation.wallet.utils.ScreenUtils +import com.flowfoundation.wallet.utils.clearCacheDir +import com.flowfoundation.wallet.utils.getActivityFromContext +import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.parseAvatarUrl +import com.flowfoundation.wallet.utils.setMeowDomainClaimed +import com.flowfoundation.wallet.utils.shortenEVMString +import com.flowfoundation.wallet.utils.svgToPng +import com.flowfoundation.wallet.utils.textToClipboard +import com.flowfoundation.wallet.utils.toast +import com.flowfoundation.wallet.utils.uiScope +import com.flowfoundation.wallet.wallet.toAddress +import com.flowfoundation.wallet.widgets.FlowLoadingDialog +import kotlinx.coroutines.delay + +@Composable +fun DrawerLayoutCompose(drawer: DrawerLayout) { + val context = LocalContext.current + val activity = remember { getActivityFromContext(context) as FragmentActivity } + val viewModel = remember { ViewModelProvider(activity)[DrawerLayoutViewModel::class.java] } + + val userInfo by viewModel.userInfo.collectAsStateWithLifecycle() + val showEvmLayout by viewModel.showEvmLayout.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadData() + } + + DisposableEffect(drawer) { + val listener = object : DrawerLayout.DrawerListener { + override fun onDrawerOpened(drawerView: View) { + viewModel.loadData() + } + override fun onDrawerClosed(drawerView: View) {} + override fun onDrawerSlide(drawerView: View, slideOffset: Float) {} + override fun onDrawerStateChanged(newState: Int) {} + } + + drawer.addDrawerListener(listener) + onDispose { + drawer.removeDrawerListener(listener) + } + } + + Column( + modifier = Modifier + .fillMaxHeight() + .background(colorResource(id = R.color.deep_bg)) + .padding(horizontal = 18.dp, vertical = 24.dp) + ) { + userInfo?.let { + HeaderSection( + userInfo = it, + onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } + ) + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + ) + } + + if (showEvmLayout) { + Spacer(modifier = Modifier.height(24.dp)) + EVMSection( + onEvmClick = { + if (EVMWalletManager.haveEVMAddress()) { + drawer.close() + } else { + EnableEVMActivity.launch(activity) + } + } + ) + } + + AccountListSection( + accounts = accounts, + balanceMap = balanceMap, + onCopyClick = { address -> + textToClipboard(address) + toast(msgRes = R.string.copy_address_toast) + }, + onAccountClick = { address -> + FlowLoadingDialog(context).show() + WalletManager.selectWalletAddress(address) + ioScope { + delay(200) + doNetworkChangeTask() + clearCacheDir() + clearWebViewCache() + setMeowDomainClaimed(false) + NftCollectionStateManager.clear() + TransactionStateManager.reload() + FungibleTokenListManager.clear() + StakingManager.clear() + CryptoProviderManager.clear() + delay(1000) + uiScope { + MainActivity.relaunch(Env.getApp()) + } + } + }, + modifier = Modifier.weight(1f) + ) + + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier.fillMaxWidth() + ) + BottomSection( + onImportWalletClick = { + WalletRestoreActivity.launch(activity) + } + ) + } +} + +@Composable +fun HeaderSection( + userInfo: UserInfoData, + onAccountSwitchClick: () -> Unit +) { + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) + ) { + val (icon, name, switch) = createRefs() + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(name.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + + IconButton( + onClick = onAccountSwitchClick, + modifier = Modifier + .constrainAs(switch) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end, (-12).dp) + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_switch_profile), + contentDescription = "Switch Profile", + tint = colorResource(id = R.color.icon) + ) + } + } +} + +@Composable +fun EVMSection(onEvmClick: () -> Unit) { + ConstraintLayout(modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEvmClick) + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(vertical = 12.dp, horizontal = 16.dp) + ) { + val (title, desc, icon) = createRefs() + + val evm = stringResource(R.string.label_evm) + val raw = stringResource(R.string.add_evm_account, evm) + val parts = raw.split(evm) + + val inlineContentId = "evmLabel" + + val annotatedText = buildAnnotatedString { + append(parts[0]) + appendInlineContent(inlineContentId, "[EVM]") + if (parts.size > 1) { + append(parts[1]) + } + } + + val inlineContent = mapOf( + inlineContentId to InlineTextContent( + placeholder = Placeholder( + width = 40.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Box( + modifier = Modifier + .height(16.dp) + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 6.dp, vertical = 1.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = evm, + color = colorResource(id = R.color.text_1), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + ) + + Text( + text = annotatedText, + inlineContent = inlineContent, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(title) { + top.linkTo(parent.top) + bottom.linkTo(desc.top, 4.dp) + start.linkTo(parent.start) + end.linkTo(icon.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + + Text( + text = stringResource(R.string.enable_evm_desc), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(desc) { + top.linkTo(title.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(title.start) + end.linkTo(title.end) + } + ) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = "Arrow Right", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) + } +} + +@Composable +fun ActiveAccountSection( + item: WalletAccountData, + balanceMap: Map, + onCopyClick: (String) -> Unit, +) { + val activeLinkedAccount = item.linkedAccounts.firstOrNull { it.isSelected } + val activeAddress = if (item.isSelected) { + item.address + } else { + activeLinkedAccount?.address ?: "" + } + val balance = balanceMap[activeAddress] ?: "" + val activeEmojiId = if (item.isSelected) { + item.emojiId + } else { + activeLinkedAccount?.emojiId?: Emoji.EMPTY.id + } + val activeName = if (item.isSelected) { + item.name + } else { + activeLinkedAccount?.name?: "" + } + val activeIcon = if (item.isSelected) { + null + } else { + activeLinkedAccount?.icon + } + val walletEmojiId = item.emojiId + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(10.dp) + ) { + val (icon, walletIcon, name, address, balanceText, copy) = createRefs() + Box( + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.colorSecondary else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(parent.start, 7.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center + ) { + if (activeIcon.isNullOrEmpty()) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(activeEmojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(activeEmojiId), + fontSize = 18.sp + ) + } + } else { + AsyncImage( + model = activeIcon, + contentDescription = "Account Icon", + modifier = Modifier + .size(36.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } + } + + if (activeLinkedAccount != null) { + Box( + modifier = Modifier + .size(20.dp) + .border( + width = 1.dp, + color = colorResource(R.color.bg_card), + shape = CircleShape + ) + .constrainAs(walletIcon) { + start.linkTo(parent.start) + top.linkTo(icon.top) + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(18.dp) + .background( + color = Color(Emoji.getEmojiColorRes(walletEmojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(walletEmojiId), + fontSize = 12.sp + ) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 9.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } + ) { + if (activeLinkedAccount != null) { + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = "LinkedAccount", + tint = colorResource(id = R.color.icon), + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + + Text( + text = activeName, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) + ) + + if (activeLinkedAccount != null && activeLinkedAccount.isCOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + ConstraintLayout( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + val (evmLabel, flowLabel) = createRefs() + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp, + modifier = Modifier.constrainAs(evmLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(flowLabel.start) + } + ) + Box( + modifier = Modifier + .constrainAs(flowLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(evmLabel.end, margin = 4.dp) + end.linkTo(parent.end) + } + .background( + color = colorResource(R.color.evm_on_flow_end_color), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_flow), + color = colorResource(id = R.color.black), + fontSize = 8.sp + ) + } + } + } + else if (item.isEOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp + ) + } + } + } + + Text( + text = shortenEVMString(activeAddress), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(activeAddress) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } +} + +@Composable +fun AccountListSection( + accounts: List, + balanceMap: Map, + onCopyClick: (String) -> Unit, + onAccountClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + val activeAccount = accounts.firstOrNull { account -> account.isSelected || account.linkedAccounts.any { it.isSelected } } + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(id = R.string.active_account), + color = colorResource(id = R.color.text_2), + fontSize = 14.sp + ) + activeAccount?.let { + Spacer(modifier = Modifier.height(16.dp)) + ActiveAccountSection( + item = it, + balanceMap = balanceMap, + onCopyClick = onCopyClick + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.other_accounts), + color = colorResource(id = R.color.text_2), + fontSize = 14.sp, + ) + + accounts.forEach { account -> + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(account.address) } + ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(linkedAccount.address) } + ) + } + } + } +} + +@Composable +fun BottomSection( + onImportWalletClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onImportWalletClick), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_add_24), + contentDescription = "Add Account", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.add_account), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } +} + +fun setupDrawerLayoutCompose(drawer: DrawerLayout) { + val composeView = ComposeView(drawer.context) + composeView.setContent { + DrawerLayoutCompose(drawer) + } + + val layoutParams = DrawerLayout.LayoutParams( + (ScreenUtils.getScreenWidth() * 0.8f).toInt(), + DrawerLayout.LayoutParams.MATCH_PARENT + ).apply { + gravity = GravityCompat.START + } + composeView.layoutParams = layoutParams + + drawer.addView(composeView) +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt index 29fbc683f..b6059399f 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt @@ -15,7 +15,7 @@ import com.flowfoundation.wallet.manager.walletconnect.WalletConnect import com.flowfoundation.wallet.network.model.UserInfoData import com.flowfoundation.wallet.page.address.AddressBookActivity import com.flowfoundation.wallet.page.backup.WalletBackupActivity -import com.flowfoundation.wallet.page.dialog.accounts.AccountSwitchDialog +import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog import com.flowfoundation.wallet.page.inbox.InboxActivity import com.flowfoundation.wallet.page.main.HomeTab import com.flowfoundation.wallet.page.main.MainActivityViewModel @@ -61,7 +61,7 @@ class ProfileFragmentPresenter( } binding.userInfo.nicknameView.setOnClickListener { logd("ProfileFragmentPresenter", "nicknameView clicked") - AccountSwitchDialog.show(fragment.childFragmentManager) + ProfileSwitchDialog.show(fragment.childFragmentManager) } binding.notLoggedIn.root.setOnClickListener { ViewModelProvider(fragment.requireActivity())[MainActivityViewModel::class.java].changeTab( @@ -107,7 +107,7 @@ class ProfileFragmentPresenter( binding.group3.aboutPreference.setOnClickListener { AboutActivity.launch(context) } binding.group4.switchAccountPreference.setOnClickListener { logd("ProfileFragmentPresenter", "switchAccountPreference clicked") - AccountSwitchDialog.show(fragment.childFragmentManager) + ProfileSwitchDialog.show(fragment.childFragmentManager) } updatePreferenceState() @@ -127,7 +127,6 @@ class ProfileFragmentPresenter( this.userInfo = userInfo with(binding.userInfo) { if (isAvatarChange) avatarView.loadAvatar(userInfo.avatar) - useridView.text = userInfo.username nicknameView.text = userInfo.nickname avatarView.setOnClickListener { ViewAvatarActivity.launch(context, userInfo) } @@ -185,4 +184,4 @@ class ProfileFragmentPresenter( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/FadeAnimationBackground.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/FadeAnimationBackground.kt index 99c3e8743..74a9c46a2 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/FadeAnimationBackground.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/FadeAnimationBackground.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,7 +58,7 @@ fun FadeAnimationBackground( rows = numberOfRows, columns = itemPerRow, itemSize = heightPerItem, - color = if (colorInt == -1) MaterialTheme.colors.primary else { + color = if (colorInt == -1) MaterialTheme.colorScheme.primary else { Color(colorInt) }, rotationDegrees = 10f @@ -159,4 +159,4 @@ private fun calculateAlpha(row: Int, col:Int, alphaOne: Float, alphaTwo: Float, private fun getNumberOfRows(screenHeight: Int, heightPerItem: Int): Int { return (screenHeight / heightPerItem) + 2 -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt new file mode 100644 index 000000000..1b95354ca --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt @@ -0,0 +1,216 @@ +package com.flowfoundation.wallet.page.wallet.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import coil3.compose.AsyncImage +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.emoji.model.Emoji +import com.flowfoundation.wallet.page.main.model.LinkedAccountData +import com.flowfoundation.wallet.utils.shortenEVMString + + +@Composable +fun LinkedAccountSection( + item: LinkedAccountData, + balance: String, + onCopyClick: ((String) -> Unit)? = null, + onAccountClick: (() -> Unit)? = null +) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } + .alpha(if (item.isSelected) 1f else 0.7f) + ) { + val (linkedIcon, icon, name, address, balanceText, copy) = createRefs() + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = "LinkedAccount", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .constrainAs(linkedIcon) { + start.linkTo(parent.start, 14.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + Box( + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.accent_green else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(linkedIcon.end, 10.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center + ) { + if (item.icon.isNullOrEmpty()) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(item.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(item.emojiId), + fontSize = 18.sp + ) + } + } else { + AsyncImage( + model = item.icon, + contentDescription = "Account Icon", + modifier = Modifier + .size(36.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 8.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } + ) { + + Text( + text = item.name, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) + ) + + if (item.isCOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + ConstraintLayout( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + ) { + val (evmLabel, flowLabel) = createRefs() + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp, + modifier = Modifier.constrainAs(evmLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start, margin = 4.dp) + end.linkTo(flowLabel.start) + } + ) + Box( + modifier = Modifier + .constrainAs(flowLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(evmLabel.end, margin = 4.dp) + end.linkTo(parent.end) + } + .background( + color = colorResource(R.color.evm_on_flow_end_color), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_flow), + color = colorResource(id = R.color.black), + fontSize = 8.sp + ) + } + } + } + } + Text( + text = shortenEVMString(item.address), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + if (onCopyClick!= null) { + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(item.address) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt new file mode 100644 index 000000000..ad2b19300 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt @@ -0,0 +1,193 @@ +package com.flowfoundation.wallet.page.wallet.view + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.account.Account +import com.flowfoundation.wallet.manager.emoji.model.Emoji +import com.flowfoundation.wallet.page.wallet.viewmodel.AvatarData +import com.flowfoundation.wallet.page.wallet.viewmodel.ProfileItemViewModel +import com.flowfoundation.wallet.utils.getActivityFromContext +import com.flowfoundation.wallet.utils.parseAvatarUrl +import com.flowfoundation.wallet.utils.svgToPng + +@Composable +fun ProfileItemSection( + profile: Account, + isSelected: Boolean, + onProfileClick: (String) -> Unit, + avatarList: List = emptyList(), + balanceMap: Map = emptyMap() +) { + + val userInfo = profile.userInfo + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } + + // Calculate total balance + val totalBalance = balanceMap.values.sumOf { balance -> + balance.replace(" FLOW", "").replace(",", "").toDoubleOrNull() ?: 0.0 + } + + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp) + .clickable(onClick = { onProfileClick(profile.wallet?.id ?: "" )}) + ) { + val (icon, name, balance, accountCount, avatarRow, switch) = createRefs() + + // Avatar + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + start.linkTo(parent.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + + // Name + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(icon.top) + start.linkTo(icon.end, 12.dp) + end.linkTo(switch.start, 12.dp) + width = Dimension.fillToConstraints + } + ) + + // Balance + Text( + text = String.format("%.2f FLOW", totalBalance), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balance) { + top.linkTo(name.bottom, 4.dp) + start.linkTo(name.start) + } + ) + + // Account count + Text( + text = "${avatarList.size} Accounts", + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(accountCount) { + top.linkTo(balance.bottom, 4.dp) + start.linkTo(name.start) + } + ) + + // Avatar LazyRow + LazyRow( + modifier = Modifier + .constrainAs(avatarRow) { + top.linkTo(accountCount.top) + bottom.linkTo(accountCount.bottom) + start.linkTo(accountCount.end, 8.dp) + end.linkTo(switch.start, 12.dp) + width = Dimension.fillToConstraints + }, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(avatarList) { avatarData -> + when (avatarData) { + is AvatarData.Icon -> { + AsyncImage( + model = avatarData.url, + contentDescription = "Account Icon", + modifier = Modifier + .size(14.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } + is AvatarData.Emoji -> { + Box( + modifier = Modifier + .size(14.dp) + .background( + color = Color(Emoji.getEmojiColorRes(avatarData.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(avatarData.emojiId), + fontSize = 8.sp + ) + } + } + } + } + } + + // Switch icon + Icon( + painter = painterResource(id = R.drawable.ic_check_round), + contentDescription = "Select Profile", + tint = colorResource(id = if (isSelected) R.color.accent_green else R.color.icon), + modifier = Modifier + .constrainAs(switch) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt new file mode 100644 index 000000000..a8cf9871b --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt @@ -0,0 +1,177 @@ +package com.flowfoundation.wallet.page.wallet.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.emoji.model.Emoji +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.utils.shortenEVMString + + +@Composable +fun WalletAccountSection( + item: WalletAccountData, + balance: String, + isSelected: Boolean = false, + canSelected: Boolean = false, + onCopyClick: ((String) -> Unit)? = null, + onAccountClick: (() -> Unit)? = null +) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } + .padding(vertical = 12.dp) + .alpha(if (item.isSelected) 1f else 0.7f) + ) { + val (icon, name, address, balanceText, copy) = createRefs() + Box( + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.accent_green else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(item.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(item.emojiId), + fontSize = 18.sp + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 8.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } + ) { + Text( + text = item.name, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) + ) + if (item.isEOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp + ) + } + } + } + Text( + text = shortenEVMString(item.address), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + if (onCopyClick != null) { + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(item.address) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } else if (canSelected) { + Icon( + painter = painterResource(id = R.drawable.ic_check_round), + contentDescription = "Select Profile", + tint = colorResource(id = if (isSelected) R.color.icon else R.color.accent_green), + modifier = Modifier + .size(20.dp) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt new file mode 100644 index 000000000..b44e272c7 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt @@ -0,0 +1,52 @@ +package com.flowfoundation.wallet.page.wallet.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.wallet.toAddress + + +@Composable +fun WalletItemSection( + account: WalletAccountData, + balanceMap: Map, + onItemSelected: (WalletAccountData) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxHeight() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 18.dp, vertical = 24.dp) + .clickable(onClick = { onItemSelected(account)}) + ) { + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "" + ) + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "" + ) + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt new file mode 100644 index 000000000..51ec93754 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt @@ -0,0 +1,128 @@ +package com.flowfoundation.wallet.page.wallet.viewmodel + +import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.manager.account.Account +import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.network.retrofitApi +import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.ioScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.math.BigDecimal +import kotlin.collections.forEach + +// Sealed class to represent avatar data (either icon URL or emoji ID) +sealed class AvatarData { + data class Icon(val url: String) : AvatarData() + data class Emoji(val emojiId: Int) : AvatarData() +} + +class ProfileItemViewModel: ViewModel() { + private val _avatarList = MutableStateFlow>(emptyList()) + val avatarList: StateFlow> = _avatarList.asStateFlow() + + private val _balanceMap = MutableStateFlow>(emptyMap()) + val balanceMap: StateFlow> = _balanceMap.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } + + // Cache for verified COA addresses and their avatar data + private val verifiedCoaAvatars = mutableMapOf() + + fun fetchWalletList(profile: Account) { + ioScope { + val wallet = profile.wallet ?: return@ioScope + val addressList = mutableListOf() + val avatars = mutableListOf() + val address = wallet.walletAddress() ?: return@ioScope + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + + // Add main account emoji + avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) + addressList.add(address) + + // Add child accounts + WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> + addressList.add(childAccount.address) + if (childAccount.icon.isNotEmpty()) { + avatars.add(AvatarData.Icon(childAccount.icon)) + } else { + val childEmojiInfo = AccountEmojiManager.getEmojiByAddress(childAccount.address) + avatars.add(AvatarData.Emoji(childEmojiInfo.emojiId)) + } + } + + var pendingCoaAddress: String? = null + val coaAddress = profile.evmAddressData?.evmAddressMap?.get(address) + coaAddress?.let { coa -> + addressList.add(coa) + // Add to pending list for verification if not already verified + if (coa !in verifiedCoaAvatars) { + pendingCoaAddress = coa + } else { + // Add directly to avatars if already verified + avatars.add(verifiedCoaAvatars[coa]!!) + } + } + + _avatarList.value = avatars + fetchAllBalances(addressList, pendingCoaAddress) + } + } + + private fun fetchAllBalances(addressList: List, pendingCoaAddress: String?) { + ioScope { + val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope + val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> + "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" + } + _balanceMap.value = formattedBalanceMap + + // Check if COA address should be added to linkedAccounts + pendingCoaAddress?.let { coaAddress -> + val coaBalance = balanceMap[coaAddress] + val hasBalance = coaBalance != null && coaBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(coaAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add COA address to avatar list + if (coaAddress !in verifiedCoaAvatars) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(coaAddress) + val avatarData = AvatarData.Emoji(emojiInfo.emojiId) + verifiedCoaAvatars[coaAddress] = avatarData + + // Update avatar list + val currentAvatars = _avatarList.value.toMutableList() + currentAvatars.add(avatarData) + _avatarList.value = currentAvatars + } + } else { + // Remove COA address from avatar list if it no longer has assets + if (coaAddress in verifiedCoaAvatars) { + val avatarToRemove = verifiedCoaAvatars[coaAddress] + verifiedCoaAvatars.remove(coaAddress) + + // Update avatar list + val currentAvatars = _avatarList.value.toMutableList() + currentAvatars.remove(avatarToRemove) + _avatarList.value = currentAvatars + } + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_add_profile.xml b/app/src/main/res/drawable/ic_add_profile.xml new file mode 100644 index 000000000..3a6028eda --- /dev/null +++ b/app/src/main/res/drawable/ic_add_profile.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy_address.xml b/app/src/main/res/drawable/ic_copy_address.xml new file mode 100644 index 000000000..96ac53d24 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_address.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 000000000..df4dadf7a --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_recover_profile.xml b/app/src/main/res/drawable/ic_recover_profile.xml new file mode 100644 index 000000000..e431f1193 --- /dev/null +++ b/app/src/main/res/drawable/ic_recover_profile.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_switch_profile.xml b/app/src/main/res/drawable/ic_switch_profile.xml new file mode 100644 index 000000000..ec9d98ac0 --- /dev/null +++ b/app/src/main/res/drawable/ic_switch_profile.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 066d75469..0049d27e8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -48,13 +48,13 @@ - + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/layout_profile_user_info.xml b/app/src/main/res/layout/layout_profile_user_info.xml index 92b056890..9a9729a67 100644 --- a/app/src/main/res/layout/layout_profile_user_info.xml +++ b/app/src/main/res/layout/layout_profile_user_info.xml @@ -29,7 +29,7 @@ android:fontFamily="@font/inter_semi_bold" android:textColor="@color/text" android:textSize="16sp" - app:layout_constraintBottom_toTopOf="@id/userid_view" + app:layout_constraintBottom_toBottomOf="@id/avatar_view" app:layout_constraintStart_toEndOf="@id/avatar_view" app:layout_constraintTop_toTopOf="@id/avatar_view" app:layout_constraintVertical_chainStyle="packed" @@ -38,21 +38,6 @@ app:drawableEndCompat="@drawable/ic_baseline_arrow_down_18" app:drawableTint="@color/accent_gray"/> - - - \ No newline at end of file + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index a5ac82b27..5e94ba539 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -130,5 +130,7 @@ #33FFFFFF #F04438 #F97066 + + #1A1A1A diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a415056f0..54fa072d1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -225,4 +225,7 @@ #FFFFFF #F04438 #F97066 - \ No newline at end of file + + #F2F2F7 + #767676 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2206ad304..9544c5423 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -560,6 +560,7 @@ EVM on Flow FlowEVM EVM + FLOW EOA Move Current Network @@ -788,4 +789,7 @@ My Own Address Custom Address Enter custom address + Add an %1$s account on Flow + Active account + Add account From df37a9a0da30dfaf5331bc89cabc755b8008ce29 Mon Sep 17 00:00:00 2001 From: Meng Date: Thu, 20 Nov 2025 13:18:09 +0800 Subject: [PATCH 2/2] fix: account list ui --- app/src/main/AndroidManifest.xml | 4 + .../wallet/manager/LaunchManager.kt | 6 +- .../account/AccountVisibilityManager.kt | 171 +++ .../wallet/manager/evm/EVMWalletManager.kt | 16 + .../page/account/AccountListActivity.kt | 167 +++ .../page/account/AccountListViewModel.kt | 236 ++++ .../dialog/profile/ProfileSwitchDialog.kt | 5 +- .../dialog/profile/ProfileSwitchViewModel.kt | 38 +- .../page/main/drawer/DrawerLayoutViewModel.kt | 393 +++--- .../wallet/page/main/model/WalletItemData.kt | 53 +- .../main/presenter/DrawerLayoutContent.kt | 1132 ++++++++--------- .../presenter/ProfileFragmentPresenter.kt | 4 +- .../wallet/page/wallet/model/AvatarData.kt | 15 + .../page/wallet/view/AccountItemSection.kt | 54 + .../page/wallet/view/LinkedAccountSection.kt | 344 ++--- .../page/wallet/view/ProfileItemSection.kt | 274 ++-- .../page/wallet/view/WalletAccountSection.kt | 314 +++-- .../page/wallet/view/WalletItemSection.kt | 52 - .../wallet/viewmodel/ProfileItemViewModel.kt | 128 -- 19 files changed, 2000 insertions(+), 1406 deletions(-) create mode 100644 app/src/main/java/com/flowfoundation/wallet/manager/account/AccountVisibilityManager.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/model/AvatarData.kt create mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/AccountItemSection.kt delete mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt delete mode 100644 app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb4f6b1de..ccd939c07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -297,6 +297,10 @@ android:name=".page.profile.subpage.wallet.WalletListActivity" android:screenOrientation="portrait"/> + + diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt index a1d5e0965..07badb917 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/LaunchManager.kt @@ -8,6 +8,7 @@ import com.flowfoundation.wallet.firebase.config.initFirebaseConfig import com.flowfoundation.wallet.firebase.firebaseInitialize import com.flowfoundation.wallet.instabug.instabugInitialize import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.manager.account.DeviceInfoManager import com.flowfoundation.wallet.manager.app.AppLifecycleObserver import com.flowfoundation.wallet.manager.app.PageLifecycleObserver @@ -42,7 +43,7 @@ object LaunchManager { safeRun { System.loadLibrary("TrustWalletCore") } ioScope { safeRun { - AccountManager.init() + AccountManager.init() logd("LaunchManager", "AccountManager initialized successfully") } } @@ -78,6 +79,7 @@ object LaunchManager { safeRun { NftCollectionStateManager.reload() } safeRun { CurrencyManager.init() } safeRun { StakingManager.init() } + safeRun { AccountVisibilityManager.init() } } private fun setNightMode() { @@ -97,4 +99,4 @@ object LaunchManager { private fun runCompatibleScript() { restoreMnemonicV0() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountVisibilityManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountVisibilityManager.kt new file mode 100644 index 000000000..c926e376b --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/account/AccountVisibilityManager.kt @@ -0,0 +1,171 @@ +package com.flowfoundation.wallet.manager.account + +import android.content.Context +import android.content.SharedPreferences +import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.manager.app.AppLifecycleObserver +import com.flowfoundation.wallet.utils.Env +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** + * Manages account visibility state + * Storage structure: Map> + * UserId - User ID, Address - Hidden wallet address + */ +object AccountVisibilityManager { + + private const val PREF_NAME = "account_visibility" + private const val KEY_HIDDEN_ACCOUNTS = "hidden_accounts" + + private var sharedPreferences: SharedPreferences? = null + private val gson = Gson() + + // In-memory cache, format: Map> + private var hiddenAccountsCache = mutableMapOf>() + + fun init() { + if (sharedPreferences == null) { + sharedPreferences = Env.getApp().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + loadHiddenAccounts() + } + } + + /** + * Load hidden account data from SharedPreferences + */ + private fun loadHiddenAccounts() { + val json = sharedPreferences?.getString(KEY_HIDDEN_ACCOUNTS, null) + if (!json.isNullOrEmpty()) { + try { + val type = object : TypeToken>>() {}.type + val loadedData: Map> = gson.fromJson(json, type) + + // Convert to in-memory cache format + hiddenAccountsCache.clear() + loadedData.forEach { (userId, addresses) -> + hiddenAccountsCache[userId] = addresses.toMutableSet() + } + } catch (e: Exception) { + e.printStackTrace() + hiddenAccountsCache.clear() + } + } + } + + /** + * Save hidden account data to SharedPreferences + */ + private fun saveHiddenAccounts() { + try { + // Convert to serializable format + val dataToSave = hiddenAccountsCache.mapValues { it.value.toList() } + val json = gson.toJson(dataToSave) + sharedPreferences?.edit()?.putString(KEY_HIDDEN_ACCOUNTS, json)?.apply() + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Get hidden account list for current user + */ + fun getHiddenAccounts(userId: String): Set { + return hiddenAccountsCache[userId]?.toSet() ?: emptySet() + } + + /** + * Check if the specified address is hidden + */ + fun isAccountHidden(userId: String, address: String): Boolean { + return hiddenAccountsCache[userId]?.contains(address) ?: false + } + + fun isCurrentProfileAccountHidden(address: String): Boolean { + val userId = firebaseUid() ?: return false + return isAccountHidden(userId, address) + } + + /** + * Hide specified account + */ + fun hideAccount(userId: String, address: String) { + if (hiddenAccountsCache[userId] == null) { + hiddenAccountsCache[userId] = mutableSetOf() + } + + if (hiddenAccountsCache[userId]!!.add(address)) { + saveHiddenAccounts() + } + } + + /** + * Show specified account (unhide) + */ + fun showAccount(userId: String, address: String) { + val userHiddenAccounts = hiddenAccountsCache[userId] + if (userHiddenAccounts != null && userHiddenAccounts.remove(address)) { + // If the user has no hidden accounts left, remove the user's record + if (userHiddenAccounts.isEmpty()) { + hiddenAccountsCache.remove(userId) + } + saveHiddenAccounts() + } + } + + /** + * Toggle account visibility + */ + fun toggleAccountVisibility(userId: String, address: String): Boolean { + val isHidden = isAccountHidden(userId, address) + if (isHidden) { + showAccount(userId, address) + } else { + hideAccount(userId, address) + } + return !isHidden // Return the hidden state after toggle + } + + /** + * Filter out hidden accounts + */ + fun filterVisibleAccounts( + userId: String, + accounts: List, + addressExtractor: (T) -> String + ): List { + val hiddenAddresses = getHiddenAccounts(userId) + if (hiddenAddresses.isEmpty()) { + return accounts + } + + return accounts.filter { account -> + val address = addressExtractor(account) + !hiddenAddresses.contains(address) + } + } + + /** + * Clear all hidden accounts for specified user + */ + fun clearUserHiddenAccounts(userId: String) { + if (hiddenAccountsCache.remove(userId) != null) { + saveHiddenAccounts() + } + } + + /** + * Clear all hidden account data + */ + fun clearAllHiddenAccounts() { + hiddenAccountsCache.clear() + sharedPreferences?.edit()?.remove(KEY_HIDDEN_ACCOUNTS)?.apply() + } + + /** + * Get hidden account statistics for all users + */ + fun getHiddenAccountsStats(): Map { + return hiddenAccountsCache.mapValues { it.value.size } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt index 48435d6a9..0707d2e99 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/evm/EVMWalletManager.kt @@ -191,6 +191,22 @@ object EVMWalletManager { } } + fun getEVMAddressByAddress(address: String): String? { + val evmAddress = evmAddressMap[address] + return if (evmAddress.isNullOrBlank() || evmAddress == "0x") { + ErrorReporter.reportWithMixpanel(EVMError.QUERY_EVM_ADDRESS_FAILED, getCurrentCodeLocation()) + return null + } else { + val checksumAddress = toChecksumEVMAddress(evmAddress) + // Validate the address format - if it's corrupted, try to refresh it + if (!isValidEVMAddress(checksumAddress)) { + logd(TAG, "Detected corrupted EVM address: $checksumAddress, attempting to refresh") + return null + } + checksumAddress + } + } + fun isValidEVMAddress(address: String): Boolean { // Check if address matches valid EVM address pattern and doesn't have suspicious patterns if (!address.matches(Regex("^0x[a-fA-F0-9]{40}$"))) { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt new file mode 100644 index 000000000..6db471604 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListActivity.kt @@ -0,0 +1,167 @@ +package com.flowfoundation.wallet.page.account + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.base.activity.BaseActivity +import com.flowfoundation.wallet.manager.account.AccountVisibilityManager +import com.flowfoundation.wallet.page.dialog.profile.ProfileSwitchDialog +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.page.wallet.view.AccountItemSection +import com.flowfoundation.wallet.page.profile.subpage.wallet.WalletSettingActivity +import com.flowfoundation.wallet.utils.getActivityFromContext +import com.flowfoundation.wallet.utils.isNightMode +import com.zackratos.ultimatebarx.ultimatebarx.UltimateBarX + +class AccountListActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + UltimateBarX.with(this).fitWindow(false).colorRes(R.color.background) + .light(!isNightMode(this)).applyStatusBar() + UltimateBarX.with(this).fitWindow(false).light(!isNightMode(this)).applyNavigationBar() + + setContent { + AccountListScreen( + onBackPressed = { finish() }, + onAddPressed = { + ProfileSwitchDialog().show(supportFragmentManager, "profile_switch") + } + ) + } + } + + companion object { + fun launch(context: Context) { + context.startActivity(Intent(context, AccountListActivity::class.java)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountListScreen( + onBackPressed: () -> Unit = {}, + onAddPressed: () -> Unit = {}, + viewModel: AccountListViewModel = viewModel() +) { + val accounts by viewModel.accounts.collectAsState() + val balanceMap by viewModel.balanceMap.collectAsState() + val context = LocalContext.current + val activity = remember { getActivityFromContext(context) as FragmentActivity } + + LaunchedEffect(Unit) { + viewModel.loadData() + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.account_list), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = colorResource(id = R.color.text), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = colorResource(id = R.color.icon) + ) + } + }, + actions = { + IconButton(onClick = onAddPressed) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Profile", + tint = colorResource(id = R.color.icon) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colorResource(id = R.color.background), + titleContentColor = colorResource(id = R.color.text), + navigationIconContentColor = colorResource(id = R.color.icon), + actionIconContentColor = colorResource(id = R.color.icon) + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.background)) + .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(9.dp) + ) { + items(accounts) { account -> + AccountItemSection( + account = account, + balanceMap = balanceMap, + onItemSelected = { address -> + WalletSettingActivity.launch(activity, address) + }, + onVisibilityToggle = { accountData -> + viewModel.toggleAccountVisibility(accountData.address) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt new file mode 100644 index 000000000..029e5a7af --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/account/AccountListViewModel.kt @@ -0,0 +1,236 @@ +package com.flowfoundation.wallet.page.account + +import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.manager.account.AccountVisibilityManager +import com.flowfoundation.wallet.manager.app.NETWORK_NAME_MAINNET +import com.flowfoundation.wallet.manager.app.NETWORK_NAME_TESTNET +import com.flowfoundation.wallet.manager.app.chainNetWorkString +import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager +import com.flowfoundation.wallet.manager.emoji.OnEmojiUpdate +import com.flowfoundation.wallet.manager.evm.EVMWalletManager +import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance +import com.flowfoundation.wallet.manager.wallet.WalletManager +import com.flowfoundation.wallet.network.ApiService +import com.flowfoundation.wallet.network.retrofitApi +import java.math.BigDecimal +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.page.main.model.LinkedAccountData +import com.flowfoundation.wallet.utils.formatLargeBalanceNumber +import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.wallet.toAddress +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.onflow.flow.ChainId + +class AccountListViewModel : ViewModel(), OnEmojiUpdate { + + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() + + private val _balanceMap = MutableStateFlow>(emptyMap()) + val balanceMap: StateFlow> = _balanceMap.asStateFlow() + + private val service by lazy { retrofitApi().create(ApiService::class.java) } + + // Cache for verified EVM addresses that should be included in linkedAccounts + private val verifiedEvmAddresses = mutableSetOf() + + init { + AccountEmojiManager.addListener(this) + } + + fun loadData() { + refreshWalletList() + } + + private fun refreshWalletList(refreshBalance: Boolean = true) { + ioScope { + val wallet = WalletManager.wallet() ?: return@ioScope + val walletAddresses = wallet.accounts.mapNotNull { (chainId, accounts) -> + val isCurrentChain = when (chainNetWorkString()) { + NETWORK_NAME_MAINNET -> chainId == ChainId.Mainnet + NETWORK_NAME_TESTNET -> chainId == ChainId.Testnet + else -> false + } + if (isCurrentChain) { + accounts.map { it.address.toAddress() } + } else { + null + } + }.flatten() + + val addressList = mutableListOf() + val accounts = mutableListOf() + val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping + + // Add EOA account if exists + val eoaAddress = WalletManager.getEOAAddressCached() + if (eoaAddress != null) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoaAddress) + addressList.add(eoaAddress) + accounts.add( + WalletAccountData( + address = eoaAddress, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == eoaAddress, + isEOAAccount = true + ) + ) + } + + // Add wallet accounts + walletAddresses.forEach { address -> + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + val linkedAccounts = mutableListOf() + + // Add child accounts + WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> + addressList.add(childAccount.address) + linkedAccounts.add( + LinkedAccountData( + address = childAccount.address, + name = childAccount.name, + icon = childAccount.icon, + emojiId = AccountEmojiManager.getEmojiByAddress(childAccount.address).emojiId, + isSelected = WalletManager.selectedWalletAddress() == childAccount.address, + isCOAAccount = false + ) + ) + } + + // Handle EVM address (COA) + EVMWalletManager.getEVMAddressByAddress(address)?.let { evmAddress -> + addressList.add(evmAddress) + // Add to pending list for verification if not already verified + if (evmAddress !in verifiedEvmAddresses) { + pendingEvmAddresses.add(Pair(evmAddress, address)) + } else { + // Add directly to linkedAccounts if already verified + val evmEmojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + linkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = evmEmojiInfo.emojiName, + icon = null, + emojiId = evmEmojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + } + } + + accounts.add( + WalletAccountData( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == address, + linkedAccounts = linkedAccounts, + isEOAAccount = false + ) + ) + addressList.add(address) + } + + _accounts.value = accounts + + if (refreshBalance) { + fetchAllBalances(addressList, pendingEvmAddresses) + } + } + } + + private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { + ioScope { + val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope + val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> + "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" + } + _balanceMap.value = formattedBalanceMap + + // Check each pending EVM address + pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> + val evmBalance = balanceMap[evmAddress] + val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(evmAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add EVM address to linked accounts + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + // Check if EVM address already exists in linked accounts + val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + if (!alreadyExists) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + // Add to verified cache for future refreshWalletList calls + verifiedEvmAddresses.add(evmAddress) + } + } else { + // Remove EVM address from linked accounts if it no longer has assets + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + if (existingLinkedAccount != null) { + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.removeAll { it.address == evmAddress } + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + } + // Remove from verified cache + verifiedEvmAddresses.remove(evmAddress) + } + } + } + } + + fun toggleAccountVisibility(address: String) { + val userId = firebaseUid() ?: return + AccountVisibilityManager.toggleAccountVisibility(userId, address) + + // Refresh the account list to reflect the new visibility state + refreshWalletList(false) + } + + override fun onEmojiUpdate(userName: String, address: String, emojiId: Int, emojiName: String) { + refreshWalletList() + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt index e652408d1..493ee05bb 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt @@ -1,5 +1,6 @@ package com.flowfoundation.wallet.page.dialog.profile +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -309,7 +310,7 @@ private fun LocalSwitchAccountItem( } } -private fun handleAccountSwitch(context: android.content.Context, account: Account, onDismiss: () -> Unit) { +private fun handleAccountSwitch(context: Context, account: Account, onDismiss: () -> Unit) { if (isTestnet()) { SwitchNetworkDialog(context, DialogType.SWITCH).show() } else { @@ -324,7 +325,7 @@ private fun handleAccountSwitch(context: android.content.Context, account: Accou } } -private fun handleLocalAccountSwitch(context: android.content.Context, account: LocalSwitchAccount, onDismiss: () -> Unit) { +private fun handleLocalAccountSwitch(context: Context, account: LocalSwitchAccount, onDismiss: () -> Unit) { if (isTestnet()) { SwitchNetworkDialog(context, DialogType.SWITCH).show() } else { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt index 2aee28a7a..2fa47e028 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt @@ -9,9 +9,10 @@ import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.network.ApiService import com.flowfoundation.wallet.network.retrofitApi -import com.flowfoundation.wallet.page.wallet.viewmodel.AvatarData +import com.flowfoundation.wallet.page.wallet.model.AvatarData import com.flowfoundation.wallet.utils.formatLargeBalanceNumber import com.flowfoundation.wallet.utils.ioScope +import com.google.gson.annotations.SerializedName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,27 +20,36 @@ import java.math.BigDecimal // Data class to represent a profile item with all its data data class ProfileItemData( + @SerializedName("account") val account: Account, + @SerializedName("avatarList") val avatarList: List, + @SerializedName("balanceMap") val balanceMap: Map ) // Sealed class for different item types in the switch list sealed class SwitchItemData { - data class ProfileItem(val data: ProfileItemData) : SwitchItemData() - data class LocalSwitchItem(val account: LocalSwitchAccount) : SwitchItemData() + data class ProfileItem( + @SerializedName("data") + val data: ProfileItemData + ) : SwitchItemData() + data class LocalSwitchItem( + @SerializedName("account") + val account: LocalSwitchAccount + ) : SwitchItemData() } class ProfileSwitchViewModel : ViewModel() { - + private val _switchItemList = MutableStateFlow>(emptyList()) val switchItemList: StateFlow> = _switchItemList.asStateFlow() - + private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow = _isLoading.asStateFlow() - + private val service by lazy { retrofitApi().create(ApiService::class.java) } - + // Cache for verified COA addresses and their avatar data per profile private val verifiedCoaAvatarsMap = mutableMapOf>() @@ -47,7 +57,7 @@ class ProfileSwitchViewModel : ViewModel() { ioScope { val rawList = AccountManager.getSwitchAccountList() val processedList = mutableListOf() - + rawList.forEach { item -> when (item) { is Account -> { @@ -59,27 +69,27 @@ class ProfileSwitchViewModel : ViewModel() { } } } - + _switchItemList.value = processedList _isLoading.value = false } } - + private suspend fun fetchProfileData(profile: Account): ProfileItemData { val wallet = profile.wallet ?: return ProfileItemData(profile, emptyList(), emptyMap()) val addressList = mutableListOf() val avatars = mutableListOf() val address = wallet.walletAddress() ?: return ProfileItemData(profile, emptyList(), emptyMap()) val profileId = wallet.id - + // Initialize cache for this profile if not exists if (profileId !in verifiedCoaAvatarsMap) { verifiedCoaAvatarsMap[profileId] = mutableMapOf() } val verifiedCoaAvatars = verifiedCoaAvatarsMap[profileId]!! - + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) - + // Add main account emoji avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) addressList.add(address) @@ -150,4 +160,4 @@ class ProfileSwitchViewModel : ViewModel() { return ProfileItemData(profile, avatars, formattedBalanceMap) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt index cc47334dc..f826729c8 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt @@ -1,6 +1,7 @@ package com.flowfoundation.wallet.page.main.drawer import androidx.lifecycle.ViewModel +import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.AccountManager import com.flowfoundation.wallet.manager.account.OnWalletDataUpdate import com.flowfoundation.wallet.manager.account.WalletFetcher @@ -16,12 +17,12 @@ import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.network.ApiService -import com.flowfoundation.wallet.network.model.NftCollectionsResponse import com.flowfoundation.wallet.network.model.WalletListData import com.flowfoundation.wallet.network.retrofitApi import com.flowfoundation.wallet.utils.formatLargeBalanceNumber import com.flowfoundation.wallet.utils.ioScope import com.flowfoundation.wallet.network.model.UserInfoData +import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.page.main.model.LinkedAccountData import com.flowfoundation.wallet.page.main.model.WalletAccountData import com.flowfoundation.wallet.wallet.toAddress @@ -33,212 +34,224 @@ import java.math.BigDecimal class DrawerLayoutViewModel : ViewModel(), ChildAccountUpdateListenerCallback, OnWalletDataUpdate, OnEmojiUpdate { - private val _userInfo = MutableStateFlow(null) - val userInfo: StateFlow = _userInfo.asStateFlow() + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() - private val _showEvmLayout = MutableStateFlow(false) - val showEvmLayout: StateFlow = _showEvmLayout.asStateFlow() + private val _showEvmLayout = MutableStateFlow(false) + val showEvmLayout: StateFlow = _showEvmLayout.asStateFlow() - private val _accounts = MutableStateFlow>(emptyList()) - val accounts: StateFlow> = _accounts.asStateFlow() + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() - private val _balanceMap = MutableStateFlow>(emptyMap()) - val balanceMap: StateFlow> = _balanceMap.asStateFlow() + private val _balanceMap = MutableStateFlow>(emptyMap()) + val balanceMap: StateFlow> = _balanceMap.asStateFlow() - private val service by lazy { retrofitApi().create(ApiService::class.java) } + private val service by lazy { retrofitApi().create(ApiService::class.java) } - // Cache for verified EVM addresses that should be included in linkedAccounts - private val verifiedEvmAddresses = mutableSetOf() + // Cache for verified EVM addresses that should be included in linkedAccounts + private val verifiedEvmAddresses = mutableSetOf() - init { - ChildAccountList.addAccountUpdateListener(this) - WalletFetcher.addListener(this) - AccountEmojiManager.addListener(this) - } + init { + ChildAccountList.addAccountUpdateListener(this) + WalletFetcher.addListener(this) + AccountEmojiManager.addListener(this) + } - fun loadData() { - loadEvmStatus() - refreshWalletList() - } + fun loadData() { + loadEvmStatus() + refreshWalletList() + } - private fun loadEvmStatus() { - _showEvmLayout.value = EVMWalletManager.showEVMEnablePage() - } + private fun loadEvmStatus() { + _showEvmLayout.value = EVMWalletManager.showEVMEnablePage() + } - fun refreshWalletList(refreshBalance: Boolean = false) { - ioScope { - _userInfo.value = AccountManager.userInfo() ?: return@ioScope - val wallet = WalletManager.wallet() ?: return@ioScope - val walletAddresses = wallet.accounts.mapNotNull { (chainId, accounts) -> - val isCurrentChain = when (chainNetWorkString()) { - NETWORK_NAME_MAINNET -> chainId == ChainId.Mainnet - NETWORK_NAME_TESTNET -> chainId == ChainId.Testnet - else -> false - } - if (isCurrentChain) { - accounts.map { it.address.toAddress() } - } else { - null - } - }.flatten() - val addressList = mutableListOf() - val accounts = mutableListOf() - val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping - val eoaAddress = WalletManager.getEOAAddressCached() - if (eoaAddress != null) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoaAddress) - addressList.add(eoaAddress) - accounts.add( - WalletAccountData( - address = eoaAddress, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == eoaAddress, - isEOAAccount = true - ) - ) - } - walletAddresses.forEach { address -> - val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) - val linkedAccounts = mutableListOf() - WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> - addressList.add(childAccount.address) - linkedAccounts.add( - LinkedAccountData( - address = childAccount.address, - name = childAccount.name, - icon = childAccount.icon, - emojiId = AccountEmojiManager.getEmojiByAddress(childAccount.address).emojiId, - isSelected = WalletManager.selectedWalletAddress() == childAccount.address, - isCOAAccount = false - ) - ) - } - EVMWalletManager.getEVMAddress()?.let { evmAddress -> - addressList.add(evmAddress) - // Add to pending list for verification if not already verified - if (evmAddress !in verifiedEvmAddresses) { - pendingEvmAddresses.add(Pair(evmAddress, address)) - } else { - // Add directly to linkedAccounts if already verified - val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) - linkedAccounts.add( - LinkedAccountData( - address = evmAddress, - name = emojiInfo.emojiName, - icon = null, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, - isCOAAccount = true - ) - ) - } + fun refreshWalletList(refreshBalance: Boolean = false) { + ioScope { + _userInfo.value = AccountManager.userInfo() ?: return@ioScope + val wallet = WalletManager.wallet() ?: return@ioScope + val walletAddresses = wallet.accounts.mapNotNull { (chainId, accounts) -> + val isCurrentChain = when (chainNetWorkString()) { + NETWORK_NAME_MAINNET -> chainId == ChainId.Mainnet + NETWORK_NAME_TESTNET -> chainId == ChainId.Testnet + else -> false + } + if (isCurrentChain) { + accounts.map { it.address.toAddress() } + } else { + null + } + }.flatten() + val addressList = mutableListOf() + val accounts = mutableListOf() + val pendingEvmAddresses = mutableListOf>() // EVM address to wallet address mapping + val eoaAddress = WalletManager.getEOAAddressCached() + if (eoaAddress != null) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(eoaAddress) + addressList.add(eoaAddress) + accounts.add( + WalletAccountData( + address = eoaAddress, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == eoaAddress, + isEOAAccount = true + ) + ) + } + walletAddresses.forEach { address -> + val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) + val linkedAccounts = mutableListOf() + WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> + addressList.add(childAccount.address) + linkedAccounts.add( + LinkedAccountData( + address = childAccount.address, + name = childAccount.name, + icon = childAccount.icon, + emojiId = AccountEmojiManager.getEmojiByAddress(childAccount.address).emojiId, + isSelected = WalletManager.selectedWalletAddress() == childAccount.address, + isCOAAccount = false + ) + ) + } + EVMWalletManager.getEVMAddressByAddress(address)?.let { evmAddress -> + addressList.add(evmAddress) + // Add to pending list for verification if not already verified + if (evmAddress !in verifiedEvmAddresses) { + pendingEvmAddresses.add(Pair(evmAddress, address)) + } else { + // Add directly to linkedAccounts if already verified + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + linkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + } + } + accounts.add( + WalletAccountData( + address = address, + name = emojiInfo.emojiName, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == address, + linkedAccounts = linkedAccounts + ) + ) + addressList.add(address) + } + + // Filter out hidden accounts for the current user + val userId = firebaseUid() + val filteredAccounts = if (userId != null) { + AccountVisibilityManager.filterVisibleAccounts( + userId, + accounts + ) { it.address } + } else { + accounts + } + + _accounts.value = filteredAccounts + if (refreshBalance) { + fetchAllBalances(addressList, pendingEvmAddresses) + } } - accounts.add( - WalletAccountData( - address = address, - name = emojiInfo.emojiName, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == address, - linkedAccounts = linkedAccounts - ) - ) - addressList.add(address) - } - _accounts.value = accounts - if (refreshBalance) { - fetchAllBalances(addressList, pendingEvmAddresses) - } } - } - - private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { - ioScope { - val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope - val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> - "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" - } - _balanceMap.value = formattedBalanceMap - - // Check each pending EVM address - pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> - val evmBalance = balanceMap[evmAddress] - val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO - var hasNFTs = false - - if (!hasBalance) { - try { - val nftResponse = service.getEVMNFTCollections(evmAddress) - val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 - hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 - } catch (e: Exception) { - // Ignore NFT API errors - } - } - if (hasBalance || hasNFTs) { - // Add EVM address to linked accounts - val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } - walletAccount?.let { account -> - // Check if EVM address already exists in linked accounts - val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } - if (!alreadyExists) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) - val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.add( - LinkedAccountData( - address = evmAddress, - name = emojiInfo.emojiName, - icon = null, - emojiId = emojiInfo.emojiId, - isSelected = WalletManager.selectedWalletAddress() == evmAddress, - isCOAAccount = true - ) - ) - val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } - if (accountIndex >= 0) { - currentAccounts[accountIndex] = updatedAccount - _accounts.value = currentAccounts - } + private fun fetchAllBalances(addressList: List, pendingEvmAddresses: List> = emptyList()) { + ioScope { + val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope + val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> + "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" } - // Add to verified cache for future refreshWalletList calls - verifiedEvmAddresses.add(evmAddress) - } - } else { - // Remove EVM address from linked accounts if it no longer has assets - val currentAccounts = _accounts.value.toMutableList() - val walletAccount = currentAccounts.find { it.address == walletAddress } - walletAccount?.let { account -> - val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } - if (existingLinkedAccount != null) { - val updatedLinkedAccounts = account.linkedAccounts.toMutableList() - updatedLinkedAccounts.removeAll { it.address == evmAddress } - val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) - val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } - if (accountIndex >= 0) { - currentAccounts[accountIndex] = updatedAccount - _accounts.value = currentAccounts - } + _balanceMap.value = formattedBalanceMap + + // Check each pending EVM address + pendingEvmAddresses.forEach { (evmAddress, walletAddress) -> + val evmBalance = balanceMap[evmAddress] + val hasBalance = evmBalance != null && evmBalance > BigDecimal.ZERO + var hasNFTs = false + + if (!hasBalance) { + try { + val nftResponse = service.getEVMNFTCollections(evmAddress) + val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 + hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 + } catch (e: Exception) { + // Ignore NFT API errors + } + } + + if (hasBalance || hasNFTs) { + // Add EVM address to linked accounts + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + // Check if EVM address already exists in linked accounts + val alreadyExists = account.linkedAccounts.any { it.address == evmAddress } + if (!alreadyExists) { + val emojiInfo = AccountEmojiManager.getEmojiByAddress(evmAddress) + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.add( + LinkedAccountData( + address = evmAddress, + name = emojiInfo.emojiName, + icon = null, + emojiId = emojiInfo.emojiId, + isSelected = WalletManager.selectedWalletAddress() == evmAddress, + isCOAAccount = true + ) + ) + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + // Add to verified cache for future refreshWalletList calls + verifiedEvmAddresses.add(evmAddress) + } + } else { + // Remove EVM address from linked accounts if it no longer has assets + val currentAccounts = _accounts.value.toMutableList() + val walletAccount = currentAccounts.find { it.address == walletAddress } + walletAccount?.let { account -> + val existingLinkedAccount = account.linkedAccounts.find { it.address == evmAddress } + if (existingLinkedAccount != null) { + val updatedLinkedAccounts = account.linkedAccounts.toMutableList() + updatedLinkedAccounts.removeAll { it.address == evmAddress } + val updatedAccount = account.copy(linkedAccounts = updatedLinkedAccounts) + val accountIndex = currentAccounts.indexOfFirst { it.address == walletAddress } + if (accountIndex >= 0) { + currentAccounts[accountIndex] = updatedAccount + _accounts.value = currentAccounts + } + } + } + // Remove from verified cache + verifiedEvmAddresses.remove(evmAddress) + } } - } - // Remove from verified cache - verifiedEvmAddresses.remove(evmAddress) } - } } - } - override fun onChildAccountUpdate(parentAddress: String, accounts: List) { - refreshWalletList(true) - } + override fun onChildAccountUpdate(parentAddress: String, accounts: List) { + refreshWalletList(true) + } - override fun onWalletDataUpdate(wallet: WalletListData) { - refreshWalletList(true) - } + override fun onWalletDataUpdate(wallet: WalletListData) { + refreshWalletList(true) + } - override fun onEmojiUpdate(userName: String, address: String, emojiId: Int, emojiName: String) { - refreshWalletList() - } + override fun onEmojiUpdate(userName: String, address: String, emojiId: Int, emojiName: String) { + refreshWalletList() + } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt index bec45f37c..367335347 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt @@ -1,20 +1,49 @@ package com.flowfoundation.wallet.page.main.model +import com.google.gson.annotations.SerializedName + data class WalletAccountData( - val address: String, - val name: String, - val emojiId: Int, - val isSelected: Boolean, - val linkedAccounts: List = emptyList(), - val isEOAAccount: Boolean = false + @SerializedName("address") + val address: String, + @SerializedName("name") + val name: String, + @SerializedName("emojiId") + val emojiId: Int, + @SerializedName("isSelected") + val isSelected: Boolean, + @SerializedName("linkedAccounts") + val linkedAccounts: List = emptyList(), + @SerializedName("isEOAAccount") + val isEOAAccount: Boolean = false ) data class LinkedAccountData( - val address: String, - val name: String, - val icon: String? = null, - val emojiId: Int, - val isSelected: Boolean, - val isCOAAccount: Boolean + @SerializedName("address") + val address: String, + @SerializedName("name") + val name: String, + @SerializedName("icon") + val icon: String? = null, + @SerializedName("emojiId") + val emojiId: Int, + @SerializedName("isSelected") + val isSelected: Boolean, + @SerializedName("isCOAAccount") + val isCOAAccount: Boolean +) + +data class WalletItemData( + @SerializedName("address") + val address: String, + @SerializedName("emojiId") + val emojiId: Int, + @SerializedName("emojiName") + val emojiName: String, + @SerializedName("isSelected") + val isSelected: Boolean = false, + @SerializedName("isEOAAccount") + val isEOAAccount: Boolean = false, + @SerializedName("isHidden") + val isHidden: Boolean = false ) diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt index fc0ddee04..05bfad76e 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/presenter/DrawerLayoutContent.kt @@ -91,629 +91,629 @@ import kotlinx.coroutines.delay @Composable fun DrawerLayoutCompose(drawer: DrawerLayout) { - val context = LocalContext.current - val activity = remember { getActivityFromContext(context) as FragmentActivity } - val viewModel = remember { ViewModelProvider(activity)[DrawerLayoutViewModel::class.java] } - - val userInfo by viewModel.userInfo.collectAsStateWithLifecycle() - val showEvmLayout by viewModel.showEvmLayout.collectAsStateWithLifecycle() - val accounts by viewModel.accounts.collectAsStateWithLifecycle() - val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.loadData() - } - - DisposableEffect(drawer) { - val listener = object : DrawerLayout.DrawerListener { - override fun onDrawerOpened(drawerView: View) { + val context = LocalContext.current + val activity = remember { getActivityFromContext(context) as FragmentActivity } + val viewModel = remember { ViewModelProvider(activity)[DrawerLayoutViewModel::class.java] } + + val userInfo by viewModel.userInfo.collectAsStateWithLifecycle() + val showEvmLayout by viewModel.showEvmLayout.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val balanceMap by viewModel.balanceMap.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.loadData() - } - override fun onDrawerClosed(drawerView: View) {} - override fun onDrawerSlide(drawerView: View, slideOffset: Float) {} - override fun onDrawerStateChanged(newState: Int) {} } - drawer.addDrawerListener(listener) - onDispose { - drawer.removeDrawerListener(listener) - } - } - - Column( - modifier = Modifier - .fillMaxHeight() - .background(colorResource(id = R.color.deep_bg)) - .padding(horizontal = 18.dp, vertical = 24.dp) - ) { - userInfo?.let { - HeaderSection( - userInfo = it, - onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } - ) - HorizontalDivider( - color = colorResource(id = R.color.border_line_stroke), - modifier = Modifier - .fillMaxWidth() - .padding(top = 14.dp) - ) - } + DisposableEffect(drawer) { + val listener = object : DrawerLayout.DrawerListener { + override fun onDrawerOpened(drawerView: View) { + viewModel.loadData() + } + override fun onDrawerClosed(drawerView: View) {} + override fun onDrawerSlide(drawerView: View, slideOffset: Float) {} + override fun onDrawerStateChanged(newState: Int) {} + } - if (showEvmLayout) { - Spacer(modifier = Modifier.height(24.dp)) - EVMSection( - onEvmClick = { - if (EVMWalletManager.haveEVMAddress()) { - drawer.close() - } else { - EnableEVMActivity.launch(activity) - } + drawer.addDrawerListener(listener) + onDispose { + drawer.removeDrawerListener(listener) } - ) } - AccountListSection( - accounts = accounts, - balanceMap = balanceMap, - onCopyClick = { address -> - textToClipboard(address) - toast(msgRes = R.string.copy_address_toast) - }, - onAccountClick = { address -> - FlowLoadingDialog(context).show() - WalletManager.selectWalletAddress(address) - ioScope { - delay(200) - doNetworkChangeTask() - clearCacheDir() - clearWebViewCache() - setMeowDomainClaimed(false) - NftCollectionStateManager.clear() - TransactionStateManager.reload() - FungibleTokenListManager.clear() - StakingManager.clear() - CryptoProviderManager.clear() - delay(1000) - uiScope { - MainActivity.relaunch(Env.getApp()) - } + Column( + modifier = Modifier + .fillMaxHeight() + .background(colorResource(id = R.color.deep_bg)) + .padding(horizontal = 18.dp, vertical = 24.dp) + ) { + userInfo?.let { + HeaderSection( + userInfo = it, + onAccountSwitchClick = { ProfileSwitchDialog.show(activity.supportFragmentManager) } + ) + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + ) } - }, - modifier = Modifier.weight(1f) - ) - - HorizontalDivider( - color = colorResource(id = R.color.border_line_stroke), - modifier = Modifier.fillMaxWidth() - ) - BottomSection( - onImportWalletClick = { - WalletRestoreActivity.launch(activity) - } - ) - } + + if (showEvmLayout) { + Spacer(modifier = Modifier.height(24.dp)) + EVMSection( + onEvmClick = { + if (EVMWalletManager.haveEVMAddress()) { + drawer.close() + } else { + EnableEVMActivity.launch(activity) + } + } + ) + } + + AccountListSection( + accounts = accounts, + balanceMap = balanceMap, + onCopyClick = { address -> + textToClipboard(address) + toast(msgRes = R.string.copy_address_toast) + }, + onAccountClick = { address -> + FlowLoadingDialog(context).show() + WalletManager.selectWalletAddress(address) + ioScope { + delay(200) + doNetworkChangeTask() + clearCacheDir() + clearWebViewCache() + setMeowDomainClaimed(false) + NftCollectionStateManager.clear() + TransactionStateManager.reload() + FungibleTokenListManager.clear() + StakingManager.clear() + CryptoProviderManager.clear() + delay(1000) + uiScope { + MainActivity.relaunch(Env.getApp()) + } + } + }, + modifier = Modifier.weight(1f) + ) + + HorizontalDivider( + color = colorResource(id = R.color.border_line_stroke), + modifier = Modifier.fillMaxWidth() + ) + BottomSection( + onImportWalletClick = { + WalletRestoreActivity.launch(activity) + } + ) + } } @Composable fun HeaderSection( - userInfo: UserInfoData, - onAccountSwitchClick: () -> Unit + userInfo: UserInfoData, + onAccountSwitchClick: () -> Unit ) { - val avatarUrl = userInfo.avatar.parseAvatarUrl() - val avatar = if (avatarUrl.contains("flovatar.com")) { - avatarUrl.svgToPng() - } else { - avatarUrl - } - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .padding(top = 40.dp) - ) { - val (icon, name, switch) = createRefs() - AsyncImage( - model = avatar, - contentDescription = "User Avatar", - contentScale = ContentScale.Crop, - placeholder = painterResource(id = R.drawable.ic_placeholder), - error = painterResource(id = R.drawable.ic_placeholder), - modifier = Modifier - .constrainAs(icon) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(name.start) - } - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - ) - - Text( - text = userInfo.nickname, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(icon.end, 16.dp) - end.linkTo(switch.start, 16.dp) - width = Dimension.fillToConstraints - } - ) - - IconButton( - onClick = onAccountSwitchClick, - modifier = Modifier - .constrainAs(switch) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - end.linkTo(parent.end, (-12).dp) - } + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) ) { - Icon( - painter = painterResource(id = R.drawable.ic_switch_profile), - contentDescription = "Switch Profile", - tint = colorResource(id = R.color.icon) - ) + val (icon, name, switch) = createRefs() + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(name.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(icon.end, 16.dp) + end.linkTo(switch.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + + IconButton( + onClick = onAccountSwitchClick, + modifier = Modifier + .constrainAs(switch) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end, (-12).dp) + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_switch_profile), + contentDescription = "Switch Profile", + tint = colorResource(id = R.color.icon) + ) + } } - } } @Composable fun EVMSection(onEvmClick: () -> Unit) { - ConstraintLayout(modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onEvmClick) - .background( - color = colorResource(id = R.color.bg_card), - shape = RoundedCornerShape(16.dp) - ) - .padding(vertical = 12.dp, horizontal = 16.dp) - ) { - val (title, desc, icon) = createRefs() - - val evm = stringResource(R.string.label_evm) - val raw = stringResource(R.string.add_evm_account, evm) - val parts = raw.split(evm) - - val inlineContentId = "evmLabel" - - val annotatedText = buildAnnotatedString { - append(parts[0]) - appendInlineContent(inlineContentId, "[EVM]") - if (parts.size > 1) { - append(parts[1]) - } - } + ConstraintLayout(modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEvmClick) + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(vertical = 12.dp, horizontal = 16.dp) + ) { + val (title, desc, icon) = createRefs() + + val evm = stringResource(R.string.label_evm) + val raw = stringResource(R.string.add_evm_account, evm) + val parts = raw.split(evm) + + val inlineContentId = "evmLabel" + + val annotatedText = buildAnnotatedString { + append(parts[0]) + appendInlineContent(inlineContentId, "[EVM]") + if (parts.size > 1) { + append(parts[1]) + } + } - val inlineContent = mapOf( - inlineContentId to InlineTextContent( - placeholder = Placeholder( - width = 40.sp, - height = 16.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center + val inlineContent = mapOf( + inlineContentId to InlineTextContent( + placeholder = Placeholder( + width = 40.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Box( + modifier = Modifier + .height(16.dp) + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 6.dp, vertical = 1.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = evm, + color = colorResource(id = R.color.text_1), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } ) - ) { - Box( - modifier = Modifier - .height(16.dp) - .background( - color = colorResource(R.color.evm), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 6.dp, vertical = 1.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = evm, + + Text( + text = annotatedText, + inlineContent = inlineContent, color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .constrainAs(title) { + top.linkTo(parent.top) + bottom.linkTo(desc.top, 4.dp) + start.linkTo(parent.start) + end.linkTo(icon.start, 16.dp) + width = Dimension.fillToConstraints + } + ) + + Text( + text = stringResource(R.string.enable_evm_desc), + color = colorResource(id = R.color.text_2), fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - } - } - ) - - Text( - text = annotatedText, - inlineContent = inlineContent, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .constrainAs(title) { - top.linkTo(parent.top) - bottom.linkTo(desc.top, 4.dp) - start.linkTo(parent.start) - end.linkTo(icon.start, 16.dp) - width = Dimension.fillToConstraints - } - ) - - Text( - text = stringResource(R.string.enable_evm_desc), - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(desc) { - top.linkTo(title.bottom) - bottom.linkTo(parent.bottom) - start.linkTo(title.start) - end.linkTo(title.end) - } - ) - - Icon( - painter = painterResource(id = R.drawable.ic_arrow_right), - contentDescription = "Arrow Right", - tint = colorResource(id = R.color.icon), - modifier = Modifier - .constrainAs(icon) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - end.linkTo(parent.end) - } - ) - } + modifier = Modifier + .constrainAs(desc) { + top.linkTo(title.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(title.start) + end.linkTo(title.end) + } + ) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = "Arrow Right", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) + } } @Composable fun ActiveAccountSection( - item: WalletAccountData, - balanceMap: Map, - onCopyClick: (String) -> Unit, + item: WalletAccountData, + balanceMap: Map, + onCopyClick: (String) -> Unit, ) { - val activeLinkedAccount = item.linkedAccounts.firstOrNull { it.isSelected } - val activeAddress = if (item.isSelected) { - item.address - } else { - activeLinkedAccount?.address ?: "" - } - val balance = balanceMap[activeAddress] ?: "" - val activeEmojiId = if (item.isSelected) { - item.emojiId - } else { - activeLinkedAccount?.emojiId?: Emoji.EMPTY.id - } - val activeName = if (item.isSelected) { - item.name - } else { - activeLinkedAccount?.name?: "" - } - val activeIcon = if (item.isSelected) { - null - } else { - activeLinkedAccount?.icon - } - val walletEmojiId = item.emojiId - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .background( - color = colorResource(id = R.color.bg_card), - shape = RoundedCornerShape(16.dp) - ) - .padding(10.dp) - ) { - val (icon, walletIcon, name, address, balanceText, copy) = createRefs() - Box( - modifier = Modifier - .size(42.dp) - .border( - width = 1.dp, - color = colorResource( - if (item.isSelected) R.color.colorSecondary else R.color.transparent - ), - shape = CircleShape - ) - .constrainAs(icon) { - start.linkTo(parent.start, 7.dp) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - contentAlignment = Alignment.Center - ) { - if (activeIcon.isNullOrEmpty()) { - Box( - modifier = Modifier - .size(36.dp) - .background( - color = Color(Emoji.getEmojiColorRes(activeEmojiId)), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = Emoji.getEmojiById(activeEmojiId), - fontSize = 18.sp - ) - } - } else { - AsyncImage( - model = activeIcon, - contentDescription = "Account Icon", - modifier = Modifier - .size(36.dp) - .background( - color = Color.Transparent, - shape = CircleShape - ) - ) - } + val activeLinkedAccount = item.linkedAccounts.firstOrNull { it.isSelected } + val activeAddress = if (item.isSelected) { + item.address + } else { + activeLinkedAccount?.address ?: "" } - - if (activeLinkedAccount != null) { - Box( + val balance = balanceMap[activeAddress] ?: "" + val activeEmojiId = if (item.isSelected) { + item.emojiId + } else { + activeLinkedAccount?.emojiId?: Emoji.EMPTY.id + } + val activeName = if (item.isSelected) { + item.name + } else { + activeLinkedAccount?.name?: "" + } + val activeIcon = if (item.isSelected) { + null + } else { + activeLinkedAccount?.icon + } + val walletEmojiId = item.emojiId + ConstraintLayout( modifier = Modifier - .size(20.dp) - .border( - width = 1.dp, - color = colorResource(R.color.bg_card), - shape = CircleShape - ) - .constrainAs(walletIcon) { - start.linkTo(parent.start) - top.linkTo(icon.top) - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(18.dp) + .fillMaxWidth() .background( - color = Color(Emoji.getEmojiColorRes(walletEmojiId)), - shape = CircleShape - ), - contentAlignment = Alignment.Center + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(10.dp) + ) { + val (icon, walletIcon, name, address, balanceText, copy) = createRefs() + Box( + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.colorSecondary else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(parent.start, 7.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center ) { - Text( - text = Emoji.getEmojiById(walletEmojiId), - fontSize = 12.sp - ) + if (activeIcon.isNullOrEmpty()) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(activeEmojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(activeEmojiId), + fontSize = 18.sp + ) + } + } else { + AsyncImage( + model = activeIcon, + contentDescription = "Account Icon", + modifier = Modifier + .size(36.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } } - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(address.top) - start.linkTo(icon.end, 9.dp) - end.linkTo(copy.start, 12.dp) - width = Dimension.fillToConstraints + if (activeLinkedAccount != null) { + Box( + modifier = Modifier + .size(20.dp) + .border( + width = 1.dp, + color = colorResource(R.color.bg_card), + shape = CircleShape + ) + .constrainAs(walletIcon) { + start.linkTo(parent.start) + top.linkTo(icon.top) + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(18.dp) + .background( + color = Color(Emoji.getEmojiColorRes(walletEmojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(walletEmojiId), + fontSize = 12.sp + ) + } + } } - ) { - if (activeLinkedAccount != null) { - Icon( - painter = painterResource(id = R.drawable.ic_link), - contentDescription = "LinkedAccount", - tint = colorResource(id = R.color.icon), - modifier = Modifier.size(12.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - } - - Text( - text = activeName, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .weight(1f, fill = false) - ) - if (activeLinkedAccount != null && activeLinkedAccount.isCOAAccount) { - Spacer(modifier = Modifier.width(4.dp)) - ConstraintLayout( - modifier = Modifier - .background( - color = colorResource(R.color.evm), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 9.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } ) { - val (evmLabel, flowLabel) = createRefs() - Text( - text = stringResource(R.string.label_evm), - color = colorResource(id = R.color.white), - fontSize = 8.sp, - modifier = Modifier.constrainAs(evmLabel) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(flowLabel.start) + if (activeLinkedAccount != null) { + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = "LinkedAccount", + tint = colorResource(id = R.color.icon), + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) } - ) - Box( - modifier = Modifier - .constrainAs(flowLabel) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(evmLabel.end, margin = 4.dp) - end.linkTo(parent.end) - } - .background( - color = colorResource(R.color.evm_on_flow_end_color), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) { + Text( - text = stringResource(R.string.label_flow), - color = colorResource(id = R.color.black), - fontSize = 8.sp + text = activeName, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) ) - } - } - } - else if (item.isEOAAccount) { - Spacer(modifier = Modifier.width(4.dp)) - Box( - modifier = Modifier - .background( - color = colorResource(R.color.evm), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) { - Text( - text = stringResource(R.string.label_evm), - color = colorResource(id = R.color.white), - fontSize = 8.sp - ) - } - } - } - Text( - text = shortenEVMString(activeAddress), - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(address) { - top.linkTo(name.bottom, 2.dp) - bottom.linkTo(balanceText.top, 2.dp) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints - } - ) - Text( - text = balance, - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(balanceText) { - top.linkTo(address.bottom) - bottom.linkTo(parent.bottom) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints - } - ) - Icon( - painter = painterResource(id = R.drawable.ic_copy_address), - contentDescription = "Copy", - tint = colorResource(id = R.color.icon), - modifier = Modifier - .size(20.dp) - .clickable(onClick = { onCopyClick(activeAddress) }) - .constrainAs(copy) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) + if (activeLinkedAccount != null && activeLinkedAccount.isCOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + ConstraintLayout( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + val (evmLabel, flowLabel) = createRefs() + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp, + modifier = Modifier.constrainAs(evmLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(flowLabel.start) + } + ) + Box( + modifier = Modifier + .constrainAs(flowLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(evmLabel.end, margin = 4.dp) + end.linkTo(parent.end) + } + .background( + color = colorResource(R.color.evm_on_flow_end_color), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_flow), + color = colorResource(id = R.color.black), + fontSize = 8.sp + ) + } + } + } + else if (item.isEOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp + ) + } + } } - ) - } + + Text( + text = shortenEVMString(activeAddress), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(activeAddress) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } } @Composable fun AccountListSection( - accounts: List, - balanceMap: Map, - onCopyClick: (String) -> Unit, - onAccountClick: (String) -> Unit, - modifier: Modifier = Modifier + accounts: List, + balanceMap: Map, + onCopyClick: (String) -> Unit, + onAccountClick: (String) -> Unit, + modifier: Modifier = Modifier ) { - val scrollState = rememberScrollState() - val activeAccount = accounts.firstOrNull { account -> account.isSelected || account.linkedAccounts.any { it.isSelected } } - Column( - modifier = modifier - .fillMaxWidth() - .padding(vertical = 24.dp) - .verticalScroll(scrollState) - ) { - Text( - text = stringResource(id = R.string.active_account), - color = colorResource(id = R.color.text_2), - fontSize = 14.sp - ) - activeAccount?.let { - Spacer(modifier = Modifier.height(16.dp)) - ActiveAccountSection( - item = it, - balanceMap = balanceMap, - onCopyClick = onCopyClick - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.other_accounts), - color = colorResource(id = R.color.text_2), - fontSize = 14.sp, - ) - - accounts.forEach { account -> - WalletAccountSection( - item = account, - balance = balanceMap[account.address.toAddress()] ?: "", - onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(account.address) } - ) - account.linkedAccounts.forEach { linkedAccount -> - LinkedAccountSection( - item = linkedAccount, - balance = balanceMap[linkedAccount.address.toAddress()] ?: "", - onCopyClick = onCopyClick, - onAccountClick = { onAccountClick(linkedAccount.address) } + val scrollState = rememberScrollState() + val activeAccount = accounts.firstOrNull { account -> account.isSelected || account.linkedAccounts.any { it.isSelected } } + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(id = R.string.active_account), + color = colorResource(id = R.color.text_2), + fontSize = 14.sp + ) + activeAccount?.let { + Spacer(modifier = Modifier.height(16.dp)) + ActiveAccountSection( + item = it, + balanceMap = balanceMap, + onCopyClick = onCopyClick + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.other_accounts), + color = colorResource(id = R.color.text_2), + fontSize = 14.sp, ) - } + + accounts.forEach { account -> + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(account.address) } + ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + onCopyClick = onCopyClick, + onAccountClick = { onAccountClick(linkedAccount.address) } + ) + } + } } - } } @Composable fun BottomSection( - onImportWalletClick: () -> Unit + onImportWalletClick: () -> Unit ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .clickable(onClick = onImportWalletClick), - verticalAlignment = Alignment.CenterVertically - ) { - Box(modifier = Modifier - .size(40.dp) - .background( - color = colorResource(id = R.color.bg_card), - shape = CircleShape - ), - contentAlignment = Alignment.Center + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable(onClick = onImportWalletClick), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - painter = painterResource(id = R.drawable.ic_baseline_add_24), - contentDescription = "Add Account", - tint = colorResource(id = R.color.text_1) - ) + Box(modifier = Modifier + .size(40.dp) + .background( + color = colorResource(id = R.color.bg_card), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_add_24), + contentDescription = "Add Account", + tint = colorResource(id = R.color.text_1) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = R.string.add_account), + color = colorResource(id = R.color.text_2), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) } - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource(id = R.string.add_account), - color = colorResource(id = R.color.text_2), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } } fun setupDrawerLayoutCompose(drawer: DrawerLayout) { - val composeView = ComposeView(drawer.context) - composeView.setContent { - DrawerLayoutCompose(drawer) - } - - val layoutParams = DrawerLayout.LayoutParams( - (ScreenUtils.getScreenWidth() * 0.8f).toInt(), - DrawerLayout.LayoutParams.MATCH_PARENT - ).apply { - gravity = GravityCompat.START - } - composeView.layoutParams = layoutParams - - drawer.addView(composeView) + val composeView = ComposeView(drawer.context) + composeView.setContent { + DrawerLayoutCompose(drawer) + } + + val layoutParams = DrawerLayout.LayoutParams( + (ScreenUtils.getScreenWidth() * 0.8f).toInt(), + DrawerLayout.LayoutParams.MATCH_PARENT + ).apply { + gravity = GravityCompat.START + } + composeView.layoutParams = layoutParams + + drawer.addView(composeView) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt index b6059399f..a5f6ab4b4 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/profile/presenter/ProfileFragmentPresenter.kt @@ -28,7 +28,7 @@ import com.flowfoundation.wallet.page.profile.subpage.currency.CurrencyListActiv import com.flowfoundation.wallet.page.profile.subpage.currency.model.findCurrencyFromFlag import com.flowfoundation.wallet.page.profile.subpage.developer.DeveloperModeActivity import com.flowfoundation.wallet.page.profile.subpage.theme.ThemeSettingActivity -import com.flowfoundation.wallet.page.profile.subpage.wallet.WalletListActivity +import com.flowfoundation.wallet.page.account.AccountListActivity import com.flowfoundation.wallet.page.profile.subpage.wallet.account.ChildAccountsActivity import com.flowfoundation.wallet.page.profile.subpage.wallet.device.DevicesActivity import com.flowfoundation.wallet.page.profile.subpage.walletconnect.session.WalletConnectSessionActivity @@ -69,7 +69,7 @@ class ProfileFragmentPresenter( ) } binding.actionGroup.addressButton.setOnClickListener { AddressBookActivity.launch(context) } - binding.actionGroup.walletButton.setOnClickListener { WalletListActivity.launch(context) } + binding.actionGroup.walletButton.setOnClickListener { AccountListActivity.launch(context) } binding.actionGroup.inboxButton.setOnClickListener { InboxActivity.launch(context) } binding.group0.backupPreference.setOnClickListener { WalletBackupActivity.launch(context) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/model/AvatarData.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/model/AvatarData.kt new file mode 100644 index 000000000..994a7b4ae --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/model/AvatarData.kt @@ -0,0 +1,15 @@ +package com.flowfoundation.wallet.page.wallet.model + +import com.google.gson.annotations.SerializedName + + +sealed class AvatarData { + data class Icon( + @SerializedName("url") + val url: String + ) : AvatarData() + data class Emoji( + @SerializedName("emojiId") + val emojiId: Int + ) : AvatarData() +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/AccountItemSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/AccountItemSection.kt new file mode 100644 index 000000000..c5529ca0c --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/AccountItemSection.kt @@ -0,0 +1,54 @@ +package com.flowfoundation.wallet.page.wallet.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.page.main.model.WalletAccountData +import com.flowfoundation.wallet.wallet.toAddress + + +@Composable +fun AccountItemSection( + account: WalletAccountData, + balanceMap: Map, + onItemSelected: (String) -> Unit, + onVisibilityToggle: ((WalletAccountData) -> Unit)? = null +) { + Column( + modifier = Modifier + .fillMaxHeight() + .background( + color = colorResource(id = R.color.bg_card), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 18.dp, vertical = 6.dp) + ) { + WalletAccountSection( + item = account, + balance = balanceMap[account.address.toAddress()] ?: "", + canSelected = false, + onCopyClick = null, + onAccountClick = { onItemSelected(account.address) }, + onVisibilityToggle = if (onVisibilityToggle != null) { + { onVisibilityToggle(account) } + } else null + ) + account.linkedAccounts.forEach { linkedAccount -> + LinkedAccountSection( + item = linkedAccount, + balance = balanceMap[linkedAccount.address.toAddress()] ?: "", + onAccountClick = { onItemSelected(linkedAccount.address) } + ) + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt index 1b95354ca..e41afffdd 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt @@ -36,181 +36,195 @@ import com.flowfoundation.wallet.utils.shortenEVMString @Composable fun LinkedAccountSection( - item: LinkedAccountData, - balance: String, - onCopyClick: ((String) -> Unit)? = null, - onAccountClick: (() -> Unit)? = null + item: LinkedAccountData, + balance: String, + onCopyClick: ((String) -> Unit)? = null, + onAccountClick: (() -> Unit)? = null ) { - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } - .alpha(if (item.isSelected) 1f else 0.7f) - ) { - val (linkedIcon, icon, name, address, balanceText, copy) = createRefs() - Icon( - painter = painterResource(id = R.drawable.ic_link), - contentDescription = "LinkedAccount", - tint = colorResource(id = R.color.icon), - modifier = Modifier - .size(20.dp) - .constrainAs(linkedIcon) { - start.linkTo(parent.start, 14.dp) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - } - ) - Box( - modifier = Modifier - .size(42.dp) - .border( - width = 1.dp, - color = colorResource( - if (item.isSelected) R.color.accent_green else R.color.transparent - ), - shape = CircleShape - ) - .constrainAs(icon) { - start.linkTo(linkedIcon.end, 10.dp) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - contentAlignment = Alignment.Center + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } + .alpha(if (item.isSelected) 1f else 0.7f) ) { - if (item.icon.isNullOrEmpty()) { + val (linkedIcon, icon, name, address, balanceText, copy) = createRefs() + Icon( + painter = painterResource(id = R.drawable.ic_link), + contentDescription = "LinkedAccount", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .constrainAs(linkedIcon) { + start.linkTo(parent.start, 14.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) Box( - modifier = Modifier - .size(36.dp) - .background( - color = Color(Emoji.getEmojiColorRes(item.emojiId)), - shape = CircleShape - ), - contentAlignment = Alignment.Center + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.accent_green else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(linkedIcon.end, 10.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center ) { - Text( - text = Emoji.getEmojiById(item.emojiId), - fontSize = 18.sp - ) - } - } else { - AsyncImage( - model = item.icon, - contentDescription = "Account Icon", - modifier = Modifier - .size(36.dp) - .background( - color = Color.Transparent, - shape = CircleShape - ) - ) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(address.top) - start.linkTo(icon.end, 8.dp) - end.linkTo(copy.start, 12.dp) - width = Dimension.fillToConstraints + if (item.icon.isNullOrEmpty()) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(item.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(item.emojiId), + fontSize = 18.sp + ) + } + } else { + AsyncImage( + model = item.icon, + contentDescription = "Account Icon", + modifier = Modifier + .size(36.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } } - ) { - - Text( - text = item.name, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .weight(1f, fill = false) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 8.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } + ) { - if (item.isCOAAccount) { - Spacer(modifier = Modifier.width(4.dp)) - ConstraintLayout( - modifier = Modifier - .background( - color = colorResource(R.color.evm), - shape = RoundedCornerShape(16.dp) + Text( + text = item.name, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) ) - ) { - val (evmLabel, flowLabel) = createRefs() - Text( - text = stringResource(R.string.label_evm), - color = colorResource(id = R.color.white), - fontSize = 8.sp, - modifier = Modifier.constrainAs(evmLabel) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start, margin = 4.dp) - end.linkTo(flowLabel.start) + + if (item.isCOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + ConstraintLayout( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + ) { + val (evmLabel, flowLabel) = createRefs() + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp, + modifier = Modifier.constrainAs(evmLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start, margin = 4.dp) + end.linkTo(flowLabel.start) + } + ) + Box( + modifier = Modifier + .constrainAs(flowLabel) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(evmLabel.end, margin = 4.dp) + end.linkTo(parent.end) + } + .background( + color = colorResource(R.color.evm_on_flow_end_color), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_flow), + color = colorResource(id = R.color.black), + fontSize = 8.sp + ) + } + } } - ) - Box( + } + Text( + text = shortenEVMString(item.address), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, modifier = Modifier - .constrainAs(flowLabel) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(evmLabel.end, margin = 4.dp) - end.linkTo(parent.end) - } - .background( - color = colorResource(R.color.evm_on_flow_end_color), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) { - Text( - text = stringResource(R.string.label_flow), - color = colorResource(id = R.color.black), - fontSize = 8.sp + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + if (onCopyClick != null) { + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(item.address) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } else { + // Show arrow icon when onCopyClick is null + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = "View Details", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } ) - } - } - } - } - Text( - text = shortenEVMString(item.address), - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(address) { - top.linkTo(name.bottom, 2.dp) - bottom.linkTo(balanceText.top, 2.dp) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints - } - ) - Text( - text = balance, - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(balanceText) { - top.linkTo(address.bottom) - bottom.linkTo(parent.bottom) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints } - ) - if (onCopyClick!= null) { - Icon( - painter = painterResource(id = R.drawable.ic_copy_address), - contentDescription = "Copy", - tint = colorResource(id = R.color.icon), - modifier = Modifier - .size(20.dp) - .clickable(onClick = { onCopyClick(item.address) }) - .constrainAs(copy) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - } - ) } - } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt index ad2b19300..9b0e0fcc5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt @@ -4,8 +4,6 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -13,19 +11,14 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -33,161 +26,156 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.flowfoundation.wallet.R import com.flowfoundation.wallet.manager.account.Account import com.flowfoundation.wallet.manager.emoji.model.Emoji -import com.flowfoundation.wallet.page.wallet.viewmodel.AvatarData -import com.flowfoundation.wallet.page.wallet.viewmodel.ProfileItemViewModel -import com.flowfoundation.wallet.utils.getActivityFromContext +import com.flowfoundation.wallet.page.wallet.model.AvatarData import com.flowfoundation.wallet.utils.parseAvatarUrl import com.flowfoundation.wallet.utils.svgToPng @Composable fun ProfileItemSection( - profile: Account, - isSelected: Boolean, - onProfileClick: (String) -> Unit, - avatarList: List = emptyList(), - balanceMap: Map = emptyMap() + profile: Account, + isSelected: Boolean, + onProfileClick: (String) -> Unit, + avatarList: List = emptyList(), + balanceMap: Map = emptyMap() ) { - val userInfo = profile.userInfo - val avatarUrl = userInfo.avatar.parseAvatarUrl() - val avatar = if (avatarUrl.contains("flovatar.com")) { - avatarUrl.svgToPng() - } else { - avatarUrl - } + val userInfo = profile.userInfo + val avatarUrl = userInfo.avatar.parseAvatarUrl() + val avatar = if (avatarUrl.contains("flovatar.com")) { + avatarUrl.svgToPng() + } else { + avatarUrl + } - // Calculate total balance - val totalBalance = balanceMap.values.sumOf { balance -> - balance.replace(" FLOW", "").replace(",", "").toDoubleOrNull() ?: 0.0 - } + // Calculate total balance + val totalBalance = balanceMap.values.sumOf { balance -> + balance.replace(" FLOW", "").replace(",", "").toDoubleOrNull() ?: 0.0 + } - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 20.dp) - .clickable(onClick = { onProfileClick(profile.wallet?.id ?: "" )}) - ) { - val (icon, name, balance, accountCount, avatarRow, switch) = createRefs() + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp) + .clickable(onClick = { onProfileClick(profile.wallet?.id ?: "" )}) + ) { + val (icon, name, balance, accountCount, avatarRow, switch) = createRefs() - // Avatar - AsyncImage( - model = avatar, - contentDescription = "User Avatar", - contentScale = ContentScale.Crop, - placeholder = painterResource(id = R.drawable.ic_placeholder), - error = painterResource(id = R.drawable.ic_placeholder), - modifier = Modifier - .constrainAs(icon) { - top.linkTo(parent.top) - start.linkTo(parent.start) - } - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - ) + // Avatar + AsyncImage( + model = avatar, + contentDescription = "User Avatar", + contentScale = ContentScale.Crop, + placeholder = painterResource(id = R.drawable.ic_placeholder), + error = painterResource(id = R.drawable.ic_placeholder), + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top) + start.linkTo(parent.start) + } + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) - // Name - Text( - text = userInfo.nickname, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .constrainAs(name) { - top.linkTo(icon.top) - start.linkTo(icon.end, 12.dp) - end.linkTo(switch.start, 12.dp) - width = Dimension.fillToConstraints - } - ) + // Name + Text( + text = userInfo.nickname, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .constrainAs(name) { + top.linkTo(icon.top) + start.linkTo(icon.end, 12.dp) + end.linkTo(switch.start, 12.dp) + width = Dimension.fillToConstraints + } + ) - // Balance - Text( - text = String.format("%.2f FLOW", totalBalance), - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(balance) { - top.linkTo(name.bottom, 4.dp) - start.linkTo(name.start) - } - ) + // Balance + Text( + text = String.format("%.2f FLOW", totalBalance), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balance) { + top.linkTo(name.bottom, 4.dp) + start.linkTo(name.start) + } + ) - // Account count - Text( - text = "${avatarList.size} Accounts", - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(accountCount) { - top.linkTo(balance.bottom, 4.dp) - start.linkTo(name.start) - } - ) + // Account count + Text( + text = "${avatarList.size} Accounts", + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(accountCount) { + top.linkTo(balance.bottom, 4.dp) + start.linkTo(name.start) + } + ) - // Avatar LazyRow - LazyRow( - modifier = Modifier - .constrainAs(avatarRow) { - top.linkTo(accountCount.top) - bottom.linkTo(accountCount.bottom) - start.linkTo(accountCount.end, 8.dp) - end.linkTo(switch.start, 12.dp) - width = Dimension.fillToConstraints - }, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(avatarList) { avatarData -> - when (avatarData) { - is AvatarData.Icon -> { - AsyncImage( - model = avatarData.url, - contentDescription = "Account Icon", - modifier = Modifier - .size(14.dp) - .background( - color = Color.Transparent, - shape = CircleShape - ) - ) - } - is AvatarData.Emoji -> { - Box( - modifier = Modifier - .size(14.dp) - .background( - color = Color(Emoji.getEmojiColorRes(avatarData.emojiId)), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = Emoji.getEmojiById(avatarData.emojiId), - fontSize = 8.sp - ) + // Avatar LazyRow + LazyRow( + modifier = Modifier + .constrainAs(avatarRow) { + top.linkTo(accountCount.top) + bottom.linkTo(accountCount.bottom) + start.linkTo(accountCount.end, 8.dp) + end.linkTo(switch.start, 12.dp) + width = Dimension.fillToConstraints + }, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(avatarList) { avatarData -> + when (avatarData) { + is AvatarData.Icon -> { + AsyncImage( + model = avatarData.url, + contentDescription = "Account Icon", + modifier = Modifier + .size(14.dp) + .background( + color = Color.Transparent, + shape = CircleShape + ) + ) + } + is AvatarData.Emoji -> { + Box( + modifier = Modifier + .size(14.dp) + .background( + color = Color(Emoji.getEmojiColorRes(avatarData.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(avatarData.emojiId), + fontSize = 8.sp + ) + } + } + } } - } } - } - } - // Switch icon - Icon( - painter = painterResource(id = R.drawable.ic_check_round), - contentDescription = "Select Profile", - tint = colorResource(id = if (isSelected) R.color.accent_green else R.color.icon), - modifier = Modifier - .constrainAs(switch) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - end.linkTo(parent.end) - } - ) - } + // Switch icon + Icon( + painter = painterResource(id = R.drawable.ic_check_round), + contentDescription = "Select Profile", + tint = colorResource(id = if (isSelected) R.color.accent_green else R.color.icon), + modifier = Modifier + .constrainAs(switch) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + ) + } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt index a8cf9871b..be2c4cc49 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.flowfoundation.wallet.R +import com.flowfoundation.wallet.manager.account.AccountManager +import com.flowfoundation.wallet.manager.account.AccountVisibilityManager import com.flowfoundation.wallet.manager.emoji.model.Emoji import com.flowfoundation.wallet.page.main.model.WalletAccountData import com.flowfoundation.wallet.utils.shortenEVMString @@ -35,143 +37,195 @@ import com.flowfoundation.wallet.utils.shortenEVMString @Composable fun WalletAccountSection( - item: WalletAccountData, - balance: String, - isSelected: Boolean = false, - canSelected: Boolean = false, - onCopyClick: ((String) -> Unit)? = null, - onAccountClick: (() -> Unit)? = null + item: WalletAccountData, + balance: String, + isSelected: Boolean = false, + canSelected: Boolean = false, + onCopyClick: ((String) -> Unit)? = null, + onAccountClick: (() -> Unit)? = null, + onVisibilityToggle: (() -> Unit)? = null ) { - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } - .padding(vertical = 12.dp) - .alpha(if (item.isSelected) 1f else 0.7f) - ) { - val (icon, name, address, balanceText, copy) = createRefs() - Box( - modifier = Modifier - .size(42.dp) - .border( - width = 1.dp, - color = colorResource( - if (item.isSelected) R.color.accent_green else R.color.transparent - ), - shape = CircleShape - ) - .constrainAs(icon) { - start.linkTo(parent.start) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(36.dp) - .background( - color = Color(Emoji.getEmojiColorRes(item.emojiId)), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = Emoji.getEmojiById(item.emojiId), - fontSize = 18.sp - ) - } + // Check if this is account list scenario (canSelected=false and onCopyClick=null) + val isAccountListMode = !canSelected && onCopyClick == null + val userInfo = AccountManager.userInfo() + val isHidden = if (isAccountListMode && userInfo != null) { + AccountVisibilityManager.isCurrentProfileAccountHidden(item.address) + } else { + false } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .constrainAs(name) { - top.linkTo(parent.top) - bottom.linkTo(address.top) - start.linkTo(icon.end, 8.dp) - end.linkTo(copy.start, 12.dp) - width = Dimension.fillToConstraints - } - ) { - Text( - text = item.name, - color = colorResource(id = R.color.text_1), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + ConstraintLayout( modifier = Modifier - .weight(1f, fill = false) - ) - if (item.isEOAAccount) { - Spacer(modifier = Modifier.width(4.dp)) + .fillMaxWidth() + .clickable(enabled = onAccountClick != null) { onAccountClick?.invoke() } + .padding(vertical = 12.dp) + .alpha(if (item.isSelected) 1f else 0.7f) + ) { + val (icon, name, address, balanceText, copy, unhideIcon) = createRefs() Box( - modifier = Modifier - .background( - color = colorResource(R.color.evm), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) + modifier = Modifier + .size(42.dp) + .border( + width = 1.dp, + color = colorResource( + if (item.isSelected) R.color.accent_green else R.color.transparent + ), + shape = CircleShape + ) + .constrainAs(icon) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + contentAlignment = Alignment.Center ) { - Text( - text = stringResource(R.string.label_evm), - color = colorResource(id = R.color.white), - fontSize = 8.sp - ) + Box( + modifier = Modifier + .size(36.dp) + .background( + color = Color(Emoji.getEmojiColorRes(item.emojiId)), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = Emoji.getEmojiById(item.emojiId), + fontSize = 18.sp + ) + } } - } - } - Text( - text = shortenEVMString(item.address), - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(address) { - top.linkTo(name.bottom, 2.dp) - bottom.linkTo(balanceText.top, 2.dp) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .constrainAs(name) { + top.linkTo(parent.top) + bottom.linkTo(address.top) + start.linkTo(icon.end, 8.dp) + end.linkTo(copy.start, 12.dp) + width = Dimension.fillToConstraints + } + ) { + Text( + text = item.name, + color = colorResource(id = R.color.text_1), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f, fill = false) + ) + if (item.isEOAAccount) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .background( + color = colorResource(R.color.evm), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = stringResource(R.string.label_evm), + color = colorResource(id = R.color.white), + fontSize = 8.sp + ) + } + } + + // Show "(Hidden)" text when account is hidden (account list mode only) + if (isAccountListMode && isHidden) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "(Hidden)", + color = colorResource(id = R.color.text_2), + fontSize = 12.sp + ) + } } - ) - Text( - text = balance, - color = colorResource(id = R.color.text_2), - fontSize = 12.sp, - modifier = Modifier - .constrainAs(balanceText) { - top.linkTo(address.bottom) - bottom.linkTo(parent.bottom) - start.linkTo(name.start) - end.linkTo(name.end) - width = Dimension.fillToConstraints + Text( + text = shortenEVMString(item.address), + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(address) { + top.linkTo(name.bottom, 2.dp) + bottom.linkTo(balanceText.top, 2.dp) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + Text( + text = balance, + color = colorResource(id = R.color.text_2), + fontSize = 12.sp, + modifier = Modifier + .constrainAs(balanceText) { + top.linkTo(address.bottom) + bottom.linkTo(parent.bottom) + start.linkTo(name.start) + end.linkTo(name.end) + width = Dimension.fillToConstraints + } + ) + if (onCopyClick != null) { + Icon( + painter = painterResource(id = R.drawable.ic_copy_address), + contentDescription = "Copy", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(onClick = { onCopyClick(item.address) }) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } else if (canSelected) { + Icon( + painter = painterResource(id = R.drawable.ic_check_round), + contentDescription = "Select Profile", + tint = colorResource(id = if (isSelected) R.color.icon else R.color.accent_green), + modifier = Modifier + .size(20.dp) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } else { + // Account list mode: show arrow icon on the right + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right), + contentDescription = "View Details", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .constrainAs(copy) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + + // Show unhide icon to the left of arrow when account is hidden + if (isHidden) { + Icon( + painter = painterResource(id = R.drawable.ic_eye_off), + contentDescription = "Show Account", + tint = colorResource(id = R.color.icon), + modifier = Modifier + .size(20.dp) + .clickable(enabled = onVisibilityToggle != null) { + onVisibilityToggle?.invoke() + } + .constrainAs(unhideIcon) { + end.linkTo(copy.start, 8.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } } - ) - if (onCopyClick != null) { - Icon( - painter = painterResource(id = R.drawable.ic_copy_address), - contentDescription = "Copy", - tint = colorResource(id = R.color.icon), - modifier = Modifier - .size(20.dp) - .clickable(onClick = { onCopyClick(item.address) }) - .constrainAs(copy) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - } - ) - } else if (canSelected) { - Icon( - painter = painterResource(id = R.drawable.ic_check_round), - contentDescription = "Select Profile", - tint = colorResource(id = if (isSelected) R.color.icon else R.color.accent_green), - modifier = Modifier - .size(20.dp) - .constrainAs(copy) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - } - ) } - } } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt deleted file mode 100644 index b44e272c7..000000000 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletItemSection.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.flowfoundation.wallet.page.wallet.view - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.unit.dp -import com.flowfoundation.wallet.R -import com.flowfoundation.wallet.page.main.model.WalletAccountData -import com.flowfoundation.wallet.wallet.toAddress - - -@Composable -fun WalletItemSection( - account: WalletAccountData, - balanceMap: Map, - onItemSelected: (WalletAccountData) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxHeight() - .background( - color = colorResource(id = R.color.bg_card), - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 18.dp, vertical = 24.dp) - .clickable(onClick = { onItemSelected(account)}) - ) { - WalletAccountSection( - item = account, - balance = balanceMap[account.address.toAddress()] ?: "" - ) - HorizontalDivider( - color = colorResource(id = R.color.border_line_stroke), - modifier = Modifier - .fillMaxWidth() - ) - account.linkedAccounts.forEach { linkedAccount -> - LinkedAccountSection( - item = linkedAccount, - balance = balanceMap[linkedAccount.address.toAddress()] ?: "" - ) - } - } -} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt deleted file mode 100644 index 51ec93754..000000000 --- a/app/src/main/java/com/flowfoundation/wallet/page/wallet/viewmodel/ProfileItemViewModel.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.flowfoundation.wallet.page.wallet.viewmodel - -import androidx.lifecycle.ViewModel -import com.flowfoundation.wallet.manager.account.Account -import com.flowfoundation.wallet.manager.emoji.AccountEmojiManager -import com.flowfoundation.wallet.manager.flowjvm.cadenceGetAllFlowBalance -import com.flowfoundation.wallet.manager.wallet.WalletManager -import com.flowfoundation.wallet.network.ApiService -import com.flowfoundation.wallet.network.retrofitApi -import com.flowfoundation.wallet.utils.formatLargeBalanceNumber -import com.flowfoundation.wallet.utils.ioScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.math.BigDecimal -import kotlin.collections.forEach - -// Sealed class to represent avatar data (either icon URL or emoji ID) -sealed class AvatarData { - data class Icon(val url: String) : AvatarData() - data class Emoji(val emojiId: Int) : AvatarData() -} - -class ProfileItemViewModel: ViewModel() { - private val _avatarList = MutableStateFlow>(emptyList()) - val avatarList: StateFlow> = _avatarList.asStateFlow() - - private val _balanceMap = MutableStateFlow>(emptyMap()) - val balanceMap: StateFlow> = _balanceMap.asStateFlow() - - private val service by lazy { retrofitApi().create(ApiService::class.java) } - - // Cache for verified COA addresses and their avatar data - private val verifiedCoaAvatars = mutableMapOf() - - fun fetchWalletList(profile: Account) { - ioScope { - val wallet = profile.wallet ?: return@ioScope - val addressList = mutableListOf() - val avatars = mutableListOf() - val address = wallet.walletAddress() ?: return@ioScope - val emojiInfo = AccountEmojiManager.getEmojiByAddress(address) - - // Add main account emoji - avatars.add(AvatarData.Emoji(emojiInfo.emojiId)) - addressList.add(address) - - // Add child accounts - WalletManager.childAccountList(address)?.get()?.forEach { childAccount -> - addressList.add(childAccount.address) - if (childAccount.icon.isNotEmpty()) { - avatars.add(AvatarData.Icon(childAccount.icon)) - } else { - val childEmojiInfo = AccountEmojiManager.getEmojiByAddress(childAccount.address) - avatars.add(AvatarData.Emoji(childEmojiInfo.emojiId)) - } - } - - var pendingCoaAddress: String? = null - val coaAddress = profile.evmAddressData?.evmAddressMap?.get(address) - coaAddress?.let { coa -> - addressList.add(coa) - // Add to pending list for verification if not already verified - if (coa !in verifiedCoaAvatars) { - pendingCoaAddress = coa - } else { - // Add directly to avatars if already verified - avatars.add(verifiedCoaAvatars[coa]!!) - } - } - - _avatarList.value = avatars - fetchAllBalances(addressList, pendingCoaAddress) - } - } - - private fun fetchAllBalances(addressList: List, pendingCoaAddress: String?) { - ioScope { - val balanceMap = cadenceGetAllFlowBalance(addressList) ?: return@ioScope - val formattedBalanceMap = balanceMap.mapValues { (_, balance) -> - "${balance.formatLargeBalanceNumber(isAbbreviation = true)} FLOW" - } - _balanceMap.value = formattedBalanceMap - - // Check if COA address should be added to linkedAccounts - pendingCoaAddress?.let { coaAddress -> - val coaBalance = balanceMap[coaAddress] - val hasBalance = coaBalance != null && coaBalance > BigDecimal.ZERO - var hasNFTs = false - - if (!hasBalance) { - try { - val nftResponse = service.getEVMNFTCollections(coaAddress) - val totalNftCount = nftResponse.data?.sumOf { it.count ?: 0 } ?: 0 - hasNFTs = nftResponse.data?.isNotEmpty() == true && totalNftCount > 0 - } catch (e: Exception) { - // Ignore NFT API errors - } - } - - if (hasBalance || hasNFTs) { - // Add COA address to avatar list - if (coaAddress !in verifiedCoaAvatars) { - val emojiInfo = AccountEmojiManager.getEmojiByAddress(coaAddress) - val avatarData = AvatarData.Emoji(emojiInfo.emojiId) - verifiedCoaAvatars[coaAddress] = avatarData - - // Update avatar list - val currentAvatars = _avatarList.value.toMutableList() - currentAvatars.add(avatarData) - _avatarList.value = currentAvatars - } - } else { - // Remove COA address from avatar list if it no longer has assets - if (coaAddress in verifiedCoaAvatars) { - val avatarToRemove = verifiedCoaAvatars[coaAddress] - verifiedCoaAvatars.remove(coaAddress) - - // Update avatar list - val currentAvatars = _avatarList.value.toMutableList() - currentAvatars.remove(avatarToRemove) - _avatarList.value = currentAvatars - } - } - } - } - } -}