From 8bee27adbb3fe2cb491d6ee18b89cddcad70e9eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 20:35:54 -0400 Subject: [PATCH] feat(menu): ask nameless accounts for a display name in Finish Your Profile Accounts created before onboarding asked for a name have none, and nothing on the You tab asks them for one except the blurred card's "Start Receiving Tips" button. Chat labels them "Flipcash User". Add "Add your display name" as the first checklist step, ahead of the profile picture and minimum tip, with the same copy and order as iOS. It opens the same display-name editor as "Start Receiving Tips". A named account sees the step ticked until the other two are done, when the whole checklist goes away as before. A nameless account has no link row or Share / Download, so its checklist sits under the blurred card, above the settings rows. --- .../app/core/ui/onboarding/NewUserTutorial.kt | 16 +++++ .../core/src/main/res/values/strings.xml | 3 + .../app/menu/internal/MenuScreenContent.kt | 67 ++++++++++++++++--- .../app/menu/internal/ProfileTutorial.kt | 7 +- .../app/menu/internal/ProfileTutorialTest.kt | 53 ++++++++++++--- 5 files changed, 124 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt index 0811bafcc4..30b7a1f0c7 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -75,6 +76,19 @@ sealed interface TutorialItem { @Composable get() = painterResource(R.drawable.ic_nav_tipcard) } + /** + * Accounts created before onboarding asked for a name have none, and chat shows them as + * "Flipcash User" until they set one. + */ + class DisplayName(override val isCompleted: Boolean) : Profile { + override val title: String + @Composable get() = stringResource(R.string.title_addDisplayName) + override val description: String + @Composable get() = stringResource(R.string.subtitle_addDisplayName) + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Edit) + } + class ProfilePicture(override val isCompleted: Boolean) : Profile { override val title: String @Composable get() = stringResource(R.string.title_addProfilePicture) @@ -204,6 +218,7 @@ private fun PreviewFinishProfileEmpty() { NewUserTutorial( title = stringResource(R.string.title_finishYourProfile), items = listOf( + TutorialItem.DisplayName(isCompleted = false), TutorialItem.ProfilePicture(isCompleted = false), TutorialItem.MinimumTip(), ), @@ -218,6 +233,7 @@ private fun PreviewFinishProfilePhotoSet() { NewUserTutorial( title = stringResource(R.string.title_finishYourProfile), items = listOf( + TutorialItem.DisplayName(isCompleted = true), TutorialItem.ProfilePicture(isCompleted = true), TutorialItem.MinimumTip(), ), diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 16e1a4e5df..c5b1ff6265 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -555,6 +555,9 @@ Finish Your Profile + + Add your display name + Choose the name people see in chat Add a profile picture Select a photo from your gallery 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 598b803edc..5a521e5875 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 @@ -430,6 +430,9 @@ private fun YouHeader( cardWidth = YouCardWidth, enabled = enabled, onClaim = onClaim, + profileTutorial = profileTutorial, + onSetProfilePicture = onSetProfilePicture, + onSetMinimumTip = onSetMinimumTip, ) is TipCardState.Claimed -> ClaimedTipCard( card = tipCardState.card, @@ -448,6 +451,7 @@ private fun YouHeader( usernameMinimumBalance = usernameMinimumBalance, onClaimUsername = onClaimUsername, profileTutorial = profileTutorial, + onSetDisplayName = onClaim, onSetProfilePicture = onSetProfilePicture, onSetMinimumTip = onSetMinimumTip, ) @@ -484,6 +488,7 @@ private fun ClaimedTipCard( usernameMinimumBalance: String, onClaimUsername: () -> Unit, profileTutorial: List?, + onSetDisplayName: () -> Unit, onSetProfilePicture: () -> Unit, onSetMinimumTip: () -> Unit, ) { @@ -564,17 +569,12 @@ private fun ClaimedTipCard( // link row. Null while the profile is unresolved so it never draws against a // guess. if (profileTutorial != null) { - NewUserTutorial( - modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.title_finishYourProfile), + ProfileTutorial( items = profileTutorial, - ) { item -> - when (item) { - is TutorialItem.ProfilePicture -> onSetProfilePicture() - is TutorialItem.MinimumTip -> onSetMinimumTip() - } - } - + onSetDisplayName = onSetDisplayName, + onSetProfilePicture = onSetProfilePicture, + onSetMinimumTip = onSetMinimumTip, + ) // Node 9641:17048 separates the checklist from the link row. The column // already spaces siblings by 10dp; the rest of the 20dp gap on each side is @@ -648,6 +648,9 @@ private fun UnclaimedTipCardPrompt( cardWidth: Dp, enabled: Boolean, onClaim: () -> Unit, + profileTutorial: List?, + onSetProfilePicture: () -> Unit, + onSetMinimumTip: () -> Unit, blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(), ) { val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction) @@ -760,10 +763,47 @@ private fun UnclaimedTipCardPrompt( } } + // No link row or Share / Download to sit above here, so the checklist goes straight under + // the stand-in, with the display-name step first. + if (profileTutorial != null) { + ProfileTutorial( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x6) + .padding(horizontal = CodeTheme.dimens.grid.x5), + items = profileTutorial, + // The same destination as "Start Receiving Tips": the name is what claims the card. + onSetDisplayName = onClaim, + onSetProfilePicture = onSetProfilePicture, + onSetMinimumTip = onSetMinimumTip, + ) + } + Spacer(Modifier.height(UnclaimedRowsGap)) } } +/** The "Finish Your Profile" checklist, drawn under whichever card the tab is showing. */ +@Composable +private fun ProfileTutorial( + items: List, + onSetDisplayName: () -> Unit, + onSetProfilePicture: () -> Unit, + onSetMinimumTip: () -> Unit, + modifier: Modifier = Modifier, +) { + NewUserTutorial( + modifier = modifier.fillMaxWidth(), + title = stringResource(R.string.title_finishYourProfile), + items = items, + ) { item -> + when (item) { + is TutorialItem.DisplayName -> onSetDisplayName() + is TutorialItem.ProfilePicture -> onSetProfilePicture() + is TutorialItem.MinimumTip -> onSetMinimumTip() + } + } +} + /** The tip card's height-to-width proportion and corner radius, mirrored from `TipCard`. */ private const val TipCardAspectRatio = 333f / 269f private const val TipCardCornerFraction = 0.08f @@ -978,6 +1018,10 @@ private fun Preview_UnclaimedTipCardPrompt() { cardWidth = YouCardWidth, enabled = true, onClaim = {}, + // What a nameless account sees: the display-name step first, nothing done yet. + profileTutorial = profileTutorialItems(UserProfile.Empty), + onSetProfilePicture = {}, + onSetMinimumTip = {}, ) } @@ -995,6 +1039,9 @@ private fun Preview_UnclaimedTipCardPrompt_NoBlur() { cardWidth = YouCardWidth, enabled = true, onClaim = {}, + profileTutorial = null, + onSetProfilePicture = {}, + onSetMinimumTip = {}, blurEnabled = false, ) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt index c8f2e6d1da..bfafd690a3 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt @@ -10,12 +10,15 @@ import com.flipcash.services.models.UserProfile * that already has a photo would otherwise flash an outstanding step on the way in. Null again once * every step is done: a checklist with nothing left to do is just a row of ticks. * - * Both steps read straight off the profile, so a step completed elsewhere — My Account's own - * Minimum Tip row, say — closes here too. + * Every step reads straight off the profile, so a step completed elsewhere — My Account's own + * Minimum Tip row, say — closes here too. An account that already has a name sees that step ticked + * rather than dropped, so the list keeps its length until the whole thing goes away. */ internal fun profileTutorialItems(profile: UserProfile?): List? { profile ?: return null val items = listOf( + // The same test the tab uses to choose the claimed card over the blurred stand-in. + TutorialItem.DisplayName(isCompleted = profile.displayName.isNotEmpty()), TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null), ) diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt index b1bd50cddd..2a4766400d 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.MediaItem import com.getcode.opencode.model.financial.Fiat import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -15,8 +16,12 @@ class ProfileTutorialTest { // "a picture is set" without needing a mocking library in this module. private val anyPicture = MediaItem(renditions = emptyList()) - private fun profile(picture: MediaItem? = null, minimumTip: Fiat? = null) = UserProfile( - displayName = "Brandon", + private fun profile( + name: String = "Brandon", + picture: MediaItem? = null, + minimumTip: Fiat? = null, + ) = UserProfile( + displayName = name, socialAccounts = emptyList(), phoneNumber = null, email = null, @@ -30,28 +35,56 @@ class ProfileTutorialTest { } @Test - fun `a bare profile leaves both steps outstanding`() { - val items = profileTutorialItems(profile()) - assertEquals(2, items?.size) + fun `the steps run display name, picture, minimum tip`() { + val items = profileTutorialItems(profile(name = "")) + assertEquals( + listOf( + TutorialItem.DisplayName::class, + TutorialItem.ProfilePicture::class, + TutorialItem.MinimumTip::class, + ), + items?.map { it::class }, + ) + } + + @Test + fun `a nameless profile leaves every step outstanding`() { + val items = profileTutorialItems(profile(name = "")) assertTrue(items!!.none { it.isCompleted }) } @Test - fun `a profile with a picture completes only the picture step`() { + fun `a named profile shows the display name step ticked rather than dropping it`() { + val items = profileTutorialItems(profile()) + assertEquals(3, items?.size) + assertEquals(1, items!!.count { it.isCompleted }) + assertTrue(items.first { it is TutorialItem.DisplayName }.isCompleted) + } + + @Test + fun `a picture completes the picture step`() { val items = profileTutorialItems(profile(picture = anyPicture)) - assertEquals(1, items?.count { it.isCompleted }) assertTrue(items!!.first { it is TutorialItem.ProfilePicture }.isCompleted) + assertFalse(items.first { it is TutorialItem.MinimumTip }.isCompleted) } @Test - fun `a saved minimum tip completes only the minimum tip step`() { + fun `a saved minimum tip completes the minimum tip step`() { val items = profileTutorialItems(profile(minimumTip = Fiat(1.0))) - assertEquals(1, items?.count { it.isCompleted }) assertTrue(items!!.first { it is TutorialItem.MinimumTip }.isCompleted) + assertFalse(items.first { it is TutorialItem.ProfilePicture }.isCompleted) } @Test - fun `a picture and a minimum tip take the checklist away entirely`() { + fun `a name, a picture and a minimum tip take the checklist away entirely`() { assertNull(profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0)))) } + + @Test + fun `a nameless profile keeps the checklist even with the other two steps done`() { + val items = profileTutorialItems( + profile(name = "", picture = anyPicture, minimumTip = Fiat(1.0)) + ) + assertEquals(listOf(false, true, true), items?.map { it.isCompleted }) + } }