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