From 584fa6cf05889f89c08d3771a9b0c1c2a04b0f58 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:18:30 -0400 Subject: [PATCH 1/9] refactor(onboarding): move NewUserTutorial into core-ui and split TutorialItem The You tab needs the same checklist card the wallet already draws, and a feature module cannot reach another feature's internal package. core-ui is where shared composables live and the convention plugin already puts it on every feature module, so the move only changes the package. The strings and drawables the component reads stay in :apps:flipcash:core, which core-ui depends on, so the R import is unchanged. TutorialItem gains nested Wallet and Profile families and the composable gains a type parameter, so each screen's when stays exhaustive over the items it can actually be given. The card radius moves to shapes.extraSmall, which is the 6 dp the design asks for. --- .../core/ui/onboarding}/NewUserTutorial.kt | 33 +++++++++++++------ .../balance/internal/WalletScreenContent.kt | 4 +-- .../app/balance/internal/WalletViewModel.kt | 6 ++-- .../internal/WalletLoadingStateTest.kt | 2 +- .../internal/WalletMilestoneGatingTest.kt | 2 +- 5 files changed, 30 insertions(+), 17 deletions(-) rename apps/flipcash/{features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components => core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding}/NewUserTutorial.kt (84%) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt similarity index 84% rename from apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt rename to apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt index c8a9f930c..6e666f88b 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/components/NewUserTutorial.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.balance.internal.components +package com.flipcash.app.core.ui.onboarding import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -27,9 +27,17 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach -import com.flipcash.features.balance.R +import com.flipcash.core.R import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall +/** + * A row in a "what's left to do" checklist. + * + * Split by the screen that owns it: [Wallet] and [Profile] are drawn by different tabs and share + * nothing but the row layout, so each call site's `when` stays exhaustive over its own family and + * cannot be handed an item it has no branch for. + */ sealed interface TutorialItem { val title: String @Composable get @@ -39,17 +47,22 @@ sealed interface TutorialItem { @Composable get val isCompleted: Boolean - class AddMoney(override val isCompleted: Boolean) : TutorialItem { + /** The wallet tab's new-user milestones. */ + sealed interface Wallet : TutorialItem + + /** The "You" tab's profile-completion steps (node 9544:18140). */ + sealed interface Profile : TutorialItem + + class AddMoney(override val isCompleted: Boolean) : Wallet { override val title: String @Composable get() = stringResource(R.string.title_addMoney) override val description: String @Composable get() = stringResource(R.string.subtitle_addMoney) override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.AddCircleOutline) - } - class ScanTipCard(override val isCompleted: Boolean) : TutorialItem { + class ScanTipCard(override val isCompleted: Boolean) : Wallet { override val title: String @Composable get() = stringResource(R.string.title_scanTipCard) override val description: String @@ -60,11 +73,11 @@ sealed interface TutorialItem { } @Composable -fun NewUserTutorial( +fun NewUserTutorial( title: String, - items: List, + items: List, modifier: Modifier = Modifier, - onItemClicked: (TutorialItem) -> Unit, + onItemClicked: (T) -> Unit, ) { val completedCount = remember(items) { items.count { it.isCompleted } } @@ -93,7 +106,7 @@ fun NewUserTutorial( Column( modifier = Modifier - .clip(CodeTheme.shapes.medium) + .clip(CodeTheme.shapes.extraSmall) .background(color = Color.White.copy(0.05f)), ) { items.fastForEach { item -> @@ -152,4 +165,4 @@ private fun OnboardingItemRow( contentDescription = null ) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 9d774a1dd..880cd5310 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -38,8 +38,8 @@ import kotlinx.coroutines.launch import com.flipcash.app.core.ui.AppreciationStyle import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader -import com.flipcash.app.balance.internal.components.NewUserTutorial -import com.flipcash.app.balance.internal.components.TutorialItem +import com.flipcash.app.core.ui.onboarding.NewUserTutorial +import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.core.ui.TileButton import com.flipcash.app.core.ui.TileButtonStyle diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index f416bd7f6..640dd2fec 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -3,7 +3,7 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.balance.internal.components.TutorialItem +import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.shared.transactionhistory.FeedSyncState @@ -53,7 +53,7 @@ internal class WalletViewModel @Inject constructor( * without waiting on the network. The tip milestone inside it is the one that needs a * server round-trip; [isTipMilestoneResolved] says whether it can be believed yet. */ - val onboardingItems: List? = null, + val onboardingItems: List? = null, /** * Whether [TutorialItem.ScanTipCard]'s answer is trustworthy. * @@ -111,7 +111,7 @@ internal class WalletViewModel @Inject constructor( sealed interface Event { data class OnOnboardingItemsUpdated( - val items: List, + val items: List, val holdsBalance: Boolean, val isTipMilestoneResolved: Boolean, ): Event diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt index 0a750bdbb..c6a52c41d 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -1,6 +1,6 @@ package com.flipcash.app.balance.internal -import com.flipcash.app.balance.internal.components.TutorialItem +import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.shared.transactionhistory.FeedSyncState import com.flipcash.shared.transactionhistory.TransactionAvatar import com.flipcash.shared.transactionhistory.TransactionListItem diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt index 2c10bdcc0..0a2461499 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt @@ -2,7 +2,7 @@ package com.flipcash.app.balance.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.StubFlipcashAnalytics -import com.flipcash.app.balance.internal.components.TutorialItem +import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.funding.PurchaseMethodController From a01509a52d9303cf6552ce232b81270a665e28a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:19:05 -0400 Subject: [PATCH 2/9] feat(onboarding): add the finish-profile icon and copy Copy from node 9544:18140. The people-in-a-circle glyph has no equivalent in the repo, so it comes from the design export rather than being hand-drawn. --- .../main/res/drawable/ic_people_circle.xml | 21 +++++++++++++++++++ .../core/src/main/res/values/strings.xml | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml diff --git a/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml b/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml new file mode 100644 index 000000000..ef9b32f04 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index dc07f4d0b..bc6a54021 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -488,6 +488,7 @@ What do you want to call your currency? Currency Name Next + Save Upload Currency Icon Choose an image that represents your currency.\nIt will be displayed as a circular icon. 500x500 Recommended @@ -540,6 +541,14 @@ Scan a Tip Card Give your first tip + Finish Your Profile + + Add a profile picture + Select a photo from your gallery + + Set your minimum tip amount + Decide what size tip matters to you + Amount to Buy Amount to Sell Enter up to %1$s From d0a473fbd2cdba65a37f337ccadb50da323494d3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:19:33 -0400 Subject: [PATCH 3/9] feat(onboarding): add the profile-completion tutorial items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilePicture is live. MinimumTip is drawn and inert — nothing backs a user-set minimum yet on either platform — so the checklist reads 1/2 at best and the card stays put, which is the state node 9641:17019 draws. --- .../app/core/ui/onboarding/NewUserTutorial.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 6e666f88b..1ad1e9f6c 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 @@ -25,8 +25,11 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall @@ -70,6 +73,30 @@ sealed interface TutorialItem { override val icon: Painter @Composable get() = painterResource(R.drawable.ic_nav_scan) } + + class ProfilePicture(override val isCompleted: Boolean) : Profile { + override val title: String + @Composable get() = stringResource(R.string.title_addProfilePicture) + override val description: String + @Composable get() = stringResource(R.string.subtitle_addProfilePicture) + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_people_circle) + } + + /** + * Drawn but inert. Nothing backs a user-set minimum tip yet: the amount comes from + * server-supplied regional presets, no field for it exists on the profile or the tip-card + * customization message, and iOS has no implementation either. The row is in the design, so + * it is drawn — and it never completes, which is the state node 9641:17019 shows. + */ + class MinimumTip(override val isCompleted: Boolean = false) : Profile { + override val title: String + @Composable get() = stringResource(R.string.title_setMinimumTip) + override val description: String + @Composable get() = stringResource(R.string.subtitle_setMinimumTip) + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_coins) + } } @Composable @@ -166,3 +193,31 @@ private fun OnboardingItemRow( ) } } + +@Preview(name = "Finish Your Profile — nothing done") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun PreviewFinishProfileEmpty() { + NewUserTutorial( + title = stringResource(R.string.title_finishYourProfile), + items = listOf( + TutorialItem.ProfilePicture(isCompleted = false), + TutorialItem.MinimumTip(), + ), + onItemClicked = {}, + ) +} + +@Preview(name = "Finish Your Profile — photo set") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun PreviewFinishProfilePhotoSet() { + NewUserTutorial( + title = stringResource(R.string.title_finishYourProfile), + items = listOf( + TutorialItem.ProfilePicture(isCompleted = true), + TutorialItem.MinimumTip(), + ), + onItemClicked = {}, + ) +} From 960e71534ab574a5c79d130b7aa1c7369a5b90f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:20:15 -0400 Subject: [PATCH 4/9] feat(menu): derive the finish-profile checklist from the user profile UserProfile.profilePicture is the completion signal; null while the profile is unresolved so the card is never drawn against a guess. --- .../app/menu/internal/ProfileTutorial.kt | 20 ++++++++ .../app/menu/internal/ProfileTutorialTest.kt | 49 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt 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 new file mode 100644 index 000000000..ae5925adf --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.app.core.ui.onboarding.TutorialItem +import com.flipcash.services.models.UserProfile + +/** + * The "Finish Your Profile" checklist for the "You" tab (node 9544:18140). + * + * Null while the profile is unresolved, so the card is never drawn against a guess — an account + * that already has a photo would otherwise flash an outstanding step on the way in. + * + * The minimum-tip step is always outstanding; see [TutorialItem.MinimumTip]. + */ +internal fun profileTutorialItems(profile: UserProfile?): List? { + profile ?: return null + return listOf( + TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), + TutorialItem.MinimumTip(), + ) +} 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 new file mode 100644 index 000000000..f0000ca28 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.app.core.ui.onboarding.TutorialItem +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MediaItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProfileTutorialTest { + + // MediaItem is a plain data class over a rendition list, so an empty one stands in for + // "a picture is set" without needing a mocking library in this module. + private val anyPicture = MediaItem(renditions = emptyList()) + + private fun profile(picture: MediaItem?) = UserProfile( + displayName = "Brandon", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + profilePicture = picture, + ) + + @Test + fun `an unresolved profile has no checklist`() { + assertNull(profileTutorialItems(profile = null)) + } + + @Test + fun `a profile without a picture leaves both steps outstanding`() { + val items = profileTutorialItems(profile(picture = null)) + assertEquals(2, items?.size) + assertTrue(items!!.none { it.isCompleted }) + } + + @Test + fun `a profile with a picture completes only the picture step`() { + val items = profileTutorialItems(profile(picture = anyPicture)) + assertEquals(1, items?.count { it.isCompleted }) + assertTrue(items!!.first { it is TutorialItem.ProfilePicture }.isCompleted) + } + + @Test + fun `the minimum tip step never completes`() { + val items = profileTutorialItems(profile(picture = anyPicture)) + assertTrue(items!!.none { it is TutorialItem.MinimumTip && it.isCompleted }) + } +} From 4c32afe96cdf6d2fe08d0d2bafd80c12370fbb69 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:21:17 -0400 Subject: [PATCH 5/9] feat(menu): carry the finish-profile checklist and its photo route The photo row reuses AppRoute.UpdateUserProfile with includePhoto only, which buildUpdateUserProfileStack reduces to a single UpdateProfileStep.Photo. --- .../app/menu/internal/MenuScreenViewModel.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index c06d4902f..979ed2301 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -12,6 +12,7 @@ import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.share.TipCodeExportFormat import com.flipcash.app.core.share.TipCodeExporter +import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.util.Linkify import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.core.toast.SystemToastController @@ -106,6 +107,9 @@ internal class MenuScreenViewModel @Inject constructor( // The gate, formatted (e.g. `$100 USD`). Carried next to [usernameProgress] because both the // card's locked subtitle and the sheet behind its tap quote it. val usernameMinimumBalance: String = "", + // The "Finish Your Profile" checklist, or null while the profile is unresolved. Only ever + // drawn under a claimed card — see [ClaimedTipCard]. + val profileTutorial: List? = null, ) { /** The card to share, export or expand — only a claimed one qualifies. */ val tipCard: Scannable.TipCard? @@ -159,6 +163,10 @@ internal class MenuScreenViewModel @Inject constructor( val progress: UsernameProgress?, val minimumBalance: String, ) : Event + data class OnProfileTutorialChanged(val items: List?) : Event + + /** The checklist's photo row — opens the photo step of the profile flow on its own. */ + data object SetProfilePicture : Event /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ data object ClaimUsername : Event @@ -272,6 +280,17 @@ internal class MenuScreenViewModel @Inject constructor( } .launchIn(viewModelScope) + // Gated on Ready for the same reason as the tip card: a named account restores its cached + // profile before auth completes, so the checklist would otherwise flash an outstanding + // photo step at someone who already has one. + userManager.state + .filter { it.authState is AuthState.Ready } + .map { it.userProfile } + .distinctUntilChanged() + .map { profileTutorialItems(it) } + .onEach { dispatchEvent(Event.OnProfileTutorialChanged(it)) } + .launchIn(viewModelScope) + // The username nudge. Gated on Ready for the same reason as the tip card: a named account // restores its cached profile before auth completes, so the card would otherwise flash for // someone who already holds a handle. @@ -365,6 +384,25 @@ internal class MenuScreenViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent( + Event.OpenScreen( + AppRoute.UpdateUserProfile( + origin = AppRoute.Sheets.Menu, + nameSource = DisplayNameSource.MyAccount, + // Photo only: the account already has a name and a card by the time + // this checklist is drawn, so the flow reduces to the one step. + includeName = false, + includePhoto = true, + includeUsername = false, + ) + ) + ) + } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { stateFlow.value.tipLink } @@ -520,10 +558,15 @@ internal class MenuScreenViewModel @Inject constructor( ) } + is Event.OnProfileTutorialChanged -> { state -> + state.copy(profileTutorial = event.items) + } + is Event.PresentDepositOptions, Event.CheckForUpdate, Event.ClaimTipCard, Event.ClaimUsername, + Event.SetProfilePicture, Event.ShareTipCard, Event.CopyTipLink, Event.DownloadTipCard, From 9434093c0a505dd33406d8217d2067b73a91d125 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:22:18 -0400 Subject: [PATCH 6/9] feat(menu): draw the finish-profile checklist on the You tab Sits between the full-screen caption and the tip link, per node 9641:17031. --- .../app/menu/internal/MenuScreenContent.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 f7e0557db..a6cd7f4ba 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 @@ -77,6 +77,8 @@ import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.menu.MenuList 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.menu.internal.components.UsernameProgress import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper @@ -280,6 +282,10 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { usernameProgress = state.usernameProgress, usernameMinimumBalance = state.usernameMinimumBalance, onClaimUsername = { viewModel.dispatchEvent(Event.ClaimUsername) }, + profileTutorial = state.profileTutorial, + onSetProfilePicture = { + viewModel.dispatchEvent(Event.SetProfilePicture) + }, ) }, footer = { @@ -407,6 +413,8 @@ private fun YouHeader( usernameProgress: UsernameProgress?, usernameMinimumBalance: String, onClaimUsername: () -> Unit, + profileTutorial: List?, + onSetProfilePicture: () -> Unit, ) { when (tipCardState) { TipCardState.Unknown -> Unit @@ -433,6 +441,8 @@ private fun YouHeader( usernameProgress = usernameProgress, usernameMinimumBalance = usernameMinimumBalance, onClaimUsername = onClaimUsername, + profileTutorial = profileTutorial, + onSetProfilePicture = onSetProfilePicture, ) } } @@ -466,6 +476,8 @@ private fun ClaimedTipCard( usernameProgress: UsernameProgress?, usernameMinimumBalance: String, onClaimUsername: () -> Unit, + profileTutorial: List?, + onSetProfilePicture: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -540,6 +552,23 @@ private fun ClaimedTipCard( .padding(horizontal = CodeTheme.dimens.grid.x5), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { + // Node 9641:17031 puts the checklist directly under the caption, above the + // 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), + items = profileTutorial, + ) { item -> + when (item) { + is TutorialItem.ProfilePicture -> onSetProfilePicture() + // Inert: nothing backs a user-set minimum tip yet. + is TutorialItem.MinimumTip -> Unit + } + } + } + if (link != null) { TipLinkRow(link = link, enabled = enabled, onCopy = onCopyLink) } From fd2c71394127f2b5e2b037ea009caf85aaacb4a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:23:50 -0400 Subject: [PATCH 7/9] feat(user-profile): title the photo step in the app bar Per node 9641:16758 the title moves into the app bar and the button reads Save. Applied to every entry into the step, onboarding included, so the screen does not read differently depending on how it was reached. --- .../core/src/main/res/values/strings.xml | 3 +- .../internal/photo/PhotoSelectionScreen.kt | 30 ++++--------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index bc6a54021..8ffe72ef8 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -977,8 +977,7 @@ AI flagged this photo for impersonation. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X AI flagged this photo as misleading. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X AI flagged this photo as spam. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X - Upload Your Photo - This photo will be shown when receiving tips + Set Profile Picture Your Name 500x500 Recommended My Tip Card diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt index 4cbbd6956..665d4b68e 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,6 +66,8 @@ internal fun PhotoSelectionScreen() { Column { AppBarWithTitle( + title = stringResource(R.string.title_setProfilePicture), + titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.back() @@ -101,29 +102,8 @@ private fun PhotoSelectionScreenContent( CodeScaffold( modifier = Modifier .padding(horizontal = CodeTheme.dimens.inset), - topBar = { - Column( - modifier = Modifier.fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x8), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - Text( - text = stringResource(R.string.title_profileImageSelection), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - - Text( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.inset), - text = stringResource(R.string.subtitle_profileImageSelection), - style = CodeTheme.typography.textSmall, - textAlign = TextAlign.Center, - color = CodeTheme.colors.textSecondary, - ) - } - }, + // The app bar carries the title now; the body is just the photo and the name. + topBar = {}, bottomBar = { Column( modifier = Modifier.fillMaxWidth(), @@ -135,7 +115,7 @@ private fun PhotoSelectionScreenContent( .fillMaxWidth() .navigationBarsPadding() .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_next), + text = stringResource(R.string.action_save), enabled = state.image.isLoaded() && state.processingState.isIdle, isLoading = state.processingState.loading, isSuccess = state.processingState.success, From 8751820731db8a480268e8b090d1598db660e5bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 16:52:58 -0400 Subject: [PATCH 8/9] fix(menu): match the profile tutorial card to the design Four gaps against node 9641:17022 showed up once the card was on a device: - No divider between the checklist and the link row (node 9641:17048). - Rows sat 40dp apart because each row carried the 20dp inset itself. The padding now belongs to the box, and the rows are spaced by one step. - The title rendered at 20sp/W500: `screenTitle` is overridden app-wide in FlipcashDesignSystem, and the design asks for Avenir Demi 18. - The gap under Full Screen was 65dp against roughly 32 in the design. The row padding move shrinks the ripple to the 40dp content box, still above the minimum touch target. NewUserTutorial is shared, so the wallet tab's tutorial card picks up the same spacing and title size. --- .../app/core/ui/onboarding/NewUserTutorial.kt | 30 +++++++++++-------- .../app/menu/internal/MenuScreenContent.kt | 13 +++++++- 2 files changed, 30 insertions(+), 13 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 1ad1e9f6c..d6a92706d 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R @@ -108,19 +109,20 @@ fun NewUserTutorial( ) { val completedCount = remember(items) { items.count { it.isCompleted } } - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset) - ) { + Column(modifier = modifier) { + // Node 9641:17024 pads the header on all four sides rather than putting a gap under it, so + // the space between the title and the box belongs to the header. Row( modifier = Modifier.fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), + .padding(CodeTheme.dimens.grid.x3), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = title, - style = CodeTheme.typography.screenTitle, + // Not `screenTitle`: FlipcashDesignSystem overrides it to 20sp/W500 app-wide, and + // node 9641:17026 asks for Avenir Demi at 18. + style = CodeTheme.typography.textMedium.copy(fontSize = 18.sp), color = CodeTheme.colors.textMain, ) @@ -131,10 +133,14 @@ fun NewUserTutorial( ) } + // Node 9641:17028: the box owns the padding and the rows sit flush inside it, spaced by + // the same step. Column( modifier = Modifier .clip(CodeTheme.shapes.extraSmall) - .background(color = Color.White.copy(0.05f)), + .background(color = Color.White.copy(0.05f)) + .padding(CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { items.fastForEach { item -> OnboardingItemRow( @@ -154,9 +160,8 @@ private fun OnboardingItemRow( modifier: Modifier = Modifier, onClick: () -> Unit, ) { - Row(modifier = modifier - .clickable(enabled = !item.isCompleted, onClick = onClick) - .padding(CodeTheme.dimens.inset), + Row( + modifier = modifier.clickable(enabled = !item.isCompleted, onClick = onClick), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { Image( @@ -172,7 +177,8 @@ private fun OnboardingItemRow( Column( modifier = Modifier .weight(1f) - .alpha(if (item.isCompleted) 0.38f else 1f) + .alpha(if (item.isCompleted) 0.38f else 1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { Text( text = item.title, @@ -186,7 +192,7 @@ private fun OnboardingItemRow( ) } Icon( - modifier = Modifier.align(Alignment.CenterVertically), + modifier = Modifier.align(Alignment.CenterVertically).size(16.dp), painter = painterResource(R.drawable.ic_chevron_right), tint = CodeTheme.colors.textSecondary, contentDescription = null 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 a6cd7f4ba..3862a6d6d 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 @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -544,7 +545,7 @@ private fun ClaimedTipCard( modifier = slideAway.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(CodeTheme.dimens.grid.x13)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) Column( modifier = Modifier @@ -567,6 +568,16 @@ private fun ClaimedTipCard( is TutorialItem.MinimumTip -> Unit } } + + + // 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 + // the divider's own padding. + HorizontalDivider( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), + color = CodeTheme.colors.divider, + thickness = CodeTheme.dimens.border, + ) } if (link != null) { From 2a78ea4d4adc244c99d512f3a36c61b6a274d11d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 17:00:58 -0400 Subject: [PATCH 9/9] fix(menu): drop the tutorial row's title gap The description's line height already separates it from the title, so the extra 5dp only pushed the two rows apart. --- .../com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt | 3 +-- 1 file changed, 1 insertion(+), 2 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 d6a92706d..9e9eeeb80 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 @@ -177,8 +177,7 @@ private fun OnboardingItemRow( Column( modifier = Modifier .weight(1f) - .alpha(if (item.isCompleted) 0.38f else 1f), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + .alpha(if (item.isCompleted) 0.38f else 1f) ) { Text( text = item.title,