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 }) + } }