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-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/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/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 - - @@ -1536,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 @@ -1576,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 @@ -1656,15 +1655,15 @@ Something Went Wrong We were unable to send your report. Please try again - Send Your First Tip Edit Profile - Claim your username › + Complete Your Profile Change photo + Add profile photo Name Add a name - Claim your username + Add a username Not set Share Profile Share Message @@ -1695,8 +1694,9 @@ Bio Add a bio - Favorite Public Groups - Add groups + Favorite Community Chats + None yet + Add chats 1 group %1$s groups 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/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/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/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..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 @@ -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,11 @@ 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, + ), + showDivider = false, onOpen = { chatId -> viewModel.dispatchEvent( Event.OpenScreen( @@ -197,7 +196,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 +217,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/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/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/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..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 @@ -40,9 +40,10 @@ 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. [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. @@ -52,17 +53,28 @@ fun FeaturedGroupsSection( groups: List, onOpen: (ChatId) -> Unit, modifier: Modifier = Modifier, + showWhenEmpty: Boolean = false, + showDivider: Boolean = true, ) { - if (groups.isEmpty()) return + 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), 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, 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 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) + } +} + 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 }