From 4c6034249ec436348c1a2185f042a5355ab20579 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 21:23:42 -0400 Subject: [PATCH 1/4] feat(myaccount): put User Profile at the top of the list It sat last, below the settings that edit it. Leading with it puts the account being described before the rows that change it. Order comes from FullMenuList, and the existing assertions use any {}, so the reorder was invisible to the tests. Adds one that pins the full order. --- .../myaccount/MyAccountScreenViewModel.kt | 4 +++- .../MyAccountScreenViewModelStateTest.kt | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index 065e28731..b614fc2f7 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -20,13 +20,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import javax.inject.Inject +// Render order. User Profile leads it, so the account being described comes before the settings +// that change it. private val FullMenuList = buildList { + add(UserProfile) add(ChangeDisplayName) add(ChangeUsername) add(ProfilePicture) add(RequireBiometrics) add(Blocklist) - add(UserProfile) } @HiltViewModel diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index 8b2aa5eca..f815300f4 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -153,6 +153,25 @@ class MyAccountScreenViewModelStateTest { assertTrue(updated.biometricsAvailable) } + @Test + fun `an unlocked user profile leads the list`() { + val unlocked = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(claimed(MyAccountScreenViewModel.State())) + + assertEquals( + listOf( + UserProfile, + ChangeDisplayName, + ChangeUsername, + ProfilePicture, + RequireBiometrics, + Blocklist, + ), + unlocked.items, + ) + } + @Test fun `user profile stays hidden until beta features unlock`() { val locked = MyAccountScreenViewModel.State() From 1dac46cfe6627f248ce7ab6cc825bb0e62d7e494 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 21:46:49 -0400 Subject: [PATCH 2/4] refactor(core): move abbreviatedLink into core User Profile needs the same link formatting the You tab uses, but it can't reach the helper: myaccount depends on :apps:flipcash:shared:menu, not :apps:flipcash:features:menu. Both feature modules get :apps:flipcash:core from the convention plugin, so the helper moves there and becomes public. One implementation means the two screens can't drift on how a link reads. --- .../kotlin/com/flipcash/app/core/util}/AbbreviatedLink.kt | 4 ++-- .../kotlin/com/flipcash/app/core/util}/AbbreviatedLinkTest.kt | 2 +- .../com/flipcash/app/menu/internal/MenuScreenContent.kt | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) rename apps/flipcash/{features/menu/src/main/kotlin/com/flipcash/app/menu/internal => core/src/main/kotlin/com/flipcash/app/core/util}/AbbreviatedLink.kt (93%) rename apps/flipcash/{features/menu/src/test/kotlin/com/flipcash/app/menu/internal => core/src/test/kotlin/com/flipcash/app/core/util}/AbbreviatedLinkTest.kt (97%) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt similarity index 93% rename from apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt index c43c6917e..e85e40d73 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/AbbreviatedLink.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.core.util import com.flipcash.services.models.isUsernameShaped @@ -17,7 +17,7 @@ private const val ABBREVIATED_ID_LENGTH = 5 * * Mirrors iOS `TipCardLinkRow.displayText(for:)`. */ -internal fun String.abbreviatedLink(): String { +fun String.abbreviatedLink(): String { val withoutScheme = substringAfter("://") val lastSegment = withoutScheme.substringAfterLast('/') if (lastSegment.isUsernameShaped()) return withoutScheme diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt similarity index 97% rename from apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt rename to apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt index 2cfc64e0e..459136666 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.menu.internal +package com.flipcash.app.core.util import kotlin.test.Test import kotlin.test.assertEquals diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index 3862a6d6d..5471b7054 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -80,6 +80,7 @@ import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState import com.flipcash.app.core.ui.onboarding.NewUserTutorial import com.flipcash.app.core.ui.onboarding.TutorialItem +import com.flipcash.app.core.util.abbreviatedLink import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper From 6678969e6e4fbac043d941fa4dcf8defb4303765 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 21:46:57 -0400 Subject: [PATCH 3/4] feat(myaccount): show the handle and tip link on User Profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen carried its own edit entry points for the display name and the photo, so two places answered "where do I change this?" Both routes pushed AppRoute.UpdateUserProfile — the same destination My Account already offers one level up. The header is now read only and My Account is the single answer. What the header gains is the public identity it was missing: @handle and the flipcash.com link, tap to copy. The link row is the You tab's row, down to the copy glyph holding a checkmark for 1.5s after the tap, because the clipboard gives no feedback of its own. With no handle claimed it states the minimum balance instead, read from userFlags.resolvedFlags.usernameMinBalance — the same flag the claim screen's rejection dialog quotes, so the two can't name different thresholds. Stated rather than actionable: claiming happens on the You tab. The taller header also made the list exactly one screen tall, which left it unable to scroll and put the last card under the gesture bar. Bottom contentPadding plus navigationBarsPadding fixes both. --- .../app/myaccount/UserProfileScreen.kt | 31 ---- .../userprofile/UserProfileScreenContent.kt | 163 +++++++++++++++--- .../userprofile/UserProfileViewModel.kt | 60 +++++-- .../ContactMethodsViewModelStateTest.kt | 12 ++ .../internal/UserProfileScreenContentTest.kt | 47 +++++ 5 files changed, 248 insertions(+), 65 deletions(-) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index 9315966d2..5474f5079 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.core.R @@ -44,36 +43,6 @@ fun UserProfileScreen() { UserProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.UserProfile, - nameSource = DisplayNameSource.MyAccount, - includeName = true, - includePhoto = false, - ) - ) - }.launchIn(this) - } - - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { - navigator.push( - AppRoute.UpdateUserProfile( - origin = AppRoute.Menu.UserProfile, - nameSource = DisplayNameSource.MyAccount, - includeName = false, - includePhoto = true, - ) - ) - }.launchIn(this) - } - LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt index 356f50ea0..7aa271695 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt @@ -23,7 +23,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -50,6 +49,20 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.components.text.SectionHeader +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.sp +import com.flipcash.app.core.util.abbreviatedLink +import com.getcode.theme.White05 +import com.getcode.theme.extraSmall +import kotlinx.coroutines.delay @Composable internal fun UserProfileScreenContent( @@ -58,16 +71,27 @@ internal fun UserProfileScreenContent( ) { val inset = CodeTheme.dimens.inset LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = inset), + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + // The last section sat directly under the gesture bar, which also left the list exactly + // one screen tall and so unable to scroll at all. + contentPadding = PaddingValues( + start = inset, + end = inset, + bottom = CodeTheme.dimens.grid.x3, + ), ) { - // Profile header — avatar (tap to edit photo) + name with a pencil (tap to edit name). + // Profile header — the account's public identity, read only. Editing the photo and the + // display name lives in My Account, which is the single place those are changed. item(contentType = "profile_header") { ProfileHeader( displayName = state.displayName, profilePicture = state.profilePicture, - onEditName = { dispatch(UserProfileViewModel.Event.EditNameClicked) }, - onEditPhoto = { dispatch(UserProfileViewModel.Event.EditPhotoClicked) }, + username = state.username, + tipCardLink = state.tipCardLink, + usernameMinBalance = state.usernameMinBalance, + onCopyLink = { dispatch(UserProfileViewModel.Event.CopyTipCardLink) }, ) } @@ -267,45 +291,136 @@ internal fun UserProfileScreenContent( private fun ProfileHeader( displayName: String, profilePicture: MediaItem?, - onEditName: () -> Unit, - onEditPhoto: () -> Unit, + username: String?, + tipCardLink: String?, + usernameMinBalance: String, + onCopyLink: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = CodeTheme.dimens.grid.x6), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + // Avatar, name, handle and link are one identity block, so they sit tight together. + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { ContactAvatar( image = profilePicture, - displayName = displayName.orEmpty(), + displayName = displayName, modifier = Modifier .size(96.dp) - .clip(CircleShape) - .clickable { onEditPhoto() }, + .clip(CircleShape), ) - Row( - modifier = Modifier.clickable { onEditName() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { + Text( + text = displayName.takeIf { it.isNotEmpty() } + ?: stringResource(R.string.subtitle_noDisplayName), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + if (username != null) { Text( - text = displayName.takeIf { it.isNotEmpty() } - ?: stringResource(R.string.subtitle_noDisplayName), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, + text = "@$username", + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } else { + // The same flag the entry screen's rejection dialog quotes, so the two never name + // different thresholds. Stated rather than actionable: claiming happens on the You tab. + Text( + text = stringResource(R.string.title_usernameUpsell), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource( + R.string.subtitle_usernameUpsellLocked, + usernameMinBalance, + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + if (tipCardLink != null) { + TipCardLinkRow( + link = tipCardLink, + onCopy = onCopyLink, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), ) + } + } +} + +/** + * The account's public link, tap to copy — the same row the You tab shows, so the link reads and + * behaves identically in both places. The clipboard gives no feedback of its own, so the copy glyph + * holds a checkmark for a moment after the tap. + */ +@Composable +private fun TipCardLinkRow( + link: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + // Bumped rather than latched so a second tap restarts the hold instead of being swallowed. + var copyToken by remember { mutableIntStateOf(0) } + val copied = copyToken > 0 + + LaunchedEffect(copyToken) { + if (copyToken > 0) { + delay(CopyConfirmationMillis) + copyToken = 0 + } + } + + Row( + modifier = modifier + .height(CodeTheme.dimens.grid.x8) + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .clickable { + onCopy() + copyToken++ + } + .padding(horizontal = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_chain_link), + contentDescription = null, + tint = Color.White, + ) + Text( + text = link.abbreviatedLink(), + style = CodeTheme.typography.textSmall.copy(fontSize = 15.sp), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Crossfade( + targetState = copied, + animationSpec = tween(CopyIconFadeMillis, easing = EaseInOut), + label = "copyConfirmation", + ) { showCheck -> Icon( - imageVector = Icons.Default.Edit, + modifier = Modifier.size(20.dp), + painter = painterResource( + if (showCheck) R.drawable.ic_check_circle else R.drawable.ic_copy + ), contentDescription = null, - tint = CodeTheme.colors.textSecondary, - modifier = Modifier.size(16.dp), + tint = Color.White, ) } } } +/** How long the copy button holds the checkmark before reverting (iOS: 1.5s). */ +private const val CopyConfirmationMillis = 1_500L + +/** Cross-fade between the copy and confirmation glyphs (iOS: 0.15s ease-in-out). */ +private const val CopyIconFadeMillis = 150 + /** Non-swipeable card wrapper — just visual styling. */ @Composable private fun CardRow( diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt index 995fd573c..addae848f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileViewModel.kt @@ -4,6 +4,9 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.util.Linkify +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ContactVerificationController @@ -16,6 +19,7 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.uuid +import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -36,6 +40,7 @@ internal class UserProfileViewModel @Inject constructor( private val contactController: ContactVerificationController, private val contactCoordinator: ContactCoordinator, private val profileController: ProfileController, + private val userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, clipboardManager: ClipboardManager, dispatchers: DispatcherProvider, @@ -47,6 +52,15 @@ internal class UserProfileViewModel @Inject constructor( internal data class State( val displayName: String = "", val profilePicture: MediaItem? = null, + /** The claimed handle, or null for an account that hasn't claimed one. */ + val username: String? = null, + /** + * The account's public link — `flipcash.com/` once a handle exists, and the UUID + * form until then. Null only before the account id has loaded. + */ + val tipCardLink: String? = null, + /** The `usernameMinBalance` flag, formatted, for the account that has no handle yet. */ + val usernameMinBalance: String = "", val phone: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, val phoneLinkedForPayment: Boolean = false, @@ -60,18 +74,15 @@ internal class UserProfileViewModel @Inject constructor( data class OnProfileUpdated( val displayName: String, val profilePicture: MediaItem?, + val username: String?, + val tipCardLink: String?, + val usernameMinBalance: String, val phone: VerifiableContactMethod?, val email: VerifiableContactMethod?, val linkedForPayment: Boolean, val socialAccounts: List, ) : Event - /** Open the update-profile flow to set/replace the display name. */ - data object EditNameClicked : Event - - /** Open the update-profile flow to set/replace the profile picture. */ - data object EditPhotoClicked : Event - data object UnlinkPhoneClicked : Event data object UnlinkEmailClicked : Event data object ConnectPhoneClicked : Event @@ -91,19 +102,36 @@ internal class UserProfileViewModel @Inject constructor( data object CopyPublicKey : Event data object CopyAccountId : Event data object CopyPushToken : Event + + /** Put [State.tipCardLink] on the clipboard. */ + data object CopyTipCardLink : Event } init { combine( userManager.state, contactCoordinator.isLinkedForPayment, - ) { state, linkedForPayment -> + userFlags.resolvedFlags, + ) { state, linkedForPayment, flags -> val profile = state.userProfile + val minimum = flags.usernameMinBalance.effectiveValue dispatchEvent( Event.OnProfileUpdated( displayName = profile?.displayName.orEmpty(), profilePicture = profile?.profilePicture, + username = profile?.username, + // Built the same way the You tab builds it, so the two never show different + // links for the same account: the handle when there is one, the id until then. + tipCardLink = state.accountId?.let { userId -> + Linkify.tipcard( + TipCardOwner.preferringUsername(profile?.username, userId) + ) + }, + usernameMinBalance = minimum.formatted( + rule = Fiat.FormattingRule.Truncated, + suffix = minimum.currencyCode.name, + ), // Carry the contact (value + verified) so unverified entries still show. phone = profile?.phoneNumber, email = profile?.email, @@ -227,6 +255,16 @@ internal class UserProfileViewModel @Inject constructor( label = resources.getString(R.string.title_clipboardLabelPushToken) ) }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.tipCardLink } + .onEach { + clipboardManager.setText( + text = it, + label = resources.getString(R.string.title_clipboardLabelTipCardLink) + ) + }.launchIn(viewModelScope) } private suspend fun unlinkPhone() { @@ -283,6 +321,9 @@ internal class UserProfileViewModel @Inject constructor( state.copy( displayName = event.displayName, profilePicture = event.profilePicture, + username = event.username, + tipCardLink = event.tipCardLink, + usernameMinBalance = event.usernameMinBalance, phone = event.phone, email = event.email, phoneLinkedForPayment = event.linkedForPayment, @@ -304,14 +345,13 @@ internal class UserProfileViewModel @Inject constructor( Event.ConnectEmailClicked, Event.ReplacePhoneClicked, Event.ReplaceEmailClicked, - Event.EditNameClicked, - Event.EditPhotoClicked, is Event.UnlinkSocialAccountClicked, Event.NavigateToPhoneVerification, Event.NavigateToEmailVerification, Event.CopyPublicKey, Event.CopyAccountId, - Event.CopyPushToken -> { state -> state } + Event.CopyPushToken, + Event.CopyTipCardLink -> { state -> state } } } } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt index 16f1eccc9..b96631cc5 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/ContactMethodsViewModelStateTest.kt @@ -41,6 +41,9 @@ class ContactMethodsViewModelStateTest { UserProfileViewModel.Event.OnProfileUpdated( displayName = "Alice", profilePicture = null, + username = "alice", + tipCardLink = "https://flipcash.com/alice", + usernameMinBalance = "$5 USD", phone = VerifiableContactMethod("+15551234567", verified = true), email = VerifiableContactMethod("test@example.com", verified = false), linkedForPayment = true, @@ -48,6 +51,8 @@ class ContactMethodsViewModelStateTest { ) )(UserProfileViewModel.State()) assertEquals("Alice", updated.displayName) + assertEquals("alice", updated.username) + assertEquals("https://flipcash.com/alice", updated.tipCardLink) assertEquals(VerifiableContactMethod("+15551234567", verified = true), updated.phone) assertEquals(VerifiableContactMethod("test@example.com", verified = false), updated.email) assertTrue(updated.phoneLinkedForPayment) @@ -61,6 +66,9 @@ class ContactMethodsViewModelStateTest { UserProfileViewModel.Event.OnProfileUpdated( displayName = "", profilePicture = null, + username = null, + tipCardLink = null, + usernameMinBalance = "$5 USD", phone = null, email = null, linkedForPayment = false, @@ -68,6 +76,10 @@ class ContactMethodsViewModelStateTest { ) )(UserProfileViewModel.State()) assertTrue(updated.displayName.isEmpty()) + // No handle claimed: the header falls back to the upsell, which needs the minimum. + assertNull(updated.username) + assertNull(updated.tipCardLink) + assertEquals("$5 USD", updated.usernameMinBalance) assertNull(updated.phone) assertNull(updated.email) assertFalse(updated.phoneLinkedForPayment) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt index 7d214d1a1..a5e01dc02 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/UserProfileScreenContentTest.kt @@ -1,10 +1,13 @@ package com.flipcash.app.myaccount.internal import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.services.models.SocialAccount @@ -24,6 +27,11 @@ class UserProfileScreenContentTest { private var lastEvent: UserProfileViewModel.Event? = null + /** The social section sits below the fold, so bring it into view before asserting on it. */ + private fun scrollTo(text: String) { + composeTestRule.onNode(hasScrollAction()).performScrollToNode(hasText(text)) + } + private fun setScreen(state: UserProfileViewModel.State = UserProfileViewModel.State()) { lastEvent = null composeTestRule.setContent { @@ -49,6 +57,43 @@ class UserProfileScreenContentTest { composeTestRule.onNodeWithText("No display name set").assertIsDisplayed() } + // --------------------------------------------------------------- + // Handle and public link + // --------------------------------------------------------------- + + @Test + fun `claimed handle shown with its link`() { + setScreen( + UserProfileViewModel.State( + username = "alice", + tipCardLink = "https://flipcash.com/alice", + ) + ) + composeTestRule.onNodeWithText("@alice").assertIsDisplayed() + // The scheme is dropped: the link is shown the way it's read aloud. + composeTestRule.onNodeWithText("flipcash.com/alice").assertIsDisplayed() + } + + @Test + fun `unclaimed handle shows the upsell and its minimum`() { + setScreen( + UserProfileViewModel.State( + username = null, + usernameMinBalance = "$5 USD", + ) + ) + composeTestRule.onNodeWithText("Get a custom @username").assertIsDisplayed() + composeTestRule.onNodeWithText("Get your balance to $5 USD or more to unlock") + .assertIsDisplayed() + } + + @Test + fun `tapping the link dispatches CopyTipCardLink`() { + setScreen(UserProfileViewModel.State(tipCardLink = "https://flipcash.com/alice")) + composeTestRule.onNodeWithText("flipcash.com/alice").performClick() + assertTrue(lastEvent is UserProfileViewModel.Event.CopyTipCardLink) + } + // --------------------------------------------------------------- // Phone // --------------------------------------------------------------- @@ -132,6 +177,7 @@ class UserProfileScreenContentTest { followerCount = 0, ) setScreen(UserProfileViewModel.State(socialAccounts = listOf(account))) + scrollTo("@testuser") composeTestRule.onNodeWithText("@testuser").assertIsDisplayed() composeTestRule.onNodeWithText("Test User").assertIsDisplayed() } @@ -139,6 +185,7 @@ class UserProfileScreenContentTest { @Test fun `no social accounts placeholder when list empty`() { setScreen(UserProfileViewModel.State(socialAccounts = emptyList())) + scrollTo("No social accounts linked") composeTestRule.onNodeWithText("No social accounts linked").assertIsDisplayed() } From 3df84af82f15ddd38095ef6a767e9bcd334bcdcb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 21:58:12 -0400 Subject: [PATCH 4/4] fix(myaccount): stop User Profile clipping its last card navigationBarsPadding sat on the list itself, so the gesture-bar inset shortened the viewport and cut the last card off at the padded edge. The inset moves into contentPadding: the list fills the window again and the last card scrolls clear of the bar. The edges now fade with verticalScrollStateGradient, at the top and the bottom, so content reads as running past the app bar and the gesture bar rather than stopping at them. The bar title goes too. The header names the account directly below it, so a title said the same thing twice. --- .../app/myaccount/UserProfileScreen.kt | 10 ++----- .../userprofile/UserProfileScreenContent.kt | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index 5474f5079..9bfde8931 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -7,13 +7,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel -import com.flipcash.core.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -31,13 +29,9 @@ fun UserProfileScreen() { modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { + // No title: the header below names the account, so a bar title would say it twice. AppBarWithTitle( - title = { - AppBarDefaults.Title( - text = stringResource(R.string.title_userProfile), - ) - }, - titleAlignment = Alignment.CenterHorizontally, + title = { }, leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, ) UserProfileScreenContent(state = state, dispatch = viewModel::dispatchEvent) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt index 7aa271695..2bf81fac4 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt @@ -8,16 +8,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -52,7 +56,6 @@ import com.getcode.ui.components.text.SectionHeader import androidx.compose.animation.Crossfade import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -62,6 +65,7 @@ import androidx.compose.ui.unit.sp import com.flipcash.app.core.util.abbreviatedLink import com.getcode.theme.White05 import com.getcode.theme.extraSmall +import com.getcode.ui.core.verticalScrollStateGradient import kotlinx.coroutines.delay @Composable @@ -70,16 +74,28 @@ internal fun UserProfileScreenContent( dispatch: (UserProfileViewModel.Event) -> Unit, ) { val inset = CodeTheme.dimens.inset + val listState = rememberLazyListState() + // The gesture-bar inset rides in contentPadding rather than on the list itself: as a layout + // inset it shortened the viewport and cut the last card off at the padded edge, and it left the + // list exactly one screen tall and so unable to scroll at all. As content padding the list still + // fills the window and the last card scrolls clear of the bar, fading out under the gradient. + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() LazyColumn( modifier = Modifier .fillMaxSize() - .navigationBarsPadding(), - // The last section sat directly under the gesture bar, which also left the list exactly - // one screen tall and so unable to scroll at all. + // Fades at both edges, so content reads as running past the app bar and the gesture bar + // rather than stopping at them. + .verticalScrollStateGradient( + scrollState = listState, + showAtStart = true, + showAtEnd = true, + isLongGradient = true, + ), + state = listState, contentPadding = PaddingValues( start = inset, end = inset, - bottom = CodeTheme.dimens.grid.x3, + bottom = bottomInset + CodeTheme.dimens.grid.x3, ), ) { // Profile header — the account's public identity, read only. Editing the photo and the