Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,9 @@
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
<string name="action_startReceivingTips">Start Receiving Tips</string>
<!-- Stand-in name on the blurred tip card behind the claim prompt. Never legible: it only
gives the name line the width a real one would have. -->
<string name="label_tipCardNamePlaceholder">Your Name</string>

<string name="title_profileNameSelection">What\'s your name?</string>
<string name="subtitle_profileNameSelection">This is how you\'ll appear to others</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import com.flipcash.shared.transactionhistory.FeedSyncState
import com.flipcash.shared.transactionhistory.TransactionListItem
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
Expand All @@ -35,6 +36,7 @@ internal class WalletViewModel @Inject constructor(
analytics: FlipcashAnalyticsService,
chatCoordinator: ChatCoordinator,
feedCoordinator: ActivityFeedCoordinator,
tokenCoordinator: TokenCoordinator,
) : BaseViewModel<WalletViewModel.State, WalletViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand All @@ -55,6 +57,8 @@ internal class WalletViewModel @Inject constructor(
*/
val transactions: List<TransactionListItem> = emptyList(),
val feedSyncState: FeedSyncState = FeedSyncState.Unknown,
/** Whether the account currently holds a balance in any token (see [isAwaitingActivity]). */
val holdsBalance: Boolean = false,
) {
val hasReceivedMoney: Boolean
get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true
Expand All @@ -71,15 +75,19 @@ internal class WalletViewModel @Inject constructor(
* 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.
* mistake for a new account — and neither is a held balance, which is a live read of the
* account rather than of the cache.
*/
val isAwaitingActivity: Boolean
get() = onboardingItems == null ||
(feedSyncState == FeedSyncState.Unknown && transactions.isEmpty())
(feedSyncState == FeedSyncState.Unknown && transactions.isEmpty() && !holdsBalance)
}

sealed interface Event {
data class OnOnboardingItemsUpdated(val items: List<TutorialItem>): Event
data class OnOnboardingItemsUpdated(
val items: List<TutorialItem>,
val holdsBalance: Boolean,
): Event
data class OnTransactionsUpdated(val transactions: List<TransactionListItem>) : Event
data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event
data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event
Expand Down Expand Up @@ -117,19 +125,27 @@ internal class WalletViewModel @Inject constructor(
.onEach { route -> dispatchEvent(Event.OpenScreen(route)) }
.launchIn(viewModelScope)

// Onboarding funnel milestones, derived from durable event history (not current balance):
// "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.
// 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.
//
// 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
// row to prove it, and would otherwise be told to add money it already has.
combine(
feedCoordinator.hasEverReceivedMoney(),
chatCoordinator.hasEverTipped(),
) { hasReceivedMoney, hasTipped ->
listOf(
TutorialItem.AddMoney(isCompleted = hasReceivedMoney),
TutorialItem.ScanTipCard(isCompleted = hasTipped),
tokenCoordinator.hasAnyBalance,
) { hasReceivedMoney, hasTipped, holdsBalance ->
Event.OnOnboardingItemsUpdated(
items = listOf(
TutorialItem.AddMoney(isCompleted = hasReceivedMoney || holdsBalance),
TutorialItem.ScanTipCard(isCompleted = hasTipped),
),
holdsBalance = holdsBalance,
)
}
.onEach { items -> dispatchEvent(Event.OnOnboardingItemsUpdated(items)) }
.onEach { dispatchEvent(it) }
.launchIn(viewModelScope)
}

Expand All @@ -147,7 +163,7 @@ internal class WalletViewModel @Inject constructor(
state.copy(feedSyncState = event.syncState)
}
is Event.OnOnboardingItemsUpdated -> { state ->
state.copy(onboardingItems = event.items)
state.copy(onboardingItems = event.items, holdsBalance = event.holdsBalance)
}
is Event.OnTransactionsUpdated -> { state ->
state.copy(transactions = event.transactions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import com.flipcash.app.core.MainCoroutineRule
import com.flipcash.app.core.dispatchers.TestDispatchers
import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
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
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -42,6 +45,9 @@ class BalanceViewModelTest {
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)
}

private lateinit var dispatchers: TestDispatchers

Expand All @@ -53,6 +59,7 @@ class BalanceViewModelTest {
analytics = StubFlipcashAnalytics(),
chatCoordinator = chatCoordinator,
feedCoordinator = feedCoordinator,
tokenCoordinator = tokenCoordinator,
)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ class WalletLoadingStateTest {
assertFalse(state.isAwaitingActivity)
}

@Test
fun `a held balance short-circuits the wait on an unsynced feed`() {
val state = WalletViewModel.State(
onboardingItems = milestones(addedMoney = true, tipped = false),
transactions = emptyList(),
feedSyncState = FeedSyncState.Unknown,
holdsBalance = true,
)
assertFalse(state.isAwaitingActivity)
}

@Test
fun `a held balance does not pre-empt the milestones themselves`() {
val state = WalletViewModel.State(
onboardingItems = null,
feedSyncState = FeedSyncState.Synced,
holdsBalance = true,
)
assertTrue(state.isAwaitingActivity)
}

@Test
fun `tutorial is withheld while the milestones are unknown`() {
assertTrue(WalletViewModel.State().isNewUserTutorialComplete)
Expand Down Expand Up @@ -101,4 +122,20 @@ class WalletLoadingStateTest {
fun `hasReceivedMoney is false while unknown, gating the action tiles`() {
assertFalse(WalletViewModel.State().hasReceivedMoney)
}

/**
* 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.
*/
@Test
fun `a held balance completes the add-money milestone without a feed row`() {
val state = WalletViewModel.State(
onboardingItems = milestones(addedMoney = true, tipped = true),
feedSyncState = FeedSyncState.Synced,
holdsBalance = true,
)
assertTrue(state.hasReceivedMoney)
assertTrue(state.isNewUserTutorialComplete)
}
}
4 changes: 4 additions & 0 deletions apps/flipcash/features/menu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ android {
}

dependencies {
testImplementation(kotlin("test"))

implementation(libs.bundles.haze)

implementation(project(":apps:flipcash:shared:appupdates"))
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:authentication"))
Expand Down
Loading
Loading