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/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 new file mode 100644 index 000000000..493ee05bb --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchDialog.kt @@ -0,0 +1,341 @@ +package com.flowfoundation.wallet.page.dialog.profile + +import android.content.Context +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: 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: 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..2fa47e028 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/dialog/profile/ProfileSwitchViewModel.kt @@ -0,0 +1,163 @@ +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.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 +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( + @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>() + + 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) + } +} 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..f826729c8 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/drawer/DrawerLayoutViewModel.kt @@ -0,0 +1,257 @@ +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 +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.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 +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.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) + } + } + } + + 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..367335347 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/model/WalletItemData.kt @@ -0,0 +1,49 @@ +package com.flowfoundation.wallet.page.main.model + +import com.google.gson.annotations.SerializedName + + +data class WalletAccountData( + @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( + @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 new file mode 100644 index 000000000..05bfad76e --- /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..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 @@ -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 @@ -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 @@ -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( @@ -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) } @@ -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/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/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..e41afffdd --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/LinkedAccountSection.kt @@ -0,0 +1,230 @@ +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) + } + ) + } 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) + } + ) + } + } +} 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..9b0e0fcc5 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/ProfileItemSection.kt @@ -0,0 +1,181 @@ +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.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.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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 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.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() +) { + + 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..be2c4cc49 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/page/wallet/view/WalletAccountSection.kt @@ -0,0 +1,231 @@ +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.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 + + +@Composable +fun WalletAccountSection( + item: WalletAccountData, + balance: String, + isSelected: Boolean = false, + canSelected: Boolean = false, + onCopyClick: ((String) -> Unit)? = null, + onAccountClick: (() -> Unit)? = null, + onVisibilityToggle: (() -> Unit)? = null +) { + // 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 + } + 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, unhideIcon) = 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 + ) + } + } + + // 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 = 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) + } + ) + } + } + } +} 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