diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt index f795d4ca3f..0081716f65 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt @@ -2,6 +2,7 @@ package com.flipcash.app.login.internal.accounts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,6 +20,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.FlagWithFiat import com.flipcash.app.core.ui.shimmer import com.flipcash.features.login.R import com.getcode.theme.CodeTheme @@ -40,7 +43,8 @@ internal fun AccountRow( onClick = onClick, onLongClick = onLongClick, ) - .padding(vertical = CodeTheme.dimens.grid.x3), + // iOS pads each row 20pt on every side; the list's contentPadding supplies the sides. + .padding(vertical = CodeTheme.dimens.grid.x4), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { @@ -60,56 +64,87 @@ internal fun AccountRow( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { - Text( - text = account.name, - // textLarge is the primary line on the app's other list rows (Blocklist). - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // iOS sets the name and the balance on one 16pt line (appTextMedium), bottom-aligned + // 10pt apart, with 5pt under the line on top of the column's spacing. + Row( + modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x1), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + // Without a balance to push to the end, the Not Found badge follows the name + // directly, as on iOS. + modifier = Modifier.weight(1f, fill = !account.notFound), + text = account.name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Order matters: a resolved balance wins, then the backend's own "no such + // account", then our inability to ask, and only then is the fetch still in + // flight. An account whose balance we could not fetch must not be reported as not + // found — the two say very different things to someone checking their own wallet. + when { + // iOS's AmountText(flagSize: .small): a 15pt flag beside the balance. + account.balance != null -> FlagWithFiat( + fiat = account.balance, + iconSize = CodeTheme.dimens.grid.x3, + ) + + account.notFound -> NotFoundBadge() + + account.balanceUnavailable -> Text( + text = stringResource(R.string.subtitle_balanceUnavailable), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + + // The skeleton the discovery list uses, sized to a short balance on the + // textMedium line. It carries no semantics: TalkBack announces the balance + // when it lands. + else -> Box( + Modifier + .size(width = 60.dp, height = 14.dp) + .shimmer() + ) + } + } Text( text = stringResource(R.string.subtitle_accountCreated, relativeCreationDate), - style = CodeTheme.typography.textSmall, + style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, ) + // The full key on one line, as iOS shows it; the middle gives way only if it can't fit. Text( text = account.ownerAddress, - style = CodeTheme.typography.textSmall, + style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, - ) - } - - // Order matters: a resolved balance wins, then the backend's own "no such account", then - // our inability to ask, and only then is the fetch still in flight. An account whose balance we could not fetch must not be reported as - // not found — the two say very different things to someone checking their own wallet. - when { - account.balance != null -> Text( - text = account.balance.formatted(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - - account.notFound -> Text( - text = stringResource(R.string.subtitle_accountNotFound), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - account.balanceUnavailable -> Text( - text = stringResource(R.string.subtitle_balanceUnavailable), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - - // The skeleton the discovery list uses, sized to a short balance on the textMedium - // line. It carries no semantics: TalkBack announces the balance when it lands. - else -> Box( - Modifier - .size(width = 60.dp, height = 14.dp) - .shimmer() + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, ) } } } +/** iOS's `Badge(decoration: .circle(.textError))`: a 6pt dot in textError beside appTextSmall. */ +@Composable +private fun NotFoundBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Box( + Modifier + .size(6.dp) + .background(CodeTheme.colors.errorText, CircleShape) + ) + Text( + text = stringResource(R.string.subtitle_accountNotFound), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt index d5c6bbb857..9e1f38ad7f 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionContent.kt @@ -8,14 +8,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.flipcash.features.login.R import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState @@ -64,7 +66,7 @@ internal fun AccountSelectionContent( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset), ) { - items(state.accounts, key = { it.id }) { account -> + itemsIndexed(state.accounts, key = { _, account -> account.id }) { index, account -> AccountRow( account = account, isCurrent = account.entropy == state.currentEntropy, @@ -74,6 +76,14 @@ internal fun AccountSelectionContent( onClick = { onSelect(account.entropy) }, onLongClick = { onRemove(account.entropy) }, ) + // iOS separates the rows with a hairline in rowSeparator, the same white + // at 10% as divider. + if (index < state.accounts.lastIndex) { + HorizontalDivider( + color = CodeTheme.colors.divider, + thickness = 0.5.dp, + ) + } } } } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt index da0153d4dd..eeda5020af 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt @@ -239,7 +239,7 @@ class AccountSelectionViewModel @Inject constructor( mnemonicName = mnemonic?.let { displayName(it) }.orEmpty(), username = profile?.username, displayName = profile?.displayName, - ownerAddress = owner?.let { truncateAddress(it) }.orEmpty(), + ownerAddress = owner.orEmpty(), creationDate = creationDate, // A record we cannot derive from is one we can never fetch a balance for. balanceUnavailable = cluster == null, @@ -341,10 +341,6 @@ class AccountSelectionViewModel @Inject constructor( word.lowercase().replaceFirstChar { it.titlecase() } } - fun truncateAddress(address: String): String = - if (address.length <= 8) address - else "${address.take(4)}...${address.takeLast(4)}" - private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnAccountsLoaded -> { state -> diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt index 6e036de4c9..4f2af226cb 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModelTest.kt @@ -24,15 +24,4 @@ class AccountSelectionViewModelTest { val name = AccountSelectionViewModel.displayName(phrase("Apple", "Elder")) assertEquals("Apple ... Elder", name) } - - @Test - fun `truncates an owner address in the middle`() { - val truncated = AccountSelectionViewModel.truncateAddress("ABCDEFGHIJKLMNOPQRSTUVWXYZ") - assertEquals("ABCD...WXYZ", truncated) - } - - @Test - fun `leaves a short address alone`() { - assertEquals("ABCD", AccountSelectionViewModel.truncateAddress("ABCD")) - } } diff --git a/maestro/blockstore_account_list.yaml b/maestro/blockstore_account_list.yaml index a0bb3e07b0..2baa4c3537 100644 --- a/maestro/blockstore_account_list.yaml +++ b/maestro/blockstore_account_list.yaml @@ -22,7 +22,7 @@ tags: visible: id: account_selection_screen timeout: 15000 -- assertVisible: "Select an Account" +- assertVisible: "Select Account" # The row itself. The display name comes from the seed, which the flow does not know, so the # creation subtitle is what identifies a row generically. - assertVisible: "Created .*" diff --git a/maestro/blockstore_switcher.yaml b/maestro/blockstore_switcher.yaml index 934ec80165..7866538eba 100644 --- a/maestro/blockstore_switcher.yaml +++ b/maestro/blockstore_switcher.yaml @@ -17,7 +17,7 @@ tags: visible: id: account_selection_screen timeout: 15000 -- assertVisible: "Select an Account" +- assertVisible: "Select Account" - assertVisible: "Created .*" # From inside the app there is no onboarding flow to fall through to, so the footer is hidden — # which makes the back control the only way out. diff --git a/ui/resources/src/main/res/values/strings-localized.xml b/ui/resources/src/main/res/values/strings-localized.xml index 3b58ba6e5d..9e122ff4a4 100644 --- a/ui/resources/src/main/res/values/strings-localized.xml +++ b/ui/resources/src/main/res/values/strings-localized.xml @@ -375,7 +375,7 @@ Require Touch ID Results Returned - Select an Account + Select Account Select a Country Select a Currency Sent