From e984575d0d043202d6b86e6f8f7a84a6896bec82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 12:02:37 -0400 Subject: [PATCH 1/5] feat(onboarding): land new accounts on Chats A new account finishing onboarding now opens on Chats, where a cold launch and a sign-in already open, instead of Wallet (node 12527:81350). The permissions phase releases to launchRoute in every case. AppRoute.OnboardingFlow.newAccount existed only to pick Wallet for that release, so it goes. homeRoute stays Wallet as the base for launch deeplinks. OnboardingFlowScreenTest renders the permissions phase and asserts the stack ends as [Tabs.Chats]; it fails if the landing reverts to homeRoute. The create_account Maestro subflow now waits for chats_screen. --- .../app/internal/ui/navigation/MainRoot.kt | 1 - .../navigation/BuildNavGraphForLaunchTest.kt | 2 - .../kotlin/com/flipcash/app/core/AppRoute.kt | 3 - .../flipcash/app/core/navigation/HomeRoute.kt | 3 +- .../app/core/navigation/HomeRouteTest.kt | 6 +- .../app/login/OnboardingFlowScreen.kt | 27 +++------ .../app/login/OnboardingFlowScreenTest.kt | 60 +++++++++++++++++++ .../app/login/OnboardingRoutingTest.kt | 23 ------- maestro/subflows/create_account.yaml | 4 +- 9 files changed, 73 insertions(+), 56 deletions(-) create mode 100644 apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingFlowScreenTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index b36b515b45..cf5d858ff4 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -231,7 +231,6 @@ internal fun buildNavGraphForLaunch( target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, - newAccount = true, ), allowBack = false, ) diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index 522c58d98a..f5df6ef99a 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -188,8 +188,6 @@ class BuildNavGraphForLaunchTest { ) val target = assertIs(route.target) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) - // Display-name entry follows the access key, so this is an account still being created. - assertTrue(target.newAccount) } // -- Authenticating -- diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 1eecce3441..9c41d4c450 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -85,9 +85,6 @@ sealed interface AppRoute : NavKey, Parcelable { val fromDeeplink: Boolean = false, val resumeAt: ResumePoint = ResumePoint.Login, val skipContacts: Boolean = true, - // Set when onboarding is creating the account rather than signing in to one; decides - // where the permissions phase releases to. - val newAccount: Boolean = false, ) : AppRoute, FlowRoute { enum class Phase { Account, Permissions } enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt index 394b164aab..a0cc221a54 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt @@ -3,8 +3,7 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.AppRoute /** - * The top-level route the app releases to when onboarding finishes creating a new account, and - * under a launch deeplink (whose token and cash-link actions open on the wallet). + * The base route every resolved launch deeplink opens on. * * The wallet is a flat tab home (not a sheet), so this route is applied with `replaceAll` / * [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab switch from the diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt index 3ac2379b17..0e86862e17 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt @@ -5,8 +5,8 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Home is where both launch and the end of onboarding release the user to — the Wallet tab, not the - * camera — so a freshly onboarded user lands on their balance. + * [homeRoute] is where launch deeplinks open. Cold launch and the end of onboarding both use + * [launchRoute] (Chats). */ class HomeRouteTest { @@ -17,7 +17,7 @@ class HomeRouteTest { @Test fun `home is a nav bar tab home`() { - // Guards the release path: it's applied with ClearAll, so it must map to a tab or the + // Deeplinks apply it with ClearAll, so it must map to a tab or the // hoisted nav bar would render with no selection. assertEquals(NavBarButton.Wallet, homeRoute.asNavBarTab()) } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 489a50c9e3..41ad745182 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -31,7 +31,6 @@ import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet -import com.flipcash.app.core.navigation.homeRoute import com.flipcash.app.core.navigation.launchRoute import com.flipcash.app.core.onboarding.OnboardingResult import com.flipcash.app.core.onboarding.OnboardingStep @@ -110,9 +109,7 @@ import kotlin.time.Duration.Companion.milliseconds * ¹ Already-granted permissions are auto-skipped via [PermissionsPhaseFlowHost]. * ² Display-name entry is shown only when no display name is set. It reuses the * UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase. - * ³ A new account (1, and 4 when it resumes account creation) lands on the Wallet tab, so a new - * user sees their balance. A restore (2) or an app resume (3) lands on Chats, where a cold launch - * opens. See [onboardingLandingRoute]. + * ³ Every path lands on Chats, where a cold launch opens ([launchRoute]). */ @Composable fun OnboardingFlowScreen( @@ -156,7 +153,7 @@ private fun PermissionsPhaseFlowHost( val userManager = LocalUserManager.current - val home = onboardingLandingRoute(route) + val home = launchRoute val permissionsSteps = buildList { if (!route.skipContacts) add(OnboardingStep.ContactPermission) @@ -239,25 +236,16 @@ private fun AccountPhaseFlowHost( ) } -/** - * Where the permissions phase releases to: the Wallet tab for an account onboarding just created, - * so a new user sees their balance, and the Chats tab a cold launch opens on for an account being - * signed in to. - */ -internal fun onboardingLandingRoute(route: AppRoute.OnboardingFlow): AppRoute = - if (route.newAccount) homeRoute else launchRoute - internal fun resolvePostAccountRoute( result: OnboardingResult, skipContacts: Boolean = true, ): AppRoute? { - val permissionsRoute = AppRoute.OnboardingFlow( - phase = AppRoute.OnboardingFlow.Phase.Permissions, - skipContacts = skipContacts, - ) return when (result) { - is OnboardingResult.ProceedToVerification -> permissionsRoute.copy(newAccount = true) - OnboardingResult.LoggedIn -> permissionsRoute + is OnboardingResult.ProceedToVerification, + OnboardingResult.LoggedIn -> AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = skipContacts, + ) OnboardingResult.Completed -> null } } @@ -281,7 +269,6 @@ private fun FlowNavigator.proceedToNameOrPermi target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, - newAccount = true, ), allowBack = false, ) diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingFlowScreenTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingFlowScreenTest.kt new file mode 100644 index 0000000000..71a44129ee --- /dev/null +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingFlowScreenTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.login + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import com.flipcash.app.core.AppRoute +import com.getcode.navigation.core.CodeNavigator +import com.getcode.navigation.core.EmptyCodeNavigator +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.results.rememberNavResultStateRegistry +import com.getcode.util.permissions.LocalPermissionChecker +import com.getcode.util.permissions.PermissionChecker +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * The end of onboarding releases the user onto Chats, replacing the whole stack (`ClearAll`), + * whether or not they just created the account. + */ +@RunWith(RobolectricTestRunner::class) +class OnboardingFlowScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val allGranted = object : PermissionChecker { + override fun isGranted(permission: String) = true + override fun shouldShowRationale(permission: String) = false + } + + @Test + fun `permissions phase with everything granted lands on chats and clears the stack`() { + val onboarding = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + skipContacts = false, + ) + val navigator = CodeNavigator( + backStack = NavBackStack(AppRoute.Tabs.Wallet, onboarding), + resultStore = EmptyCodeNavigator.resultStore, + onRootReached = {}, + ) + + composeTestRule.setContent { + CompositionLocalProvider( + LocalCodeNavigator provides navigator, + LocalPermissionChecker provides allGranted, + ) { + OnboardingFlowScreen(onboarding, rememberNavResultStateRegistry()) + } + } + composeTestRule.waitForIdle() + + // ClearAll leaves only the destination on the stack. + assertEquals(listOf(AppRoute.Tabs.Chats), navigator.backStack.toList()) + } +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt index 58a6e2844c..abc5c3220f 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt @@ -1,15 +1,11 @@ package com.flipcash.app.login import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.navigation.homeRoute -import com.flipcash.app.core.navigation.launchRoute import com.flipcash.app.core.onboarding.OnboardingResult import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull -import kotlin.test.assertTrue class OnboardingRoutingTest { @@ -23,7 +19,6 @@ class OnboardingRoutingTest { val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(true, flow.skipContacts) - assertTrue(flow.newAccount) } // -- LoggedIn routes to permissions -- @@ -36,7 +31,6 @@ class OnboardingRoutingTest { val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(true, flow.skipContacts) - assertFalse(flow.newAccount) } // -- skipContacts is forwarded -- @@ -58,21 +52,4 @@ class OnboardingRoutingTest { fun `Completed returns null`() { assertNull(resolvePostAccountRoute(OnboardingResult.Completed)) } - - // -- Where onboarding lands -- - - @Test - fun `a new account lands on the wallet`() { - val route = AppRoute.OnboardingFlow( - phase = AppRoute.OnboardingFlow.Phase.Permissions, - newAccount = true, - ) - assertEquals(homeRoute, onboardingLandingRoute(route)) - } - - @Test - fun `an existing account lands where a cold launch does`() { - val route = AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) - assertEquals(launchRoute, onboardingLandingRoute(route)) - } } diff --git a/maestro/subflows/create_account.yaml b/maestro/subflows/create_account.yaml index ae1dd8c4e4..cd5847a8f6 100644 --- a/maestro/subflows/create_account.yaml +++ b/maestro/subflows/create_account.yaml @@ -29,5 +29,5 @@ appId: com.flipcash.app.android text: "Allow" optional: true -# Onboarding releases to the Wallet tab, not the camera (homeRoute = AppRoute.Tabs.Wallet). -- extendedWaitUntil: { visible: { id: wallet_screen }, timeout: 20000 } +# Onboarding releases to the Chats tab, where a cold launch opens (launchRoute = AppRoute.Tabs.Chats). +- extendedWaitUntil: { visible: { id: chats_screen }, timeout: 20000 } From f7cc49c260d203b862c4ee942a3fa42a38356f52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 12:13:53 -0400 Subject: [PATCH 2/5] feat(wallet): show only the Add Money row to new users Frame 11 (node 12527:81782) drops the new-user tutorial card from the wallet. The "Send Your First Tip" checklist and its "Scan a Profile Card" item are gone; the Add Money row stays and hides once money has been added, the same signal that brings in the wallet tiles. Tipping no longer feeds the wallet, so WalletViewModel drops its ChatCoordinator dependency and the balance module drops shared:chat. --- .../com/flipcash/app/core/ui/NavigationBar.kt | 2 +- .../app/core/ui/onboarding/AddMoneyRow.kt | 58 ++++ .../app/core/ui/onboarding/NewUserTutorial.kt | 169 ------------ .../main/res/drawable/ic_people_circle.xml | 6 +- .../core/src/main/res/values/strings.xml | 5 - .../features/balance/build.gradle.kts | 1 - .../balance/internal/WalletScreenContent.kt | 44 ++- .../app/balance/internal/WalletViewModel.kt | 108 +++----- .../balance/internal/BalanceViewModelTest.kt | 3 - .../internal/WalletLoadingStateTest.kt | 82 ++---- .../internal/WalletMilestoneGatingTest.kt | 109 ++++---- .../flipcash/shared/chat/ChatCoordinator.kt | 14 - .../com/flipcash/shared/chat/ChatState.kt | 22 -- .../chat/internal/RealChatCoordinator.kt | 8 +- .../internal/delegates/FeedSyncDelegate.kt | 28 +- .../internal/delegates/MessagingDelegate.kt | 28 -- .../shared/chat/ChatHistoryHydrationTest.kt | 259 ------------------ .../app/persistence/dao/ChatMessageDao.kt | 7 - .../sources/ChatMessageDataSource.kt | 23 +- .../persistence/sources/MessageDataSource.kt | 5 +- .../ActivityFeedCoordinator.kt | 38 ++- .../ResolveHasReceivedMoneyTest.kt | 67 +++++ 22 files changed, 307 insertions(+), 779 deletions(-) create mode 100644 apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/AddMoneyRow.kt delete mode 100644 apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt delete mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt create mode 100644 apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/ResolveHasReceivedMoneyTest.kt diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 693f21ec6a..e1d36b2f22 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -313,7 +313,7 @@ val NavBarButton.label: Int /** * The glyph for [this] tab at the weight its selection calls for (node 10000:111297). Every tab is * drawn as an outline until it is selected, where it fills in, white in both states. The unsuffixed - * drawable is the outline, so `ic_nav_tipcard` is also the outline the tutorial card reuses. + * drawable is the outline. * * The scanner tab carries the tip card glyph because scanning one is what that tab is for. The You * tab draws the user's own photo when there is one (see the avatar branch above) and falls back to diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/AddMoneyRow.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/AddMoneyRow.kt new file mode 100644 index 0000000000..9ab4af6830 --- /dev/null +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/AddMoneyRow.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.core.ui.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall + +/** The wallet tab's call to add money, shown until the account has been funded. */ +@Composable +fun AddMoneyRow( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .clip(CodeTheme.shapes.extraSmall) + .background(color = Color.White.copy(0.05f)) + .clickable(onClick = onClick) + .padding(CodeTheme.dimens.grid.x3), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Icon( + modifier = Modifier.align(Alignment.Top).size(24.dp), + imageVector = Icons.Outlined.AddCircleOutline, + tint = CodeTheme.colors.textMain, + contentDescription = null, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_addMoney), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.subtitle_addMoney), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} 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 deleted file mode 100644 index cb9c858261..0000000000 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.flipcash.app.core.ui.onboarding - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -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.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -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.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastForEach -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. - * - * [Wallet] is the family the wallet tab draws, so its call site's `when` stays exhaustive over - * the milestones it owns. - */ -sealed interface TutorialItem { - val title: String - @Composable get - val description: String - @Composable get - val icon: Painter - @Composable get - val isCompleted: Boolean - - /** The wallet tab's new-user milestones. */ - sealed interface Wallet : 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) : Wallet { - override val title: String - @Composable get() = stringResource(R.string.title_scanTipCard) - override val description: String - @Composable get() = stringResource(R.string.subtitle_scanTipCard) - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_nav_tipcard) - } -} - -@Composable -fun NewUserTutorial( - title: String, - items: List, - modifier: Modifier = Modifier, - onItemClicked: (T) -> Unit, -) { - val completedCount = remember(items) { items.count { it.isCompleted } } - - 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(CodeTheme.dimens.grid.x3), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = title, - // 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, - ) - - Text( - text = "$completedCount / ${items.count()}", - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - - // 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)) - .padding(CodeTheme.dimens.grid.x3), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - items.fastForEach { item -> - OnboardingItemRow( - item = item, - modifier = Modifier.fillMaxWidth(), - ) { - onItemClicked(item) - } - } - } - } -} - -@Composable -private fun OnboardingItemRow( - item: TutorialItem, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - Row( - modifier = modifier.clickable(enabled = !item.isCompleted, onClick = onClick), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - Image( - modifier = Modifier.align(Alignment.Top).size(24.dp), - painter = if (item.isCompleted) { - painterResource(R.drawable.ic_checked_green) - } else { - item.icon - }, - colorFilter = if (!item.isCompleted) ColorFilter.tint(CodeTheme.colors.textMain) else null, - contentDescription = null, - ) - Column( - modifier = Modifier - .weight(1f) - .alpha(if (item.isCompleted) 0.38f else 1f) - ) { - Text( - text = item.title, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - Text( - text = item.description, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) - } - Icon( - 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/core/src/main/res/drawable/ic_people_circle.xml b/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml index ef9b32f04d..6a514dc615 100644 --- a/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml +++ b/apps/flipcash/core/src/main/res/drawable/ic_people_circle.xml @@ -1,6 +1,6 @@ - + Add Money Add money to your account - Scan a Profile Card - Send your first message - - @@ -1656,7 +1652,6 @@ Something Went Wrong We were unable to send your report. Please try again - Send Your First Tip Edit Profile diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index 35b9361647..ad0201b7d6 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { implementation(project(":apps:flipcash:shared:transaction-history")) implementation(project(":apps:flipcash:shared:analytics")) testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) - implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:tokens")) 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 27235aaa38..978dc1acd2 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 @@ -49,8 +49,7 @@ import com.flipcash.app.core.ui.TokenCardArrival import com.flipcash.app.core.ui.TokenCardExpansion import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader -import com.flipcash.app.core.ui.onboarding.NewUserTutorial -import com.flipcash.app.core.ui.onboarding.TutorialItem +import com.flipcash.app.core.ui.onboarding.AddMoneyRow import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.core.ui.TileButton import com.flipcash.app.core.ui.TileButtonStyle @@ -89,15 +88,15 @@ internal fun WalletScreenContent( ReportDrawnWhen { !tokenState.isAwaitingTokens } // The tab waits on the token set and nothing else. Tokens are what the balance and the card deck - // are made of, so there is no drawing the tab without them; the activity preview and the tutorial - // are separate reads that arrive later and fill in underneath. + // are made of, so there is no drawing the tab without them; the activity preview and the Add Money + // row are separate reads that arrive later and fill in underneath. // // Waiting on the activity feed here as well (the earlier gate was `isAwaitingTokens || // isAwaitingActivity`) held a resolved balance behind an unresolved history. The two things that // gate bought are both kept below without the wait: the activity section draws only once there // are rows to draw, and by then tokens have resolved, so a convert row titles itself // "USDF -> Dad Cash" from both mints rather than falling back to the server's bare "Converted"; - // and the tutorial, the one thing that would be *wrong* rather than late if drawn from a + // and the Add Money row, the one thing that would be *wrong* rather than late if drawn from a // still-empty cache -- telling an established account it is new -- keeps the activity wait // explicitly. BalanceHeader's own spinner stays dead code: the header is only ever drawn once // the balance has resolved. @@ -202,31 +201,20 @@ internal fun WalletScreenContent( Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) } - // The milestones are a read of a local cache that starts empty on a fresh login, so an - // established account would be shown the new-user tutorial until its history arrived. This - // is the one section that has to wait the feed out (see State.isAwaitingActivity) -- drawing - // it early is wrong, not merely early. - balanceState.onboardingItems - ?.takeIf { !balanceState.isAwaitingActivity && !balanceState.isNewUserTutorialComplete } - ?.let { items -> - item { - NewUserTutorial( - modifier = Modifier.fillMaxWidth() - .padding(bottom = CodeTheme.dimens.grid.x5), - title = stringResource(R.string.title_tipOnboarding), - items = items, - ) { item -> - when (item) { - is TutorialItem.AddMoney -> { - dispatchEvent(WalletViewModel.Event.PresentDepositOptions) - } - is TutorialItem.ScanTipCard -> { - dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Tabs.Scanner)) - } - } - } + // The added-money answer is a read of a local cache that starts empty on a fresh login, so + // an established account would be shown this row until its history arrived. It has to wait + // the feed out (see State.showAddMoney) -- drawing it early is wrong, not merely early. + if (balanceState.showAddMoney) { + item { + AddMoneyRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x5), + ) { + dispatchEvent(WalletViewModel.Event.PresentDepositOptions) } } + } tokenState.tokens ?.let { tokens -> 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 b4c2826d1a..155746e9b3 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 @@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalytics -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 @@ -14,7 +13,6 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.WalletReveal import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager @@ -37,7 +35,6 @@ internal class WalletViewModel @Inject constructor( dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, analytics: FlipcashAnalytics, - chatCoordinator: ChatCoordinator, feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, walletReveal: WalletRevealCoordinator, @@ -49,25 +46,14 @@ internal class WalletViewModel @Inject constructor( data class State( val preferredOnRampProvider: OnRampProvider.Defined? = null, /** - * Onboarding milestones, or `null` while they are still unknown. The distinction matters: - * an empty/incomplete checklist is what draws the new-user tutorial, and every milestone - * reads as incomplete before its source has reported. + * Whether the account has added money, or `null` while that is still unknown. Every + * answer reads "no" before its source has reported, so `null` is what keeps the Add Money + * row from being drawn at an account that is simply not loaded yet. * * Published off local reads (the feed's own cache and the held balance), so this lands - * 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. + * without waiting on the network. */ - val onboardingItems: List? = null, - /** - * Whether [TutorialItem.ScanTipCard]'s answer is trustworthy. - * - * The tip milestone is read off the chat cache, which reports every account as never - * having tipped until its history has been reconciled with the server — a wait that - * scales with how many conversations the account has. Only the tutorial depends on that - * answer, so only the tutorial waits for it (see [isNewUserTutorialComplete]); the rest - * of the tab draws off state it already has, as iOS does. - */ - val isTipMilestoneResolved: Boolean = false, + val receivedMoney: Boolean? = null, /** * Preview of the most recent unified cross-token activity — at most [RECENT_PREVIEW_COUNT] * rows. The coordinator owns the mapping and enforces the limit; the full paged history is a @@ -85,49 +71,38 @@ internal class WalletViewModel @Inject constructor( val reveal: WalletReveal? = null, ) { val hasReceivedMoney: Boolean - get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true + get() = receivedMoney == true /** - * Treated as complete while unknown, so the tutorial is never the thing we guess at. - * - * "Unknown" covers the un-reconciled chat cache as well as absent milestones: until - * [isTipMilestoneResolved], `ScanTipCard` reads incomplete for everyone, and drawing that - * would tell an established tipper to go and scan a tip card. + * Whether the Add Money row is drawn: money has not been added, and that is known. + * Drawing it from a still-empty cache would show it to an established account. */ - val isNewUserTutorialComplete: Boolean - get() = !isTipMilestoneResolved || onboardingItems?.all { it.isCompleted } != false + val showAddMoney: Boolean + get() = !isAwaitingActivity && !hasReceivedMoney /** - * Whether the onboarding milestones are still settling. + * Whether the added-money answer is still settling. * - * The milestones are a read of a *local cache* that starts empty on a fresh login, so they + * It is a read of a *local cache* that starts empty on a fresh login, so it * cannot be trusted until the feed has been reconciled with the server at least once. - * Without this an established account signing in was shown the new-user tutorial for as - * long as its history took to arrive. Local rows short-circuit the wait: if there is - * already activity to draw, there is nothing to mistake for a new account — and neither is - * a held balance, which is a live read of the account rather than of the cache. + * Local rows short-circuit the wait: if there is already activity to draw, there is + * nothing to mistake for a new account — and neither is a held balance, which is a live + * read of the account rather than of the cache. * - * Read by the tutorial and nothing else. The recent-activity preview needs no such wait — - * it draws when there are rows to draw — and the tab as a whole waits only on the token - * set, which is what the balance and the card deck are made of. - * - * Scoped to the activity feed on purpose. The *chat* cache backing the tip milestone - * settles separately and far later — its hydration waits on a per-conversation backfill — - * and the only thing that reads it is the tutorial, which withholds itself while unsure - * (see [isNewUserTutorialComplete]). Holding the whole tab for it meant the balance and - * the card deck, both long since resolved, waited on an answer neither of them uses. + * Read by the Add Money row and nothing else. The recent-activity preview needs no such + * wait — it draws when there are rows to draw — and the tab as a whole waits only on the + * token set, which is what the balance and the card deck are made of. */ val isAwaitingActivity: Boolean - get() = onboardingItems == null || + get() = receivedMoney == null || (feedSyncState == FeedSyncState.Unknown && transactions.isEmpty() && !holdsBalance) } sealed interface Event { - data class OnOnboardingItemsUpdated( - val items: List, + data class OnReceivedMoneyUpdated( + val hasReceivedMoney: Boolean?, val holdsBalance: Boolean, - val isTipMilestoneResolved: Boolean, - ): Event + ) : Event data class OnTransactionsUpdated(val transactions: List) : Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event @@ -182,32 +157,19 @@ internal class WalletViewModel @Inject constructor( .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) - // Onboarding funnel milestones, derived from durable event history: "added money" = any - // completed *incoming* entry in the activity feed — a buy, a deposit, or a tip received; - // "scanned a tip card" = an outgoing Cash chat message with verb TIPPED. + // "Added money" = any completed *incoming* entry in the activity feed — a buy, a deposit, + // or a tip received. // - // Holding a balance completes "add money" on its own. The feed is a *local* cache of events, - // so an account funded before this install — or on another device — has money but no local + // Holding a balance counts on its own. The feed is a *local* cache of events, so an + // account funded before this install — or on another device — has money but no local // row to prove it, and would otherwise be told to add money it already has. combine( feedCoordinator.hasEverReceivedMoney(), - chatCoordinator.hasEverTipped(), tokenCoordinator.hasAnyBalance, - ) { hasReceivedMoney, hasTipped, holdsBalance -> - // A null tip milestone means the chat cache has not been reconciled yet, and an - // un-hydrated cache reports every account as never having tipped. That is carried as - // `isTipMilestoneResolved` rather than by withholding the emission: the other - // milestone and the action tiles are answerable from local state immediately, and - // holding them back put the chat backfill on the critical path for the whole tab. - // "Scan a Profile Card" is still never drawn as outstanding to someone who already did it - // — [State.isNewUserTutorialComplete] reads complete until this resolves. - Event.OnOnboardingItemsUpdated( - items = listOf( - TutorialItem.AddMoney(isCompleted = hasReceivedMoney || holdsBalance), - TutorialItem.ScanTipCard(isCompleted = hasTipped == true), - ), + ) { hasReceivedMoney, holdsBalance -> + Event.OnReceivedMoneyUpdated( + hasReceivedMoney = receivedMoney(hasReceivedMoney, holdsBalance), holdsBalance = holdsBalance, - isTipMilestoneResolved = hasTipped != null, ) } .onEach { dispatchEvent(it) } @@ -218,6 +180,13 @@ internal class WalletViewModel @Inject constructor( /** Rows of recent activity previewed on the wallet screen (the rest lives on the dive-in). */ const val RECENT_PREVIEW_COUNT = 3 + /** + * A held balance proves money was added; otherwise the feed's answer stands, including its + * `null` while that answer cannot be believed yet. + */ + fun receivedMoney(hasReceivedMoney: Boolean?, holdsBalance: Boolean): Boolean? = + if (holdsBalance || hasReceivedMoney == true) true else hasReceivedMoney + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { Event.OpenCurrencySelection -> { state -> state } @@ -229,11 +198,10 @@ internal class WalletViewModel @Inject constructor( is Event.OnFeedSyncStateChanged -> { state -> state.copy(feedSyncState = event.syncState) } - is Event.OnOnboardingItemsUpdated -> { state -> + is Event.OnReceivedMoneyUpdated -> { state -> state.copy( - onboardingItems = event.items, + receivedMoney = event.hasReceivedMoney, holdsBalance = event.holdsBalance, - isTipMilestoneResolved = event.isTipMilestoneResolved, ) } is Event.OnTransactionsUpdated -> { state -> diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt index a5c31598b6..7f0d800bc4 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt @@ -12,7 +12,6 @@ import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.UserManager import io.mockk.coEvery @@ -47,7 +46,6 @@ class BalanceViewModelTest { private val userManager: UserManager = mockk(relaxed = true) private val userFlags: UserFlagsCoordinator = mockk(relaxed = true) private val purchaseMethodController: PurchaseMethodController = mockk(relaxed = true) - private val chatCoordinator: ChatCoordinator = mockk(relaxed = true) private val feedCoordinator: ActivityFeedCoordinator = mockk(relaxed = true) private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) { every { hasAnyBalance } returns flowOf(false) @@ -67,7 +65,6 @@ class BalanceViewModelTest { dispatchers = dispatchers, purchaseMethodController = purchaseMethodController, analytics = analytics, - chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, walletReveal = walletReveal, 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 ac5cf1ac42..a41cc2fe75 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,5 @@ package com.flipcash.app.balance.internal -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 @@ -11,16 +10,11 @@ import kotlin.time.Instant /** * The wallet tab must not mistake a cold local cache for an empty account: on a fresh login the - * activity feed and the onboarding milestones both read empty until the first sync lands, and - * rendering that verdict greets an established user with the new-user tutorial. + * activity feed and the added-money flag both read empty until the first sync lands, and + * rendering that verdict greets an established user with the Add Money row. */ class WalletLoadingStateTest { - private fun milestones(addedMoney: Boolean, tipped: Boolean) = listOf( - TutorialItem.AddMoney(isCompleted = addedMoney), - TutorialItem.ScanTipCard(isCompleted = tipped), - ) - private val aTransaction = TransactionListItem( id = "1", messageId = listOf(1), @@ -41,7 +35,7 @@ class WalletLoadingStateTest { @Test fun `still awaits activity when milestones report against an unsynced feed`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = false, tipped = false), + receivedMoney = false, feedSyncState = FeedSyncState.Unknown, ) assertTrue(state.isAwaitingActivity) @@ -50,7 +44,7 @@ class WalletLoadingStateTest { @Test fun `stops awaiting once the feed has synced`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = false, tipped = false), + receivedMoney = false, feedSyncState = FeedSyncState.Synced, ) assertFalse(state.isAwaitingActivity) @@ -59,7 +53,7 @@ class WalletLoadingStateTest { @Test fun `stops awaiting when the feed is unreachable rather than spinning forever`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = false, tipped = false), + receivedMoney = false, feedSyncState = FeedSyncState.Unavailable, ) assertFalse(state.isAwaitingActivity) @@ -68,7 +62,7 @@ class WalletLoadingStateTest { @Test fun `cached rows short-circuit the wait — there is nothing to mistake for a new account`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = false), + receivedMoney = true, transactions = listOf(aTransaction), feedSyncState = FeedSyncState.Unknown, ) @@ -78,7 +72,7 @@ class WalletLoadingStateTest { @Test fun `a held balance short-circuits the wait on an unsynced feed`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = false), + receivedMoney = true, transactions = emptyList(), feedSyncState = FeedSyncState.Unknown, holdsBalance = true, @@ -89,7 +83,7 @@ class WalletLoadingStateTest { @Test fun `a held balance does not pre-empt the milestones themselves`() { val state = WalletViewModel.State( - onboardingItems = null, + receivedMoney = null, feedSyncState = FeedSyncState.Synced, holdsBalance = true, ) @@ -97,51 +91,28 @@ class WalletLoadingStateTest { } @Test - fun `tutorial is withheld while the milestones are unknown`() { - assertTrue(WalletViewModel.State().isNewUserTutorialComplete) + fun `add money row is hidden while nothing is known`() { + assertFalse(WalletViewModel.State().showAddMoney) } @Test - fun `tutorial shows once a milestone is known to be outstanding`() { - val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = false), - isTipMilestoneResolved = true, - feedSyncState = FeedSyncState.Synced, - ) - assertFalse(state.isNewUserTutorialComplete) + fun `add money row is hidden while history is unsynced with nothing stored`() { + val state = WalletViewModel.State(receivedMoney = false, feedSyncState = FeedSyncState.Unknown) + assertFalse(state.showAddMoney) } @Test - fun `tutorial is complete when every milestone is`() { - val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = true), - isTipMilestoneResolved = true, - feedSyncState = FeedSyncState.Synced, - ) - assertTrue(state.isNewUserTutorialComplete) + fun `add money row shows once history synced and no money was added`() { + val state = WalletViewModel.State(receivedMoney = false, feedSyncState = FeedSyncState.Synced) + assertTrue(state.showAddMoney) + assertFalse(state.hasReceivedMoney) } - /** - * The chat cache reports every account as never having tipped until it has been reconciled, - * and reconciling it waits on a per-conversation backfill. The tutorial is the only thing that - * reads the answer, so it is the only thing that waits. - */ @Test - fun `an unresolved tip milestone withholds the tutorial without holding the tab`() { - val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = false), - isTipMilestoneResolved = false, - feedSyncState = FeedSyncState.Synced, - ) - assertTrue( - state.isNewUserTutorialComplete, - "an un-reconciled chat cache must not be drawn as an outstanding milestone", - ) - assertFalse( - state.isAwaitingActivity, - "the balance and the card deck do not read the tip milestone and must not wait for it", - ) - assertTrue(state.hasReceivedMoney, "the action tiles read local state and are answerable") + fun `add money row is hidden once money was added, and the tiles show`() { + val state = WalletViewModel.State(receivedMoney = true, feedSyncState = FeedSyncState.Synced) + assertFalse(state.showAddMoney) + assertTrue(state.hasReceivedMoney) } @Test @@ -151,18 +122,17 @@ class WalletLoadingStateTest { /** * An account funded before this install has money but no local feed row to prove it. The - * milestone is fed `hasEverReceivedMoney || holdsBalance`, so the checklist — and the action - * tiles it gates — must read complete off the balance alone. + * flag is fed `hasEverReceivedMoney || holdsBalance`, so the row hides and the action tiles + * show off the balance alone. */ @Test - fun `a held balance completes the add-money milestone without a feed row`() { + fun `a held balance counts as money added without a feed row`() { val state = WalletViewModel.State( - onboardingItems = milestones(addedMoney = true, tipped = true), - isTipMilestoneResolved = true, + receivedMoney = true, feedSyncState = FeedSyncState.Synced, holdsBalance = true, ) assertTrue(state.hasReceivedMoney) - assertTrue(state.isNewUserTutorialComplete) + assertFalse(state.showAddMoney) } } 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 384eb86e40..5757ebe388 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,6 @@ package com.flipcash.app.balance.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.flipcash.app.analytics.FlipcashAnalytics -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 @@ -10,13 +9,12 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.FeedSyncState import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -24,16 +22,11 @@ import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * The tip milestone is read off the chat cache, which reports every account as never having tipped - * until its history has been reconciled — a wait that scales with how many conversations the - * account has. The wallet must not draw that answer, but it must not wait on it either: the - * milestones publish immediately off local state, carrying - * [WalletViewModel.State.isTipMilestoneResolved] to say whether the tip half can be believed, and - * only the tutorial reads that flag. + * The Add Money row hides until the activity feed has synced, shows while no money has been added + * (tipping does not matter), and hides once money is added or a balance is held. */ @OptIn(ExperimentalCoroutinesApi::class) class WalletMilestoneGatingTest { @@ -44,16 +37,14 @@ class WalletMilestoneGatingTest { @get:Rule var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) - private val hasEverTipped = MutableStateFlow(null) + private val hasReceivedMoney = MutableStateFlow(false) + private val hasBalance = MutableStateFlow(false) - private val chatCoordinator: ChatCoordinator = mockk(relaxed = true) { - every { hasEverTipped() } returns hasEverTipped - } private val feedCoordinator: ActivityFeedCoordinator = mockk(relaxed = true) { - every { hasEverReceivedMoney() } returns flowOf(true) + every { hasEverReceivedMoney() } returns hasReceivedMoney } private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) { - every { hasAnyBalance } returns flowOf(true) + every { hasAnyBalance } returns hasBalance } private val walletReveal: WalletRevealCoordinator = mockk(relaxed = true) { @@ -68,76 +59,84 @@ class WalletMilestoneGatingTest { dispatchers = dispatchers, purchaseMethodController = mockk(relaxed = true), analytics = FlipcashAnalytics.None, - chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, walletReveal = walletReveal, ) @Test - fun `milestones publish without waiting on the tip milestone`() = + fun `row is hidden until history has synced`() = runTest(mainCoroutineRule.dispatcher) { dispatchers = TestDispatchers(testScheduler) + every { feedCoordinator.syncState } returns MutableStateFlow(FeedSyncState.Unknown) val vm = createViewModel() advanceUntilIdle() - val state = vm.stateFlow.value - assertNotNull( - state.onboardingItems, - "the add-money milestone is answerable from local state and must not wait", - ) - assertFalse( - state.isTipMilestoneResolved, - "an un-reconciled chat cache cannot answer whether the account has tipped", - ) - assertTrue( - state.isNewUserTutorialComplete, - "so the tutorial withholds itself rather than drawing an answer it does not have", - ) - assertTrue( - state.hasReceivedMoney, - "the milestone the chat cache says nothing about is drawn immediately", - ) + assertFalse(vm.stateFlow.value.showAddMoney) } @Test - fun `the tip milestone is believed once the chat cache reconciles`() = + fun `row shows for a synced account that never added money, tipping or not`() = runTest(mainCoroutineRule.dispatcher) { dispatchers = TestDispatchers(testScheduler) + every { feedCoordinator.syncState } returns MutableStateFlow(FeedSyncState.Synced) val vm = createViewModel() advanceUntilIdle() - hasEverTipped.value = true + + assertTrue(vm.stateFlow.value.showAddMoney) + assertFalse(vm.stateFlow.value.hasReceivedMoney) + } + + @Test + fun `row hides and tiles show once money is added`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { feedCoordinator.syncState } returns MutableStateFlow(FeedSyncState.Synced) + + val vm = createViewModel() + advanceUntilIdle() + hasReceivedMoney.value = true advanceUntilIdle() - val state = vm.stateFlow.value - val items = state.onboardingItems - assertNotNull(items) - assertTrue(state.isTipMilestoneResolved) - assertEquals( - listOf(true, true), - items.map { it.isCompleted }, - "both milestones read complete: the account holds a balance and has tipped", - ) - assertTrue(items.any { it is TutorialItem.ScanTipCard }) + assertFalse(vm.stateFlow.value.showAddMoney) + assertTrue(vm.stateFlow.value.hasReceivedMoney) } @Test - fun `a reconciled cache that has never seen a tip draws the tutorial`() = + fun `a held balance hides the row without a feed row`() = runTest(mainCoroutineRule.dispatcher) { dispatchers = TestDispatchers(testScheduler) + every { feedCoordinator.syncState } returns MutableStateFlow(FeedSyncState.Synced) val vm = createViewModel() advanceUntilIdle() - hasEverTipped.value = false + hasBalance.value = true + advanceUntilIdle() + + assertFalse(vm.stateFlow.value.showAddMoney) + } + + /** The feed answers `null` until it has synced: a stale `false` must not draw the row. */ + @Test + fun `row stays hidden while the feed has no believable answer even if the sync state is Synced`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { feedCoordinator.syncState } returns MutableStateFlow(FeedSyncState.Synced) + hasReceivedMoney.value = null + + val vm = createViewModel() advanceUntilIdle() - val state = vm.stateFlow.value - assertTrue(state.isTipMilestoneResolved) - assertFalse( - state.isNewUserTutorialComplete, - "an answered 'never tipped' is an outstanding milestone, not an unknown one", - ) + assertFalse(vm.stateFlow.value.showAddMoney) } + + @Test + fun `a held balance proves money was added but an unknown feed answer stays unknown`() { + assertEquals(true, WalletViewModel.receivedMoney(hasReceivedMoney = null, holdsBalance = true)) + assertEquals(true, WalletViewModel.receivedMoney(hasReceivedMoney = true, holdsBalance = false)) + assertEquals(null, WalletViewModel.receivedMoney(hasReceivedMoney = null, holdsBalance = false)) + assertEquals(false, WalletViewModel.receivedMoney(hasReceivedMoney = false, holdsBalance = false)) + } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index bf3477c422..68b91235b1 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -299,20 +299,6 @@ interface MessagingOperations { /** Observes all messages in [chatId] as a flat list. */ fun observeMessages(chatId: ChatId): Flow> - /** - * Whether the user has ever sent a tip — a Cash message with verb TIPPED — or `null` while the - * answer is not yet trustworthy. - * - * The read is of a local cache, so an absent TIPPED message means "never tipped" only once that - * cache is known to be complete; before then it is indistinguishable from history that has not - * arrived. Onboarding is the caller, and it draws a tutorial off the answer, so it needs the - * third state rather than a `false` it has to guess about. Resolves to `true`/`false` once - * [ChatHydrationState] leaves [ChatHydrationState.Unknown] — including on - * [ChatHydrationState.Unavailable], so an unreachable server ends the wait instead of extending - * it forever. - */ - fun hasEverTipped(): Flow - /** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */ fun observeMessagesPaged(chatId: ChatId): Flow> diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 92b5f65ab3..8aaea83eea 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -21,7 +21,6 @@ data class ChatState( val typingIndicators: Map> = emptyMap(), val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, - val historyHydration: ChatHydrationState = ChatHydrationState.Unknown, val activeChat: ChatId? = null, // The chats the viewer archived, read in the same combine that builds [feed] so the feed and // the archive set always change together. `feed` and `archivedFeed` split on it. @@ -50,24 +49,3 @@ enum class FeedSyncState { Synced, Error, } - -/** - * How far the *message* cache has got in reconciling itself with the server for the signed-in user. - * - * Distinct from [FeedSyncState], which reports only the conversation-list fetch. That fetch marks - * itself [FeedSyncState.Synced] as soon as the metadata lands, before the per-chat history backfill - * it schedules has run — so it cannot answer "is this account's chat history here yet". Callers that - * read chat history as evidence need that second question: the wallet's "send a tip" milestone looks - * for an outgoing TIPPED message, and an absent one only means the user has never tipped once the - * cache is known to be complete. - */ -enum class ChatHydrationState { - /** No sync has finished a full pass this session — an absent message proves nothing. */ - Unknown, - - /** A sync succeeded and every catch-up it scheduled has run: an absent message really is absent. */ - Hydrated, - - /** A sync completed without success. Callers should stop waiting; the next sync will retry. */ - Unavailable, -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 118d41ab3e..4e7af95252 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -209,12 +209,8 @@ class RealChatCoordinator @Inject constructor( eventStreamDelegate.performDeltaSync(event.chatId) is FeedSyncDelegate.Event.ReadPointerUnreported -> messagingDelegate.reportReadPointer(event.chatId, event.messageId) - // Arrives after every catch-up item above it, because this is one sequential - // collector over a FIFO channel. Anything reading chat history as evidence — - // the wallet's "send a tip" milestone — waits for this rather than for the - // feed sync, which reports itself synced before the backfill is scheduled. - FeedSyncDelegate.Event.CatchUpComplete -> - feedDelegate.markHistoryHydrated() + // Marks the end of the backfill; nothing routes on it. + FeedSyncDelegate.Event.CatchUpComplete -> Unit } }.launchIn(scope) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index d605123b31..4dd856055c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -24,7 +24,6 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatFeeds -import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations import com.flipcash.shared.chat.FeedSyncState @@ -123,10 +122,9 @@ class FeedSyncDelegate @Inject constructor( * [events] is a FIFO channel routed by a single sequential collector in * [RealChatCoordinator][com.flipcash.shared.chat.internal.RealChatCoordinator], so by the * time this is handled every catch-up item ahead of it has finished its suspend call and - * committed its writes. That ordering is the whole reason it exists: it is what makes - * [markHistoryHydrated] safe to call, and it cannot be replaced by watching - * [FeedSyncState][com.flipcash.shared.chat.FeedSyncState], which flips to `Synced` before - * the catch-up is even scheduled. + * committed its writes. It marks the end of the backfill, which + * [FeedSyncState][com.flipcash.shared.chat.FeedSyncState] cannot: that flips to `Synced` + * before the catch-up is even scheduled. */ data object CatchUpComplete : Event } @@ -333,16 +331,6 @@ class FeedSyncDelegate @Inject constructor( syncJob = scope.launch { performFeedSync() } } - /** - * Marks the message cache reconciled with the server. - * - * Called by the coordinator when it routes [Event.CatchUpComplete], not by the sync itself — - * the sync only *schedules* the backfill, and hydration is about that backfill having run. - */ - internal fun markHistoryHydrated() { - stateHolder.update { it.copy(historyHydration = ChatHydrationState.Hydrated) } - } - internal fun cancelJobs() { syncJob?.cancel() feedObserverJob?.cancel() @@ -572,16 +560,6 @@ class FeedSyncDelegate @Inject constructor( stateHolder.update { state -> state.copy( feedSyncState = FeedSyncState.Error, - // Don't downgrade a hydration that already succeeded: a later failure means - // this sync missed, not that the cache stopped being trustworthy. Moving off - // Unknown at all matters though — callers waiting on hydration have to stop - // waiting when the server is unreachable, or the wallet spins forever - // offline. - historyHydration = if (state.historyHydration == ChatHydrationState.Unknown) { - ChatHydrationState.Unavailable - } else { - state.historyHydration - }, ) } fetchLeftoverReadStamps() diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 8c331f263e..56eb704d1e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -45,7 +45,6 @@ import com.flipcash.services.models.GetChatError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatHydration -import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.internal.SenderResolver import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations @@ -171,33 +170,6 @@ class MessagingDelegate @Inject constructor( return messageDataSource.observeMessages(chatId) } - override fun hasEverTipped(): Flow = - stateHolder.state - .map { it.historyHydration } - .distinctUntilChanged() - .flatMapLatest { hydration -> - // Re-subscribing on the hydration change re-runs the query, so the first value a - // caller sees after a backfill is a fresh read. `combine`-ing the two flows instead - // would race: the emission carrying "hydrated" would carry the *pre*-backfill answer - // with it, and the caller would act on `false` a beat before Room's invalidation - // published `true` — the same flash of wrong state, just narrower. - messageDataSource.hasEverTipped().map { tipped -> - when { - // A TIPPED message in the cache is proof regardless of hydration, and - // answering straight away is what keeps a warm cache — the normal case now - // that logout no longer wipes it — from waiting on a round-trip. - tipped -> true - // Absence proves nothing yet. Null rather than false: the caller has to be - // able to tell "has not tipped" from "we have not looked". - hydration == ChatHydrationState.Unknown -> null - else -> false - } - } - } - // The re-subscribe repeats the current answer whenever hydration moves, which for a - // cache that already held the tip is the same `true` twice over. - .distinctUntilChanged() - override fun observeMessagesPaged(chatId: ChatId): Flow> { return Pager( // The first load defaults to three pages. One fills the screen, and every row in it diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt deleted file mode 100644 index 7c24fe5002..0000000000 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ /dev/null @@ -1,259 +0,0 @@ -package com.flipcash.shared.chat - -import com.flipcash.app.core.dispatchers.TestDispatchers -import com.flipcash.app.persistence.sources.ChatMemberDataSource -import com.flipcash.app.persistence.sources.ChatMessageDataSource -import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.app.persistence.sources.ContactDataSource -import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.services.controllers.ChatController -import com.flipcash.services.controllers.ChatMessagingController -import com.flipcash.services.controllers.EventStreamingController -import com.flipcash.services.models.chat.ChatFeedPage -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatMessage -import com.flipcash.services.models.chat.ChatMetadata -import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.ChatUpdate -import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.internal.ChatIdGenerator -import com.flipcash.shared.chat.internal.ChatStateHolder -import com.flipcash.shared.chat.internal.RealChatCoordinator -import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate -import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate -import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate -import com.flipcash.shared.chat.internal.delegates.MessagingDelegate -import com.getcode.opencode.model.accounts.AccountCluster -import com.getcode.utils.network.NetworkConnectivityListener -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertEquals -import kotlin.time.Instant - -/** - * The "send a tip" onboarding milestone is read off the local chat cache, so an absent TIPPED - * message is only evidence once that cache is known to be complete. These tests hold the milestone - * to that: it withholds an answer until the sync's catch-up has actually run, rather than reporting - * the cold-start `false` that re-shows the tutorial to an account which has already tipped. - * - * The feed sync alone cannot be that signal — it reports itself `Synced` after writing the - * conversation list, before the per-chat backfill it schedules has run. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class ChatHistoryHydrationTest { - - private val chatId = ChatId("aabbccdd") - private val selfId = listOf(1, 2, 3) - private val otherId = listOf(4, 5, 6) - - /** Stands in for the Room query behind the milestone; the backfill flips it. */ - private val tippedInCache = MutableStateFlow(false) - - private val messagingController = mockk(relaxed = true) - private val chatController = mockk(relaxed = true) - - private fun message(messageId: Long) = ChatMessage( - messageId = messageId, - senderId = otherId, - content = listOf(MessageContent.Text("hi")), - timestamp = Instant.fromEpochSeconds(1_000 + messageId), - unreadSeq = messageId, - eventSequence = messageId, - ) - - private fun metadata() = ChatMetadata( - chatId = chatId, - type = ChatType.TIP_DM, - members = emptyList(), - lastMessage = message(20), - lastActivity = Instant.fromEpochSeconds(1_000), - latestEventSequence = 0, - ) - - private fun TestScope.buildCoordinator(): RealChatCoordinator { - val userManager = mockk(relaxed = true) - every { userManager.accountId } returns selfId - - val eventStreamingController = mockk(relaxed = true) - every { eventStreamingController.chatUpdates } returns - Channel(Channel.UNLIMITED).receiveAsFlow() - - val messageDataSource = mockk(relaxed = true) - every { messageDataSource.hasEverTipped() } returns tippedInCache - // A cold cache: nothing has messages, so the sync schedules a catch-up for every chat. - coEvery { messageDataSource.hasMessages(any()) } returns false - - val metadataDataSource = mockk(relaxed = true) - val memberDataSource = mockk(relaxed = true) - val stateHolder = ChatStateHolder() - - return RealChatCoordinator( - feedDelegate = FeedSyncDelegate( - messagingController = mockk(relaxed = true), - chatController = chatController, - metadataDataSource = metadataDataSource, - messageDataSource = messageDataSource, - memberDataSource = memberDataSource, - stateHolder = stateHolder, - userManager = userManager, - ), - eventStreamDelegate = EventStreamDelegate( - eventStreamingController = eventStreamingController, - messagingController = messagingController, - metadataDataSource = metadataDataSource, - messageDataSource = messageDataSource, - memberDataSource = memberDataSource, - tokenCoordinator = mockk(relaxed = true), - userManager = userManager, - stateHolder = stateHolder, - analytics = mockk(relaxed = true), - exchange = mockk(relaxed = true), - ), - dmChatResolverDelegate = DmChatResolverDelegate( - chatIdGenerator = ChatIdGenerator(), - userManager = userManager, - contactDataSource = mockk(relaxed = true), - memberDataSource = memberDataSource, - ), - messagingDelegate = MessagingDelegate( - chatController = chatController, - messagingController = messagingController, - metadataDataSource = metadataDataSource, - messageDataSource = messageDataSource, - memberDataSource = memberDataSource, - notificationManager = mockk(relaxed = true), - userManager = userManager, - stateHolder = stateHolder, - analytics = mockk(relaxed = true), - senderResolver = mockk(relaxed = true), - ), - stateHolder = stateHolder, - draftStore = mockk(relaxed = true), - userManager = userManager, - networkObserver = mockk(relaxed = true), - dispatchers = TestDispatchers(testScheduler), - groupFeedDelegate = mockk(relaxed = true), - reactionsDelegate = mockk(relaxed = true), - ) - } - - private fun feedReturns(chats: List) { - coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns - Result.success(ChatFeedPage(emptyList(), null, false)) - coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns - Result.success(ChatFeedPage(chats, null, false)) - } - - private fun TestScope.collectMilestone(coordinator: RealChatCoordinator): List { - val values = mutableListOf() - coordinator.hasEverTipped().onEach { values += it }.launchIn(backgroundScope) - runCurrent() - return values - } - - /** - * Signs in and lets the session's work run to completion, then tears it down. - * - * The teardown is not incidental: signing in starts a heartbeat that delays forever, and - * [runTest] drains the scheduler once the body returns — on the coordinator's own scope, which - * is not [TestScope.backgroundScope] and so outlives the body. Left running, that loop spins - * virtual time indefinitely and the test never ends. - */ - private suspend fun TestScope.session(coordinator: RealChatCoordinator, body: () -> Unit) { - coordinator.onUserLoggedIn(mockk(relaxed = true)) - runCurrent() - // Unconditional: a failing assertion that skipped the teardown would leave the heartbeat - // running, and the resulting spin would bury the assertion under a test that never returns. - try { - body() - } finally { - coordinator.teardown() - } - } - - /** - * The reported bug, at the read side: an account that has tipped signs in, the cache is cold, - * and the milestone must never say `false` on the way to `true`. Anything that reports `false` - * first draws the tutorial for a frame. - */ - @Test - fun `the milestone withholds an answer until the backfill has run`() = runTest { - feedReturns(listOf(metadata())) - // The backfill is what surfaces the tip: it is older than the chat's last message, so the - // feed sync's own write could never have revealed it. - coEvery { messagingController.getMessages(chatId) } coAnswers { - tippedInCache.value = true - Result.success(listOf(message(20))) - } - val coordinator = buildCoordinator() - val values = collectMilestone(coordinator) - - assertEquals(listOf(null), values, "nothing has been reconciled yet") - - session(coordinator) { - assertEquals( - listOf(null, true), - values, - "the milestone must resolve straight from unknown to tipped, never through false", - ) - } - } - - /** Absence is real once the catch-up has run; the caller has to be released to draw. */ - @Test - fun `the milestone reports false once the cache is reconciled`() = runTest { - feedReturns(listOf(metadata())) - coEvery { messagingController.getMessages(chatId) } returns Result.success(emptyList()) - val coordinator = buildCoordinator() - val values = collectMilestone(coordinator) - - session(coordinator) { - assertEquals(listOf(null, false), values) - } - } - - /** - * A warm cache is the normal case now that signing out keeps chat history, and proof of a tip - * cannot go stale — so it answers without waiting for a round-trip. - */ - @Test - fun `a cached tip answers before any sync`() = runTest { - tippedInCache.value = true - feedReturns(emptyList()) - val coordinator = buildCoordinator() - - assertEquals(listOf(true), collectMilestone(coordinator)) - coordinator.teardown() - } - - /** - * Offline, the answer is still not trustworthy — but waiting on a server that cannot be reached - * would hold the wallet on its spinner indefinitely, which is worse than a stale milestone. - */ - @Test - fun `an unreachable server ends the wait rather than extending it`() = runTest { - coEvery { chatController.getDmChatFeed(any(), any()) } returns - Result.failure(RuntimeException("offline")) - val coordinator = buildCoordinator() - val values = collectMilestone(coordinator) - - session(coordinator) { - assertEquals(listOf(null, false), values) - } - } -} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index e649afd7c3..57058311a4 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -26,13 +26,6 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource - /** - * True once the user has sent a tip — an **outgoing** Cash message (sender = self) with verb - * TIPPED. Received tips don't count toward the "scanned a tip card" onboarding milestone. - */ - @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE sender_id_hex = :selfIdHex AND content_json LIKE '%\"action\":\"TIPPED\"%')") - fun hasEverTipped(selfIdHex: String): Flow - /** * The newest [limit] messages across every chat that Search can read: not tombstones, not * empty, and either plaintext or opened on this device. Matching happens in Kotlin, not here: diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 688e5339fc..3054f7f79f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -21,7 +21,6 @@ import com.getcode.opencode.model.core.RandomId import com.getcode.utils.hexEncodedString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterNotNull @@ -57,26 +56,6 @@ class ChatMessageDataSource @Inject constructor( activeChatId = chatId } - /** - * Reactive "has the user ever sent a tip" — an outgoing Cash message (self) with verb TIPPED. - * - * Both inputs are resolved reactively rather than read once at subscription time. The per-user - * DB is created at login and the account id is set during it, so a subscriber that starts before - * either is ready (the wallet tab composing while a soft login is still in flight) used to latch - * onto a constant `false` for the whole session — pinning the onboarding checklist to - * "incomplete" and leaving the new-user tutorial on screen for an established account. - */ - @OptIn(ExperimentalCoroutinesApi::class) - fun hasEverTipped(): Flow = - combine( - FlipcashDatabase.observeInstance(), - userManager.state.map { it.accountId }.distinctUntilChanged(), - ) { database, accountId -> database to accountId } - .flatMapLatest { (database, accountId) -> - val selfHex = accountId?.hexEncodedString() ?: return@flatMapLatest flowOf(false) - database?.chatMessageDao()?.hasEverTipped(selfHex) ?: flowOf(false) - } - // region PagingDataSource override fun observe(): PagingSource { @@ -220,7 +199,7 @@ class ChatMessageDataSource @Inject constructor( /** * [countInboundAfter] as a stream, so arrivals and pointer moves both update it. Resolved through - * [FlipcashDatabase.observeInstance] like [hasEverTipped]: a subscriber that starts before the + * [FlipcashDatabase.observeInstance]: a subscriber that starts before the * per-user DB exists counts 0 until it does, rather than completing empty for good. */ @OptIn(ExperimentalCoroutinesApi::class) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index ad35b8678f..3cd7774ab4 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -91,9 +91,8 @@ class MessageDataSource @Inject constructor( * Resolved through [FlipcashDatabase.observeInstance] for the same reason as [observeRecent]: * the per-user DB is created at login, *after* singletons have built their flow graphs. Reading * `db` once at subscription time latched any subscriber that started before the DB existed onto - * a constant `false` for the rest of the session — which pinned the wallet's onboarding - * checklist to "incomplete" (and so kept the new-user tutorial on screen) no matter how much - * activity later landed in the feed. + * a constant `false` for the rest of the session — which pinned the wallet's Add Money row + * on screen no matter how much activity later landed in the feed. */ @OptIn(ExperimentalCoroutinesApi::class) fun hasEverReceivedMoney(): Flow = diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index 63de8a8008..3e3bcb2064 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -424,9 +424,11 @@ class ActivityFeedCoordinator @Inject internal constructor( /** * Reactive "has money ever come in" — any completed incoming entry in the feed: an on-ramp buy, - * a deposit, or a tip received. + * a deposit, or a tip received. `null` while the answer cannot be believed; see + * [resolveHasReceivedMoney]. */ - fun hasEverReceivedMoney(): Flow = dataSource.hasEverReceivedMoney() + fun hasEverReceivedMoney(): Flow = + resolveHasReceivedMoney(syncState, dataSource::hasEverReceivedMoney) suspend fun checkPendingMessagesForUpdates(): Result { val pendingMessages = dataSource.query(whereClause = "state = '${NotificationState.PENDING.name}'") @@ -481,3 +483,35 @@ class ActivityFeedCoordinator @Inject internal constructor( } } } + +/** + * Folds the feed's sync state into "has money ever come in". + * + * A cold cache reports every account as never having received money until its history has been + * fetched, so absence only counts once the feed has left [FeedSyncState.Unknown]. Until then the + * answer is `null`, which a caller can tell apart from `false`. + * + * [received] is re-subscribed on every sync-state change so the first value after a sync is a + * fresh read. `combine`-ing the two flows instead would race: the emission carrying `Synced` would + * carry the *pre-sync* answer, and the caller would act on `false` a beat before Room's + * invalidation published `true`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal fun resolveHasReceivedMoney( + syncState: Flow, + received: () -> Flow, +): Flow = + syncState + .distinctUntilChanged() + .flatMapLatest { state -> + received().map { hasReceived -> + when { + // A cached incoming entry is proof regardless of sync state. + hasReceived -> true + state == FeedSyncState.Unknown -> null + else -> false + } + } + } + // The re-subscribe repeats the current answer whenever sync state moves. + .distinctUntilChanged() diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/ResolveHasReceivedMoneyTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/ResolveHasReceivedMoneyTest.kt new file mode 100644 index 0000000000..08ed55cb91 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/ResolveHasReceivedMoneyTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.shared.transactionhistory + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ResolveHasReceivedMoneyTest { + + private val syncState = MutableStateFlow(FeedSyncState.Unknown) + private val inRoom = MutableStateFlow(false) + + private fun kotlinx.coroutines.test.TestScope.collect(): List { + val values = mutableListOf() + resolveHasReceivedMoney(syncState) { inRoom } + .onEach { values += it } + .launchIn(backgroundScope) + return values + } + + @Test + fun `absence is unknown until the feed has synced`() = + runTest(UnconfinedTestDispatcher()) { + val values = collect() + assertEquals(listOf(null), values) + } + + @Test + fun `absence after a sync is a definite no`() = + runTest(UnconfinedTestDispatcher()) { + val values = collect() + syncState.value = FeedSyncState.Synced + assertEquals(listOf(null, false), values) + } + + @Test + fun `an unreachable feed ends the wait with a no`() = + runTest(UnconfinedTestDispatcher()) { + val values = collect() + syncState.value = FeedSyncState.Unavailable + assertEquals(listOf(null, false), values) + } + + @Test + fun `a cached entry is proof before the feed has synced`() = + runTest(UnconfinedTestDispatcher()) { + inRoom.value = true + val values = collect() + assertEquals(listOf(true), values) + } + + /** The fetch writes Room, then flips to Synced: the sync must never read as a stale `false`. */ + @Test + fun `money written before the sync lands never reads as no`() = + runTest(UnconfinedTestDispatcher()) { + val values = collect() + inRoom.value = true + syncState.value = FeedSyncState.Synced + assertEquals(listOf(null, true), values) + } +} + From 08fb415f9e97da60d9eb6341c04c840556fc2525 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 12:17:43 -0400 Subject: [PATCH 3/5] feat(menu): nudge new users to complete their profile Frame 12 (node 12527:81838) drops "Claim your username" from the You tab and makes the primary button carry the nudge. Username stays settable from Edit Profile. The button reads "Complete Your Profile" in the dynamic accent the currency creator uses until the profile has a photo, a cover and a bio (isProfileComplete), then "Edit Profile" in the usual gray. Favorite chats are not required. The favorite community chats section now draws a "None yet" body when empty, but only once a read for the current username has succeeded, so an account with favorites does not flash it. The username progress card, its balance gate and ClaimUsername event leave MenuScreenViewModel with the link. --- .../core/src/main/res/values/strings.xml | 5 +- apps/flipcash/features/menu/build.gradle.kts | 4 - .../app/menu/internal/MenuScreenContent.kt | 48 +++--- .../app/menu/internal/MenuScreenViewModel.kt | 146 ++++-------------- .../internal/components/UsernameProgress.kt | 16 -- .../app/menu/internal/OwnProfileStateTest.kt | 46 ++++++ .../ui/profile/FeaturedGroupsSection.kt | 16 +- .../common/ui/profile/ProfileActionButton.kt | 5 +- 8 files changed, 115 insertions(+), 171 deletions(-) delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6cee1cb524..b0e75af77a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1655,7 +1655,7 @@ Edit Profile - Claim your username › + Complete Your Profile Change photo Name Add a name @@ -1690,7 +1690,8 @@ Bio Add a bio - Favorite Public Groups + Favorite Community Chats + None yet Add groups 1 group diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 3d8bf2d322..713a985c05 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -25,10 +25,6 @@ dependencies { implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) - // Balance for the username gate only — :shared:tokens:core is the narrow half, so the - // whole token stack does not come with it. - implementation(project(":apps:flipcash:shared:tokens:core")) - implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) 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 487cda4bbe..fae9a5eb4c 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 @@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign @@ -64,6 +63,7 @@ import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.common.ui.profile.FeaturedGroupsSection import com.flipcash.shared.common.ui.profile.ProfileActionButton import com.flipcash.shared.common.ui.profile.ProfileHeader +import com.getcode.theme.rememberDynamicAccent import com.flipcash.shared.common.ui.profile.ProfileTopScrollEdge import com.flipcash.shared.common.ui.profile.ProfileStatsCard import com.flipcash.shared.common.ui.profile.rememberFeaturedGroupItems @@ -127,9 +127,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { profile = profileState.profile, onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) }, onShare = null, - onClaimUsername = state.usernameProgress?.let { - { viewModel.dispatchEvent(Event.ClaimUsername) } - }, onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, ) } @@ -149,9 +146,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { profile = profileState.profile, onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) }, onShare = { viewModel.dispatchEvent(Event.ShareProfile) }, - onClaimUsername = state.usernameProgress?.let { - { viewModel.dispatchEvent(Event.ClaimUsername) } - }, onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, ) } @@ -170,6 +164,10 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { .padding(horizontal = CodeTheme.dimens.inset) .padding(top = CodeTheme.dimens.staticGrid.x4), groups = rememberFeaturedGroupItems(state.featuredGroups), + showWhenEmpty = showFavoritesEmptyState( + profileState.profile.username, + state.favoritesReadFor, + ), onOpen = { chatId -> viewModel.dispatchEvent( Event.OpenScreen( @@ -197,7 +195,6 @@ private fun OwnProfileHeader( profile: UserProfile?, onEdit: () -> Unit, onShare: (() -> Unit)?, - onClaimUsername: (() -> Unit)?, onSettings: () -> Unit, ) { // The header is what scrolls under the gear, so it is the gear's frosting source. @@ -219,27 +216,24 @@ private fun OwnProfileHeader( title = profile?.displayName?.ifEmpty { null }, subtitle = profile?.username?.takeIf { it.isNotEmpty() }?.let { "@$it" }, body = profile?.bio?.ifEmpty { null }, - // As on iOS: a one-line offer under the handle while there is no handle, or only an - // auto-assigned one. The tap goes through the balance gate like the old progress card. - underSubtitle = { - if (onClaimUsername != null) { - Text( - modifier = Modifier - .padding(top = CodeTheme.dimens.staticGrid.x1) - .clickable(onClick = onClaimUsername) - .padding(vertical = CodeTheme.dimens.staticGrid.x1) - .testTag("you-claim-username"), - text = stringResource(R.string.action_claimYourUsernameLink), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textMain, + actions = { + if (isProfileComplete(profile)) { + ProfileActionButton( + text = stringResource(R.string.action_editProfile), + onClick = onEdit, + ) + } else { + val (accent, onAccent) = rememberDynamicAccent( + fallbackAccent = CodeTheme.colors.secondary, + fallbackOnAccent = CodeTheme.colors.onAction, + ) + ProfileActionButton( + text = stringResource(R.string.action_completeYourProfile), + onClick = onEdit, + contentColor = onAccent, + containerColor = accent, ) } - }, - actions = { - ProfileActionButton( - text = stringResource(R.string.action_editProfile), - onClick = onEdit, - ) // Only a named profile has something to share. if (onShare != null) { ProfileActionButton( 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 598f615668..45e5464564 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 @@ -9,14 +9,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.ui.forViewer -import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.funding.PurchaseMethodController -import com.flipcash.app.tokens.core.TotalBalanceProvider -import com.flipcash.app.userflags.UserFlagsCoordinator -import com.flipcash.app.userflags.UsernameGate -import com.flipcash.app.userflags.usernameGate -import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatMetadata @@ -26,15 +19,9 @@ import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.FeaturedGroupsStore import com.flipcash.shared.common.ui.profile.joinedLabel -import com.getcode.manager.BottomBarAction -import com.getcode.manager.BottomBarManager -import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID -import com.getcode.opencode.model.financial.Fiat -import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -47,28 +34,16 @@ import kotlinx.coroutines.flow.onEach import java.util.Locale import javax.inject.Inject -/** - * The minimum-balance gate as the copy quotes it — `$100 USD` rather than `$100.00`. Shared by the - * card and the sheet so a round threshold never renders two different ways. - */ -private fun Fiat.formattedGate(exchange: Exchange): String = - forViewer(exchange.preferredRate, exchange.rates()) - .formatted(rule = Fiat.FormattingRule.Truncated) - @HiltViewModel internal class MenuScreenViewModel @Inject constructor( userManager: UserManager, - userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, - totalBalance: TotalBalanceProvider, analytics: FlipcashAnalytics, private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, private val featuredGroupsStore: FeaturedGroupsStore, tipPayments: TipPaymentDelegate, - private val resources: ResourceHelper, - private val exchange: Exchange, ) : BaseViewModel( initialState = State(), @@ -78,17 +53,14 @@ internal class MenuScreenViewModel @Inject constructor( data class State( // What the header draws: nothing yet, the claim prompt, or the viewer's own profile. val profileState: ProfileState = ProfileState.Unknown, - // The nudge toward claiming a `@handle`, or null when there is nothing to nudge about — a - // handle already exists, or the account state hasn't resolved yet. - val usernameProgress: UsernameProgress? = null, - // 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 = "", // What another user must send to open a chat with the viewer, formatted. Null until it // resolves, which the stats card draws as a dash. val minimumToChat: String? = null, // The viewer's own featured groups, in profile order. Empty until the first read lands. val featuredGroups: List = emptyList(), + // The username the featured-groups read last succeeded for. Compared with the live username + // by [showFavoritesEmptyState], so a changed handle is unread until its own read lands. + val favoritesReadFor: String? = null, ) /** @@ -128,15 +100,10 @@ internal class MenuScreenViewModel @Inject constructor( data class OpenScreen(val screen: AppRoute) : Event data class OnProfileStateChanged(val profileState: ProfileState) : Event - data class OnUsernameProgressChanged( - val progress: UsernameProgress?, - val minimumBalance: String, - ) : Event data class OnMinimumToChatChanged(val minimumToChat: String?) : Event data class OnFeaturedGroupsChanged(val groups: List) : Event + data class OnFavoritesRead(val username: String, val groups: List) : Event - /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ - data object ClaimUsername : Event /** The claim prompt's CTA — collect a display name so the account gets a real card. */ data object ClaimTipCard : Event @@ -210,78 +177,8 @@ internal class MenuScreenViewModel @Inject constructor( .filterNotNull() .filter { it.isNotEmpty() } .distinctUntilChanged() - .onEach { featuredGroupsStore.load(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. - combine( - userManager.state - .filter { it.authState is AuthState.Ready } - .map { it.userProfile?.username to (it.userProfile?.isUsernameAutoAssigned == true) }, - userFlags.resolvedFlags.map { it.usernameMinBalance.effectiveValue }, - totalBalance.observeTotalBalance(), - ) { (username, isAutoAssigned), minimum, balance -> - val progress = when (val gate = usernameGate(username, isAutoAssigned, minimum, balance)) { - UsernameGate.Claimed -> null - UsernameGate.Unlocked -> UsernameProgress.Unlocked - is UsernameGate.Locked -> UsernameProgress.Locked( - fraction = gate.fraction, - remaining = gate.shortfall.formattedGate(exchange), - ) - } - Event.OnUsernameProgressChanged(progress, minimum.formattedGate(exchange)) - } - .distinctUntilChanged() - .onEach { dispatchEvent(it) } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - when (stateFlow.value.usernameProgress) { - UsernameProgress.Unlocked -> dispatchEvent( - Event.OpenScreen( - AppRoute.UpdateUserProfile( - origin = AppRoute.Tabs.Menu, - steps = listOf(UpdateProfileStep.Username), - ) - ) - ) - - // Below the minimum the tap states the rule instead of walking into a rejection - // on submit. Same strings as the server's refusal, and the same informational - // style the entry screen gives it, so the two can't disagree. - is UsernameProgress.Locked -> BottomBarManager.showInfo( - title = resources.getString( - R.string.error_title_usernameMinimumBalance, - stateFlow.value.usernameMinimumBalance, - ), - message = resources.getString( - R.string.error_description_usernameMinimumBalance, - stateFlow.value.usernameMinimumBalance, - ), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_addMoney), - onClick = { - dispatchEvent( - Event.PresentDepositOptions( - AddMoneySource.USERNAME_SHORTFALL - ) - ) - }, - ), - BottomBarAction( - text = resources.getString(R.string.action_dismiss), - style = BottomBarManager.BottomBarButtonStyle.Text, - ), - ), - ) - - null -> Unit - } + .onEach { username -> + if (featuredGroupsStore.load(username)) dispatchEvent(Event.OnFavoritesRead(username, featuredGroupsStore.groups.value)) } .launchIn(viewModelScope) @@ -314,13 +211,6 @@ internal class MenuScreenViewModel @Inject constructor( state.copy(profileState = event.profileState) } - is Event.OnUsernameProgressChanged -> { state -> - state.copy( - usernameProgress = event.progress, - usernameMinimumBalance = event.minimumBalance, - ) - } - is Event.OnMinimumToChatChanged -> { state -> state.copy(minimumToChat = event.minimumToChat) } @@ -329,9 +219,12 @@ internal class MenuScreenViewModel @Inject constructor( state.copy(featuredGroups = event.groups) } + is Event.OnFavoritesRead -> { state -> + state.copy(favoritesReadFor = event.username, featuredGroups = event.groups) + } + is Event.PresentDepositOptions, Event.ClaimTipCard, - Event.ClaimUsername, Event.ShareProfile, is Event.OpenScreen -> { state -> state } } @@ -357,3 +250,22 @@ internal fun shareProfileRoute( directChatId = null, ) } + +/** + * Whether the own profile's featured-groups section may draw its "None yet" empty state. With no + * username there is nothing to read, so it may at once; otherwise only once a read for that + * username has succeeded, so an account that has favorites never flashes the empty state while the + * read is in flight or after it fails. + */ +internal fun showFavoritesEmptyState(username: String?, favoritesReadFor: String?): Boolean = + username.isNullOrEmpty() || favoritesReadFor == username + +/** + * Whether the profile is complete: a profile photo, a cover photo and a non-blank bio. Username, + * minimum-to-chat and favorite chats do not count. + */ +internal fun isProfileComplete(profile: UserProfile?): Boolean = + profile != null && + profile.profilePicture != null && + profile.coverPicture != null && + profile.bio.isNotBlank() diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt deleted file mode 100644 index 3fb355c78c..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.flipcash.app.menu.internal.components - -/** - * How close the account is to being allowed a `@handle`. Null in the state once a handle the user - * picked exists; otherwise it decides whether the claim link opens the entry screen or states the - * minimum balance. - */ -internal sealed interface UsernameProgress { - /** - * @param fraction how much of the minimum the balance covers, `0f..1f`. - * @param remaining the shortfall, already formatted for display (e.g. `$12.50 USD`). - */ - data class Locked(val fraction: Float, val remaining: String) : UsernameProgress - - data object Unlocked : UsernameProgress -} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt index 4a911b6e7d..f39da9b389 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt @@ -2,9 +2,12 @@ package com.flipcash.app.menu.internal import com.flipcash.app.core.AppRoute import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MediaItem import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class OwnProfileStateTest { @@ -46,4 +49,47 @@ class OwnProfileStateTest { assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unknown, userId)) assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unclaimed(placeholder = null), userId)) } + + private val photo = MediaItem(renditions = emptyList()) + + private fun profile( + profilePicture: MediaItem? = null, + coverPicture: MediaItem? = null, + bio: String = "", + ) = UserProfile.Empty.copy(profilePicture = profilePicture, coverPicture = coverPicture, bio = bio) + + @Test + fun `a profile missing any of photo, cover or bio is incomplete`() { + assertFalse(isProfileComplete(profile())) + assertFalse(isProfileComplete(profile(photo, photo, bio = ""))) + assertFalse(isProfileComplete(profile(photo, photo, bio = " "))) + assertFalse(isProfileComplete(profile(photo, null, bio = "hi"))) + assertFalse(isProfileComplete(profile(null, photo, bio = "hi"))) + assertFalse(isProfileComplete(null)) + } + + @Test + fun `photo, cover and bio complete the profile without a username`() { + assertTrue(isProfileComplete(profile(photo, photo, bio = "hi").copy(username = ""))) + } + + @Test + fun `the empty state waits for a successful read when a username is set`() { + assertFalse(showFavoritesEmptyState("ada", favoritesReadFor = null)) + assertFalse(showFavoritesEmptyState("ada", favoritesReadFor = "old_handle")) + assertTrue(showFavoritesEmptyState("ada", favoritesReadFor = "ada")) + } + + @Test + fun `the empty state draws at once when there is no username to read`() { + assertTrue(showFavoritesEmptyState(null, favoritesReadFor = null)) + assertTrue(showFavoritesEmptyState("", favoritesReadFor = null)) + } + + @Test + fun `a successful favorites read lands in state`() { + val state = reduce(MenuScreenViewModel.Event.OnFavoritesRead("ada", emptyList()))(MenuScreenViewModel.State()) + assertEquals("ada", state.favoritesReadFor) + assertEquals(emptyList(), state.featuredGroups) + } } diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt index 2de1207e86..0388d20b5a 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt @@ -40,9 +40,9 @@ data class FeaturedGroupItem( ) /** - * The groups a person has chosen to feature, under the stats on their profile. Draws nothing for - * an empty list, heading and divider included, so a profile without featured groups looks the - * same as before the section existed. + * The groups a person has chosen to feature, under the stats on their profile. An empty list + * draws nothing, heading and divider included, unless [showWhenEmpty] is set: the section then + * stays with a "None yet" body that takes no taps. * * Rows arrive as [FeaturedGroupItem]s and report the tap by id; what opening a group does is the * caller's. @@ -52,8 +52,9 @@ fun FeaturedGroupsSection( groups: List, onOpen: (ChatId) -> Unit, modifier: Modifier = Modifier, + showWhenEmpty: Boolean = false, ) { - if (groups.isEmpty()) return + if (groups.isEmpty() && !showWhenEmpty) return Column(modifier = modifier.fillMaxWidth()) { HorizontalDivider(color = CodeTheme.colors.divider) @@ -63,6 +64,13 @@ fun FeaturedGroupsSection( style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, ) + if (groups.isEmpty()) { + Text( + text = stringResource(R.string.text_featuredGroupsNone), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) { groups.forEach { group -> FeaturedGroupRow( diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt index 0b67237384..1433376b2c 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt @@ -45,11 +45,13 @@ fun ProfileActionButton( onClick: () -> Unit, modifier: Modifier = Modifier, contentColor: Color = CodeTheme.colors.textMain, + containerColor: Color = White10, ) { ActionCapsule( onClick = onClick, modifier = modifier.heightIn(min = ActionHeight), contentPadding = CodeTheme.dimens.staticGrid.x4, + containerColor = containerColor, ) { Text( text = text, @@ -88,6 +90,7 @@ private fun ActionCapsule( onClick: () -> Unit, modifier: Modifier, contentPadding: Dp, + containerColor: Color = White10, content: @Composable () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -96,7 +99,7 @@ private fun ActionCapsule( modifier = modifier .alpha(if (pressed) PressedAlpha else 1f) .clip(CircleShape) - .background(White10) + .background(containerColor) .clickable( interactionSource = interactionSource, indication = null, From 42c37d2747d32689aea2aa82e935453e53b78e9b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 13:58:30 -0400 Subject: [PATCH 4/5] feat(myaccount): plain Username row and balance gate on Edit Profile Frame 13 (node 12527:81886) replaces the "Claim your username" prompt with a Username row showing the current @handle, and rewords the photo actions to "Add cover photo" and "Add profile photo" on this screen only; group editing keeps its labels. Tapping Username while the handle is not one the user chose and the balance is under the minimum shows "$100 Balance Required" with Add Money and Dismiss. The amount is the existing usernameMinBalance user flag, formatted for the viewer, not a constant. Otherwise the tap opens the username step as before. isUsernameBalanceRequired holds the rule and is unit-tested. --- .../core/src/main/res/values/strings.xml | 8 ++++-- .../editprofile/EditProfileScreenContent.kt | 26 ++++++------------- .../editprofile/EditProfileViewModel.kt | 26 ++++++++++++------- .../editprofile/EditProfileViewModelTest.kt | 26 +++++++++++++++++-- .../common/ui/profile/ProfileEditFields.kt | 6 ++--- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b0e75af77a..e3be076c73 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1532,6 +1532,8 @@ Usernames must be a minimum of %1$d characters %1$s Minimum Balance Required In order to stop username squatting getting a username requires a total Flipcash balance of at least %1$s + %1$s Balance Required + Setting a custom username requires a balance of %1$s or more Something Went Wrong Please try again @@ -1572,6 +1574,7 @@ Set Profile Picture Cover Change cover + Add cover photo Couldn\'t Upload Your Cover This image isn\'t allowed as a cover. Please try a different image. If you think it was rejected in error please DM @flipcash on X Your Name @@ -1657,9 +1660,10 @@ Edit Profile Complete Your Profile Change photo + Add profile photo Name Add a name - Claim your username + Add a username Not set Share Profile Share Message @@ -1692,7 +1696,7 @@ Favorite Community Chats None yet - Add groups + Add chats 1 group %1$s groups diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt index a9999b7980..b8210e5f44 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileScreenContent.kt @@ -41,8 +41,8 @@ internal fun EditProfileScreenContent( avatar = state.avatar, displayName = state.displayName, access = BlobAccessContext.Owned, - changeCoverLabel = stringResource(R.string.action_changeCover), - changePhotoLabel = stringResource(R.string.action_changePhoto), + changeCoverLabel = stringResource(R.string.action_addCoverPhoto), + changePhotoLabel = stringResource(R.string.action_addProfilePhoto), onChangeCover = { openStep(UpdateProfileStep.Cover) }, onChangePhoto = { openStep(UpdateProfileStep.Photo) }, ) @@ -57,22 +57,12 @@ internal fun EditProfileScreenContent( placeholder = stringResource(R.string.placeholder_editProfileName), onClick = { openStep(EditProfileViewModel.nameStep) }, ) - if (state.usernameNeedsClaim) { - FieldCard( - title = stringResource(R.string.title_username), - value = stringResource(R.string.action_claimUsername), - placeholder = "", - valueIsPrompt = true, - onClick = { dispatch(EditProfileViewModel.Event.UsernameClicked) }, - ) - } else { - FieldCard( - title = stringResource(R.string.title_username), - value = "@${state.username.orEmpty()}", - placeholder = "", - onClick = { openStep(UpdateProfileStep.Username) }, - ) - } + FieldCard( + title = stringResource(R.string.title_username), + value = state.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, + placeholder = stringResource(R.string.placeholder_editProfileUsername), + onClick = { dispatch(EditProfileViewModel.Event.UsernameClicked) }, + ) FieldCard( title = stringResource(R.string.title_bio), value = state.bio.ifEmpty { null }, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt index acb91a9ca0..d026d7ce0f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt @@ -40,10 +40,10 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import javax.inject.Inject -/** The gate as the copy quotes it, `$100 USD` rather than `$100.00`. */ +/** The gate as the copy quotes it, `$100` rather than `$100.00`. */ // Priced in USD, shown in the viewer's currency. private fun Fiat.formattedGate(exchange: Exchange): String = - forViewer(exchange.preferredRate, exchange.rates()).formatted() + forViewer(exchange.preferredRate, exchange.rates()).formatted(rule = Fiat.FormattingRule.Truncated) @HiltViewModel internal class EditProfileViewModel @Inject constructor( @@ -66,7 +66,7 @@ internal class EditProfileViewModel @Inject constructor( val userId: ID? = null, val displayName: String = "", val username: String? = null, - /** No handle yet, or one the server assigned: the row offers the claim instead of the handle. */ + /** No handle yet, or one the server assigned rather than chosen: changing it is balance-gated. */ val usernameNeedsClaim: Boolean = true, val bio: String = "", val avatar: MediaItem? = null, @@ -138,25 +138,24 @@ internal class EditProfileViewModel @Inject constructor( }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) - // A held handle goes straight to its editor. Claiming goes through the same balance gate - // the You tab's progress card uses: the claim step once the balance clears the minimum, the - // rule stated otherwise. + // Setting a custom handle is gated on the `usernameMinBalance` flag: the username flow once + // the balance clears it, the rule stated otherwise. A handle the user already chose goes + // straight to its editor. eventFlow .filterIsInstance() .onEach { val state = stateFlow.value - val gate = state.usernameGate - if (!state.usernameNeedsClaim || gate !is UsernameGate.Locked) { + if (!isUsernameBalanceRequired(state.usernameNeedsClaim, state.usernameGate)) { dispatchEvent(Event.OpenStep(UpdateProfileStep.Username)) return@onEach } BottomBarManager.showInfo( title = resources.getString( - R.string.error_title_usernameMinimumBalance, + R.string.error_title_usernameBalanceRequired, state.usernameMinimumBalance, ), message = resources.getString( - R.string.error_description_usernameMinimumBalance, + R.string.error_description_usernameBalanceRequired, state.usernameMinimumBalance, ), actions = listOf( @@ -177,6 +176,13 @@ internal class EditProfileViewModel @Inject constructor( } internal companion object { + /** + * Whether tapping Username must stop at the balance-required modal: the handle is not one + * the user chose, and the balance is short of the minimum. + */ + fun isUsernameBalanceRequired(usernameNeedsClaim: Boolean, gate: UsernameGate): Boolean = + usernameNeedsClaim && gate is UsernameGate.Locked + val nameStep = UpdateProfileStep.Name(DisplayNameSource.MyAccount) val updateStateForEvent: (Event) -> ((State) -> State) = { event -> diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt index 6015af43da..c8c77541e2 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt @@ -99,7 +99,7 @@ class EditProfileViewModelTest { val events = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.eventFlow.collect { events += it } } vm.dispatchEvent(EditProfileViewModel.Event.OnProfileChanged(profile(username = username))) - vm.dispatchEvent(EditProfileViewModel.Event.OnUsernameGateChanged(gate, "$100 USD")) + vm.dispatchEvent(EditProfileViewModel.Event.OnUsernameGateChanged(gate, "$100")) advanceUntilIdle() events.clear() vm.dispatchEvent(EditProfileViewModel.Event.UsernameClicked) @@ -116,7 +116,7 @@ class EditProfileViewModelTest { assertTrue(events.none { it is EditProfileViewModel.Event.OpenStep }) assertEquals( - "error_title_usernameMinimumBalance", + "error_title_usernameBalanceRequired", BottomBarManager.messages.value.firstOrNull()?.title, ) } @@ -156,6 +156,28 @@ class EditProfileViewModelTest { assertTrue(EditProfileViewModel.Event.OpenScreen(route) in events) } + @Test + fun `the balance gate stops only an unchosen handle that is short of the minimum`() { + val gate = EditProfileViewModel.isUsernameBalanceRequired(usernameNeedsClaim = true, gate = locked()) + assertTrue(gate) + // Enough balance, or no minimum configured: straight to the username flow. + assertFalse(EditProfileViewModel.isUsernameBalanceRequired(true, UsernameGate.Unlocked)) + // A handle the user already chose is not re-gated. + assertFalse(EditProfileViewModel.isUsernameBalanceRequired(false, locked())) + assertFalse(EditProfileViewModel.isUsernameBalanceRequired(false, UsernameGate.Claimed)) + } + + @Test + fun `an auto-assigned handle is shown and still gated`() = runTest(mainCoroutineRule.dispatcher) { + val vm = viewModel() + vm.dispatchEvent( + EditProfileViewModel.Event.OnProfileChanged(profile(username = "mike_2", autoAssigned = true)), + ) + advanceUntilIdle() + assertEquals("mike_2", vm.stateFlow.value.username) + assertTrue(vm.stateFlow.value.usernameNeedsClaim) + } + private val reduce = EditProfileViewModel.updateStateForEvent private fun profile( diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt index 05ce4ae4cf..af1a523f23 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileEditFields.kt @@ -156,8 +156,7 @@ fun CameraBadge(modifier: Modifier = Modifier) { /** * A tappable card: the field's title over its current value, with the disclosure chevron. * - * A null [value] shows [placeholder] in the secondary colour; [valueIsPrompt] does the same for a - * value that is itself a call to action. [maxLines] is 1 for a name and 3 for a description. + * A null [value] shows [placeholder] in the secondary colour. [maxLines] is 1 for a name and 3 for a description. */ @Composable fun FieldCard( @@ -166,7 +165,6 @@ fun FieldCard( placeholder: String, onClick: () -> Unit, modifier: Modifier = Modifier, - valueIsPrompt: Boolean = false, maxLines: Int = 1, ) { val grid = CodeTheme.dimens.staticGrid @@ -189,7 +187,7 @@ fun FieldCard( Text( text = value ?: placeholder, style = CodeTheme.typography.textMedium, - color = if (value == null || valueIsPrompt) { + color = if (value == null) { CodeTheme.colors.textSecondary } else { CodeTheme.colors.textMain From 77617d0e9e198e4274ecc7f2eb0d3a3ed708b16e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 14:49:50 -0400 Subject: [PATCH 5/5] fix(menu): drop the divider above Favorite Community Chats on the You tab Frame 12 has no rule above the section, and iOS already removed it. The divider stays on other people's profiles, where FeaturedGroupsSection is also used. --- .../com/flipcash/app/menu/internal/MenuScreenContent.kt | 1 + .../shared/common/ui/profile/FeaturedGroupsSection.kt | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) 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 fae9a5eb4c..2d43b191ab 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 @@ -168,6 +168,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { profileState.profile.username, state.favoritesReadFor, ), + showDivider = false, onOpen = { chatId -> viewModel.dispatchEvent( Event.OpenScreen( diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt index 0388d20b5a..eccf7abbfc 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/FeaturedGroupsSection.kt @@ -42,7 +42,8 @@ data class FeaturedGroupItem( /** * The groups a person has chosen to feature, under the stats on their profile. An empty list * draws nothing, heading and divider included, unless [showWhenEmpty] is set: the section then - * stays with a "None yet" body that takes no taps. + * stays with a "None yet" body that takes no taps. [showDivider] draws the rule above the heading; + * the owner's own profile leaves it off. * * Rows arrive as [FeaturedGroupItem]s and report the tap by id; what opening a group does is the * caller's. @@ -53,11 +54,14 @@ fun FeaturedGroupsSection( onOpen: (ChatId) -> Unit, modifier: Modifier = Modifier, showWhenEmpty: Boolean = false, + showDivider: Boolean = true, ) { if (groups.isEmpty() && !showWhenEmpty) return Column(modifier = modifier.fillMaxWidth()) { - HorizontalDivider(color = CodeTheme.colors.divider) + if (showDivider) { + HorizontalDivider(color = CodeTheme.colors.divider) + } Text( modifier = Modifier.padding(vertical = CodeTheme.dimens.staticGrid.x5), text = stringResource(R.string.title_featuredGroups),