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
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ internal fun WalletScreenContent(
)
}

if (balanceState.hasAddedMoney) {
if (balanceState.hasReceivedMoney) {
item {
Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ internal class WalletViewModel @Inject constructor(
val transactions: List<TransactionListItem> = emptyList(),
val feedSyncState: FeedSyncState = FeedSyncState.Unknown,
) {
val hasAddedMoney: Boolean
val hasReceivedMoney: Boolean
get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true

/** Treated as complete while unknown, so the tutorial is never the thing we guess at. */
Expand Down Expand Up @@ -118,14 +118,14 @@ internal class WalletViewModel @Inject constructor(
.launchIn(viewModelScope)

// Onboarding funnel milestones, derived from durable event history (not current balance):
// "added money" = a completed deposit/buy in the activity feed; "scanned a tip card" =
// a Cash chat message with verb TIPPED.
// "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.
combine(
feedCoordinator.hasEverAddedMoney(),
feedCoordinator.hasEverReceivedMoney(),
chatCoordinator.hasEverTipped(),
) { hasAddedMoney, hasTipped ->
) { hasReceivedMoney, hasTipped ->
listOf(
TutorialItem.AddMoney(isCompleted = hasAddedMoney),
TutorialItem.AddMoney(isCompleted = hasReceivedMoney),
TutorialItem.ScanTipCard(isCompleted = hasTipped),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class WalletLoadingStateTest {
}

@Test
fun `hasAddedMoney is false while unknown, gating the action tiles`() {
assertFalse(WalletViewModel.State().hasAddedMoney)
fun `hasReceivedMoney is false while unknown, gating the action tiles`() {
assertFalse(WalletViewModel.State().hasReceivedMoney)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,21 @@ interface MessageDao {
@Query("SELECT * FROM messages")
suspend fun getAllMessages(): List<MessageEntity>

/** True once any completed deposit/buy notification exists — the "added money" milestone. */
/**
* True once any completed *incoming* notification exists — the "added money" milestone.
*
* Money arriving is money arriving, regardless of how: an on-ramp buy, a deposit, or a tip
* received from someone else. All three are the credit side of the feed (see
* `MessageMetadata.isOutgoing`), so all three satisfy the milestone. Swaps are excluded — they
* debit the source mint rather than bringing new money in.
*/
@Query(
"SELECT EXISTS(SELECT 1 FROM messages WHERE state = 'COMPLETED' AND (" +
"metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.DepositedCrypto%' OR " +
"metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.BoughtToken%'))"
"metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.BoughtToken%' OR " +
"metadata LIKE '%com.flipcash.app.core.feed.MessageMetadata.ReceivedCrypto%'))"
)
fun hasEverAddedMoney(): Flow<Boolean>
fun hasEverReceivedMoney(): Flow<Boolean>

@Query("DELETE FROM messages")
suspend fun deleteAllMessages()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.flipcash.app.persistence.dao

import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import app.cash.turbine.test
import com.flipcash.app.persistence.FlipcashDatabase
import com.flipcash.app.persistence.entities.MessageEntity
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/**
* Covers the "added money" onboarding milestone, which is really "has any money ever come in" —
* a buy, a deposit, or a tip received all satisfy it; outgoing entries do not.
*/
@RunWith(RobolectricTestRunner::class)
class MessageDaoTest {

private lateinit var db: FlipcashDatabase
private lateinit var dao: MessageDao

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = db.messageDao()
}

@After
fun tearDown() {
db.close()
}

// -- helpers --

private var nextId = 0

private fun metadataJson(type: String) =
"""{"type":"com.flipcash.app.core.feed.MessageMetadata.$type"}"""

private fun message(
metadataType: String,
state: String = "COMPLETED",
) = MessageEntity(
// Base58 alphabet only — the entity decodes this lazily, but keep it valid anyway.
idBase58 = "message${++nextId}",
text = "test",
amountUsdc = 100L,
amountNative = null,
nativeCurrency = null,
rate = null,
state = state,
timestamp = nextId.toLong(),
metadata = metadataJson(metadataType),
mintBase58 = null,
)

private suspend fun hasReceivedMoney(): Boolean {
var result = false
dao.hasEverReceivedMoney().test {
result = awaitItem()
cancelAndIgnoreRemainingEvents()
}
return result
}

// -- tests --

@Test
fun `no messages means no money has come in`() = runTest {
assertFalse(hasReceivedMoney())
}

@Test
fun `a completed deposit satisfies the milestone`() = runTest {
dao.upsert(message("DepositedCrypto"))
assertTrue(hasReceivedMoney())
}

@Test
fun `a completed buy satisfies the milestone`() = runTest {
dao.upsert(message("BoughtToken"))
assertTrue(hasReceivedMoney())
}

@Test
fun `a received tip satisfies the milestone`() = runTest {
dao.upsert(message("ReceivedCrypto"))
assertTrue(hasReceivedMoney())
}

@Test
fun `outgoing activity alone does not satisfy the milestone`() = runTest {
dao.upsert(
message("DirectlySentCrypto"),
message("IndirectlySentCrypto"),
message("WithdrewCrypto"),
message("SoldToken"),
message("SwappedCrypto"),
message("PaidCrypto"),
)
assertFalse(hasReceivedMoney())
}

@Test
fun `an incoming entry that has not completed does not satisfy the milestone`() = runTest {
dao.upsert(message("ReceivedCrypto", state = "PENDING"))
assertFalse(hasReceivedMoney())
}

@Test
fun `the milestone flips live as an incoming entry lands`() = runTest {
dao.hasEverReceivedMoney().test {
assertFalse(awaitItem())
dao.upsert(message("ReceivedCrypto"))
assertTrue(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ class MessageDataSource @Inject constructor(
}

/**
* Reactive "has the user ever added money" — any completed deposit/buy notification.
* Reactive "has money ever come in" — any completed incoming notification (buy, deposit, or a
* tip received).
*
* 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
Expand All @@ -71,9 +72,9 @@ class MessageDataSource @Inject constructor(
* activity later landed in the feed.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun hasEverAddedMoney(): Flow<Boolean> =
fun hasEverReceivedMoney(): Flow<Boolean> =
FlipcashDatabase.observeInstance().flatMapLatest { database ->
database?.messageDao()?.hasEverAddedMoney() ?: flowOf(false)
database?.messageDao()?.hasEverReceivedMoney() ?: flowOf(false)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,11 @@ class ActivityFeedCoordinator @Inject internal constructor(
profiles to tokens
}

/** Reactive "has the user ever added money" — any completed deposit/buy in the feed. */
fun hasEverAddedMoney(): Flow<Boolean> = dataSource.hasEverAddedMoney()
/**
* Reactive "has money ever come in" — any completed incoming entry in the feed: an on-ramp buy,
* a deposit, or a tip received.
*/
fun hasEverReceivedMoney(): Flow<Boolean> = dataSource.hasEverReceivedMoney()

suspend fun checkPendingMessagesForUpdates(): Result<Int> {
val pendingMessages = dataSource.query(whereClause = "state = '${NotificationState.PENDING.name}'")
Expand Down
Loading