diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index ece264431..fb0429def 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -915,6 +915,9 @@
Receive Tips From Everyone
Add your name to receive tips
Start Receiving Tips
+
+ Your Name
What\'s your name?
This is how you\'ll appear to others
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 07bc98a76..d471933e7 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
@@ -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
@@ -35,6 +36,7 @@ internal class WalletViewModel @Inject constructor(
analytics: FlipcashAnalyticsService,
chatCoordinator: ChatCoordinator,
feedCoordinator: ActivityFeedCoordinator,
+ tokenCoordinator: TokenCoordinator,
) : BaseViewModel(
initialState = State(),
updateStateForEvent = updateStateForEvent,
@@ -55,6 +57,8 @@ internal class WalletViewModel @Inject constructor(
*/
val transactions: List = 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
@@ -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): Event
+ data class OnOnboardingItemsUpdated(
+ val items: List,
+ val holdsBalance: Boolean,
+ ): Event
data class OnTransactionsUpdated(val transactions: List) : Event
data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event
data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event
@@ -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)
}
@@ -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)
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 385578103..afe22bc1d 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
@@ -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
@@ -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
@@ -53,6 +59,7 @@ class BalanceViewModelTest {
analytics = StubFlipcashAnalytics(),
chatCoordinator = chatCoordinator,
feedCoordinator = feedCoordinator,
+ tokenCoordinator = tokenCoordinator,
)
@Test
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 87950c695..d8afd7c23 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
@@ -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)
@@ -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)
+ }
}
diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts
index c3c31b498..b9e55abbd 100644
--- a/apps/flipcash/features/menu/build.gradle.kts
+++ b/apps/flipcash/features/menu/build.gradle.kts
@@ -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"))
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 5ade04023..edf91312a 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
@@ -12,6 +12,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -32,7 +33,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -50,6 +53,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
@@ -57,6 +61,8 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -74,18 +80,30 @@ import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.LocalFeatureFlags
import com.flipcash.app.menu.MenuList
import com.flipcash.app.menu.internal.MenuScreenViewModel.Event
+import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState
+import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.app.updates.LocalAppUpdater
+import com.flipcash.services.models.UserProfile
+import com.flipcash.core.R as CoreR
import com.flipcash.features.menu.R
import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.theme.CodeTheme
import com.getcode.theme.White
import com.getcode.theme.White05
+import com.getcode.theme.White08
import com.getcode.theme.White50
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.core.noRippleClickable
import com.getcode.ui.theme.CodeScaffold
+import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.blur.HazeBlurDefaults
+import dev.chrisbanes.haze.blur.HazeBlurStyle
+import dev.chrisbanes.haze.blur.HazeColorEffect
+import dev.chrisbanes.haze.blur.hazeBlur
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
@@ -108,6 +126,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
// the display and everything else — rows, footer, tab bar — animates out from under it
// (node 9277:121410). Pushing a route would cross-fade a second copy of the card in instead.
var cardExpanded by remember { mutableStateOf(false) }
+ // Only a claimed card expands — the unclaimed stand-in is decoration behind the prompt.
val canExpand = isNewUi && state.tipCard != null
LaunchedEffect(canExpand) {
@@ -217,8 +236,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
header = {
if (isNewUi) {
YouHeader(
- card = state.tipCard,
- link = state.tipLink,
+ tipCardState = state.tipCardState,
enabled = !cardExpanded,
expansion = expansion,
slideAway = slideAway,
@@ -229,6 +247,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) },
onShare = { viewModel.dispatchEvent(Event.ShareTipCard) },
onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) },
+ onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) },
)
} else {
MoneyTiles(viewModel, navigator)
@@ -325,6 +344,53 @@ private fun expansionSpring(visibilityThreshold: T? = null) = spring(
* The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance,
* the copyable tip link, and the Share / Download tiles.
*
+ * An account with no display name has no card yet, and gets [UnclaimedTipCardPrompt] in its place
+ * rather than an empty page. Nothing is drawn while the state is still [TipCardState.Unknown] — a
+ * named account resolves in a frame or two, and a prompt that flashed at it would be a lie.
+ */
+@Composable
+private fun YouHeader(
+ tipCardState: TipCardState,
+ enabled: Boolean,
+ expansion: Float,
+ slideAway: Modifier,
+ cardWidth: Dp,
+ cardShift: Float,
+ onCardSlotPositioned: (Float) -> Unit,
+ onToggleFullScreen: () -> Unit,
+ onCopyLink: () -> Unit,
+ onShare: () -> Unit,
+ onDownload: () -> Unit,
+ onClaim: () -> Unit,
+) {
+ when (tipCardState) {
+ TipCardState.Unknown -> Unit
+ is TipCardState.Unclaimed -> UnclaimedTipCardPrompt(
+ placeholder = tipCardState.placeholder,
+ cardWidth = cardWidth,
+ enabled = enabled,
+ onClaim = onClaim,
+ )
+ is TipCardState.Claimed -> ClaimedTipCard(
+ card = tipCardState.card,
+ link = tipCardState.link,
+ enabled = enabled,
+ expansion = expansion,
+ slideAway = slideAway,
+ cardWidth = cardWidth,
+ cardShift = cardShift,
+ onCardSlotPositioned = onCardSlotPositioned,
+ onToggleFullScreen = onToggleFullScreen,
+ onCopyLink = onCopyLink,
+ onShare = onShare,
+ onDownload = onDownload,
+ )
+ }
+}
+
+/**
+ * The claimed card and everything that hangs off it.
+ *
* The caller drives the full-screen state: it sizes the card ([cardWidth]) and draws it out of its
* slot towards the middle of the display ([cardShift], off the slot position reported by
* [onCardSlotPositioned]). It also hands down [slideAway] — the fade-and-slide every non-card
@@ -332,8 +398,8 @@ private fun expansionSpring(visibilityThreshold: T? = null) = spring(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
-private fun YouHeader(
- card: Scannable.TipCard?,
+private fun ClaimedTipCard(
+ card: Scannable.TipCard,
link: String?,
enabled: Boolean,
expansion: Float,
@@ -346,8 +412,6 @@ private fun YouHeader(
onShare: () -> Unit,
onDownload: () -> Unit,
) {
- if (card == null) return
-
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -434,6 +498,152 @@ private fun YouHeader(
}
}
+/**
+ * What the "You" tab shows before the account has a display name: the card it *would* have, blurred
+ * out behind a prompt to claim it. Mirrors iOS `YouScreen.setupPrompt`.
+ *
+ * The stand-in is the account's real scannable payload drawn over an unnamed profile, with the
+ * card's own fill turned off so the 8% ground shows through — the same construction iOS uses. It is
+ * decoration: not tappable, not expandable, not shareable, and the tip link and Share / Download
+ * tiles are absent entirely, because there is nothing yet to link to or share.
+ *
+ * [blurEnabled] is haze's own API-31 gate, surfaced so a preview can render what an API 29/30
+ * device draws (see `Preview_UnclaimedTipCardPrompt_NoBlur`). Leave it at the default in app code.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun UnclaimedTipCardPrompt(
+ placeholder: Scannable.TipCard?,
+ cardWidth: Dp,
+ enabled: Boolean,
+ onClaim: () -> Unit,
+ blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(),
+) {
+ val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction)
+ val hazeState = rememberHazeState()
+ val placeholderName = stringResource(CoreR.string.label_tipCardNamePlaceholder)
+
+ // The card's ground, flattened: the 8% white wash resolved against the page behind it. The
+ // frosting composites over this, which is what makes the overlay opaque — haze draws the blur as
+ // a layer in FRONT of its source rather than filtering it in place, so without an opaque ground
+ // the sharp code would read straight through its own frosting.
+ // The HazeBlurStyle builder is not a @Composable scope, so the theme read is hoisted above it.
+ val cardGround = White08.compositeOver(CodeTheme.colors.background)
+ val frosting = HazeBlurStyle {
+ blurEnabled(blurEnabled)
+ blurRadius(PlaceholderBlurRadius)
+ backgroundColor(cardGround)
+ // Haze only blurs on API 31+ and minSdk is 29; below that it falls back to this scrim, which
+ // has to be opaque for the same reason. Never the sharp code: an unclaimed card drawn
+ // legibly would read as a real one.
+ fallbackColorEffect(HazeColorEffect.tint(cardGround))
+ // Off: haze's default film grain over a scannable figure reads as noise in the code itself
+ // rather than as texture, and iOS frosts the stand-in with a plain blur.
+ noiseFactor(0f)
+ }
+
+ // The stand-in is a fixed-width card in a full-width header slot, so it has to be centred the way
+ // the claimed card's own Column centres it. Without this it sits at the slot's start edge.
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ // The card pads itself off the status bar; the list's content padding already owns that
+ // clearance here, so consume the inset rather than paying it twice.
+ .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility)
+ .size(cardWidth, cardWidth * TipCardAspectRatio)
+ .clip(shape)
+ .background(White08)
+ .border(PlaceholderBorderWidth, White.copy(alpha = 0.12f), shape),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (placeholder != null) {
+ // A nameless account renders its name line as a bare "Tip ", which frosts to a much
+ // narrower smudge than a real card's. Stand a name in so the blur has the weight the
+ // claimed card's would (iOS `YouScreen.placeholderName`).
+ val stoodIn = remember(placeholder, placeholderName) {
+ placeholder.copy(user = placeholder.user.copy(displayName = placeholderName))
+ }
+
+ CompositionLocalProvider(
+ LocalTipCardColor provides Color(0xFF101011),
+ // Fill off, so the placeholder ground behind it is what's frosted, not an opaque card.
+ LocalTipCardBaseAlpha provides 0f,
+ ) {
+ ScannableRenderer(
+ modifier = Modifier.hazeSource(hazeState),
+ scannable = stoodIn,
+ tipCardWidth = cardWidth,
+ )
+ }
+
+ // Drawn over the stand-in and under the prompt, so the copy below stays sharp.
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .hazeBlur(HazeInput.Sources(hazeState), frosting)
+ )
+ }
+
+ Column(
+ // iOS caps the prompt at the card width less 16, so the copy never reaches the corners.
+ modifier = Modifier.width(cardWidth - PromptInset * 2),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(CoreR.string.title_tipIntro),
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ modifier = Modifier.padding(top = 8.dp),
+ text = stringResource(CoreR.string.subtitle_tipIntro),
+ style = CodeTheme.typography.textSmall,
+ // Full strength, not secondary: it sits over the blurred code's glow.
+ color = CodeTheme.colors.textMain,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ modifier = Modifier
+ .padding(top = 20.dp)
+ .clip(CircleShape)
+ .background(CodeTheme.colors.textMain)
+ .clickable(enabled = enabled, onClick = onClaim)
+ .padding(horizontal = 25.dp, vertical = 10.dp),
+ text = stringResource(CoreR.string.action_startReceivingTips),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.background,
+ )
+ }
+ }
+
+ Spacer(Modifier.height(UnclaimedRowsGap))
+ }
+}
+
+/** The tip card's height-to-width proportion and corner radius, mirrored from `TipCard`. */
+private const val TipCardAspectRatio = 333f / 269f
+private const val TipCardCornerFraction = 0.08f
+
+/** How far the unclaimed stand-in is frosted (iOS `YouScreen.setupPrompt`: `blur(radius: 12)`). */
+private val PlaceholderBlurRadius = 12.dp
+
+/** The hairline that keeps the blurred stand-in readable as a card rather than a smudge. */
+private val PlaceholderBorderWidth = 1.dp
+
+/** Margin between the claim prompt and the stand-in card's edges (iOS: 16 across the pair). */
+private val PromptInset = 8.dp
+
+/**
+ * Gap between the unclaimed stand-in and the first settings row. Wider than the claimed card's 19,
+ * because the claimed card pays part of its clearance in the Share / Download tiles that the
+ * unclaimed state doesn't draw (iOS `YouScreen`: `.padding(.top, displayName == nil ? 48 : 19)`).
+ */
+private val UnclaimedRowsGap = 48.dp
+
/**
* The label + chevron that toggles the card's full-screen state — "Full Screen" pointing down under
* the resting card (node 9276:4634), "Close" pointing up at the foot of the expanded one
@@ -651,3 +861,42 @@ private fun VersionFooter(
)
}
}
+
+private val PreviewCodeData = listOf(
+ 0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0,
+ 0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE,
+ 0x6F, 0x03, 0xB4, 0x87, 0x2C, 0xE5, 0x50, 0xA9,
+ 0x1E, 0x73, 0xC6, 0x3F, 0x98, 0x41, 0xDA, 0x65,
+ 0x0B, 0xF2, 0x7D, 0xAE, 0x53, 0xC0, 0x19,
+).map { it.toByte() }
+
+/** The stand-in as an API 31+ device draws it: haze's RenderEffect blur over the card's ground. */
+@Preview(name = "Unclaimed — blurred (API 31+)")
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_UnclaimedTipCardPrompt() {
+ UnclaimedTipCardPrompt(
+ placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty),
+ cardWidth = YouCardWidth,
+ enabled = true,
+ onClaim = {},
+ )
+}
+
+/**
+ * The same stand-in on API 29/30, where haze can't blur and falls through to its scrim delegate.
+ * The scrim draws nothing on its own, so this is the preview that proves `fallbackColorEffect`
+ * is doing its job: the code underneath must be fully covered, not legible.
+ */
+@Preview(name = "Unclaimed — scrim fallback (API 29/30)")
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_UnclaimedTipCardPrompt_NoBlur() {
+ UnclaimedTipCardPrompt(
+ placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty),
+ cardWidth = YouCardWidth,
+ enabled = true,
+ onClaim = {},
+ blurEnabled = false,
+ )
+}
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 f630d6c05..0a3bc9c37 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
@@ -7,8 +7,8 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService
import com.flipcash.app.bills.share.TipCodePreviewCache
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.android.VersionInfo
+import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.bill.Scannable
-import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.core.extensions.setText
import com.flipcash.app.core.share.TipCodeExportFormat
import com.flipcash.app.core.share.TipCodeExporter
@@ -82,12 +82,38 @@ internal class MenuScreenViewModel @Inject constructor(
val unlockedBetaFeaturesManually: Boolean = false,
val appVersionInfo: VersionInfo = VersionInfo(),
val releaseTrack: String = "",
- // The viewer's own tip card, shown at the top of the v2 "You" tab. Null until resolved
- // (or when the profile has no display name).
- val tipCard: Scannable.TipCard? = null,
- // The shareable URL for [tipCard]. Displayed abbreviated; copied in full.
- val tipLink: String? = null,
- )
+ // The viewer's own tip card, shown at the top of the v2 "You" tab.
+ val tipCardState: TipCardState = TipCardState.Unknown,
+ ) {
+ /** The card to share, export or expand — only a claimed one qualifies. */
+ val tipCard: Scannable.TipCard?
+ get() = (tipCardState as? TipCardState.Claimed)?.card
+
+ /** The shareable URL for [tipCard]. Displayed abbreviated; copied in full. */
+ val tipLink: String?
+ get() = (tipCardState as? TipCardState.Claimed)?.link
+ }
+
+ /**
+ * What the "You" tab has to draw at the top of the page.
+ *
+ * The three cases are deliberately distinct: `tipCard == null` used to mean both "we haven't
+ * resolved it yet" and "this account has no display name, so there is nothing to resolve", and
+ * the header drew nothing for either — leaving a nameless account with no card, no prompt, and
+ * no way to claim one from this tab.
+ */
+ sealed interface TipCardState {
+ /** Still resolving (or signed out). Draw nothing rather than guessing. */
+ data object Unknown : TipCardState
+
+ /**
+ * The account has no display name, so it has no card to show yet. [placeholder] is a real
+ * scannable stand-in drawn blurred behind the claim prompt; it is never shareable.
+ */
+ data class Unclaimed(val placeholder: Scannable.TipCard?) : TipCardState
+
+ data class Claimed(val card: Scannable.TipCard, val link: String?) : TipCardState
+ }
sealed interface Event {
data object OnVersionInfoClicked: Event
@@ -99,7 +125,9 @@ internal class MenuScreenViewModel @Inject constructor(
data class OnStaffUserDetermined(val staff: Boolean) : Event
data object PresentDepositOptions: Event
data class OpenScreen(val screen: AppRoute) : Event
- data class OnTipCardPopulated(val card: Scannable.TipCard, val link: String?) : Event
+ data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event
+ /** The claim prompt's CTA — collect a display name so the account gets a real card. */
+ data object ClaimTipCard : Event
data object ShareTipCard : Event
data object CopyTipLink : Event
data object DownloadTipCard : Event
@@ -175,18 +203,54 @@ internal class MenuScreenViewModel @Inject constructor(
}.onEach { route -> dispatchEvent(Event.OpenScreen(route)) }
.launchIn(viewModelScope)
- // Rebuild the viewer's own tip card whenever their profile becomes available/changes, so the
- // v2 "You" tab can show it at the top. Warm the Sharesheet preview eagerly so it's ready by
- // the time the user taps "Share as a Link".
+ // Rebuild the viewer's own tip card whenever their profile becomes available/changes, so
+ // the v2 "You" tab can show it at the top. Warm the Sharesheet preview eagerly so it's ready
+ // by the time the user taps "Share as a Link" — but only for a card that can be shared.
+ //
+ // Gated on Ready: a named account restores its cached profile before auth completes, so
+ // waiting here means it never flashes the claim prompt on the way in.
userManager.state
- .mapNotNull { it.userProfile }
+ .filter { it.authState is AuthState.Ready }
+ .map { it.userProfile }
.distinctUntilChanged()
- .map { tippingCoordinator.resolveTipCard() }
- .onResult(onSuccess = { card ->
- val userId = tippingCoordinator.currentUserId
- dispatchEvent(Event.OnTipCardPopulated(card, userId?.let { Linkify.tipcard(it) }))
- userId?.let { tipCodePreviewCache.prepare(it, card) }
- })
+ .onEach { profile ->
+ if (profile?.displayName.isNullOrEmpty()) {
+ // No name means no card yet — the tab prompts to claim one instead. Built
+ // locally, so an account whose profile the server has never seen still gets it.
+ dispatchEvent(
+ Event.OnTipCardStateChanged(
+ TipCardState.Unclaimed(tippingCoordinator.unclaimedTipCard())
+ )
+ )
+ } else {
+ tippingCoordinator.resolveTipCard().onSuccess { card ->
+ val userId = tippingCoordinator.currentUserId
+ dispatchEvent(
+ Event.OnTipCardStateChanged(
+ TipCardState.Claimed(card, userId?.let { Linkify.tipcard(it) })
+ )
+ )
+ userId?.let { tipCodePreviewCache.prepare(it, card) }
+ }
+ }
+ }
+ .launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ dispatchEvent(
+ Event.OpenScreen(
+ AppRoute.UpdateUserProfile(
+ origin = AppRoute.Sheets.Menu,
+ nameSource = DisplayNameSource.TipCardSetup,
+ includeName = true,
+ // Explicitly false: a name is all a tip card needs.
+ includePhoto = false,
+ )
+ )
+ )
+ }
.launchIn(viewModelScope)
eventFlow
@@ -322,12 +386,13 @@ internal class MenuScreenViewModel @Inject constructor(
)
}
- is Event.OnTipCardPopulated -> { state ->
- state.copy(tipCard = event.card, tipLink = event.link)
+ is Event.OnTipCardStateChanged -> { state ->
+ state.copy(tipCardState = event.tipCardState)
}
Event.PresentDepositOptions,
Event.CheckForUpdate,
+ Event.ClaimTipCard,
Event.ShareTipCard,
Event.CopyTipLink,
Event.DownloadTipCard,
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt
new file mode 100644
index 000000000..aebd2666f
--- /dev/null
+++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt
@@ -0,0 +1,46 @@
+package com.flipcash.app.menu.internal
+
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.services.models.UserProfile
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * The "You" tab used to model the tip card as a nullable, which conflated "still resolving" with
+ * "this account has no display name". The header drew nothing for either, so a nameless account got
+ * a tab with no card, no prompt and no way to claim one. The three states must stay distinct — and
+ * only a claimed card is shareable, linkable or expandable.
+ */
+class TipCardStateTest {
+
+ private val aCard = Scannable.TipCard(data = emptyList(), user = UserProfile.Empty)
+
+ @Test
+ fun `nothing is shareable while the card is still resolving`() {
+ val state = MenuScreenViewModel.State()
+ assertEquals(MenuScreenViewModel.TipCardState.Unknown, state.tipCardState)
+ assertNull(state.tipCard)
+ assertNull(state.tipLink)
+ }
+
+ @Test
+ fun `an unclaimed stand-in is never treated as the viewer's card`() {
+ val state = MenuScreenViewModel.State(
+ tipCardState = MenuScreenViewModel.TipCardState.Unclaimed(placeholder = aCard),
+ )
+ // Drawn blurred behind the claim prompt, but it is not a card the viewer owns: no share,
+ // no download, no link, and the caller's `canExpand` gate stays closed.
+ assertNull(state.tipCard)
+ assertNull(state.tipLink)
+ }
+
+ @Test
+ fun `a claimed card carries its shareable link`() {
+ val state = MenuScreenViewModel.State(
+ tipCardState = MenuScreenViewModel.TipCardState.Claimed(aCard, "https://flipcash.com/x"),
+ )
+ assertEquals(aCard, state.tipCard)
+ assertEquals("https://flipcash.com/x", state.tipLink)
+ }
+}
diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts
index e120534b9..8fbc71b42 100644
--- a/apps/flipcash/features/tipping/build.gradle.kts
+++ b/apps/flipcash/features/tipping/build.gradle.kts
@@ -7,6 +7,8 @@ android {
}
dependencies {
+ testImplementation(kotlin("test"))
+
implementation(project(":services:flipcash"))
implementation(project(":services:opencode"))
implementation(project(":libs:messaging"))
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt
index ab91d460d..10c5dad2b 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt
@@ -6,6 +6,8 @@ import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.data.Loadable
import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.core.tipping.TipStep
+import com.flipcash.app.featureflags.FeatureFlag
+import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.shareable.Shareable
import com.flipcash.app.bills.share.TipCodePreviewCache
@@ -34,6 +36,7 @@ internal class TipFlowViewModel @Inject constructor(
userManager: UserManager,
tippingCoordinator: TippingCoordinator,
tokenCoordinator: TokenCoordinator,
+ featureFlags: FeatureFlagController,
shareable: ShareSheetController,
tipCodePreviewCache: TipCodePreviewCache,
private val resources: ResourceHelper,
@@ -68,19 +71,14 @@ internal class TipFlowViewModel @Inject constructor(
combine(
userManager.state.map { it.userProfile }.distinctUntilChanged(),
stateFlow.map { it.resumed }.distinctUntilChanged(),
- ) { profile, resumed ->
- buildList {
- val hasProfile = !profile?.displayName.isNullOrEmpty() // TODO: explicitly not required right now && profile.profilePicture != null
- when {
- // Post-setup handoff: land on TipCard alone, with a close affordance. No Tips
- // underneath — closing exits, and reopening from home lands on the Tips list.
- resumed -> add(TipStep.TipCard)
- // Fresh open, incomplete profile: start setup (Intro → user-profile flow).
- !hasProfile -> add(TipStep.Intro)
- // Fresh open, set up: the Tips list.
- else -> add(TipStep.Tips)
- }
- }
+ featureFlags.observe(FeatureFlag.NewUi),
+ ) { profile, resumed, isNewUi ->
+ stepsFor(
+ // TODO: explicitly not required right now && profile.profilePicture != null
+ hasProfile = !profile?.displayName.isNullOrEmpty(),
+ resumed = resumed,
+ isNewUi = isNewUi,
+ )
}.onEach {
dispatchEvent(Event.StepsUpdated(it))
}.launchIn(viewModelScope)
@@ -119,7 +117,29 @@ internal class TipFlowViewModel @Inject constructor(
.launchIn(viewModelScope)
}
- companion object {
+ internal companion object {
+ /**
+ * The step the flow opens on.
+ *
+ * In v2 this flow *is* the "Chats" root tab: it always shows the list (and its empty state)
+ * whatever the profile looks like, so a nameless account gets the same chrome as any other,
+ * and the claim-your-tip-card prompt lives on the You tab instead. Swapping a root tab out
+ * for a setup screen would also strand that screen's Close, which has no sheet to dismiss.
+ */
+ fun stepsFor(hasProfile: Boolean, resumed: Boolean, isNewUi: Boolean): List =
+ listOf(
+ when {
+ isNewUi -> TipStep.Tips
+ // Post-setup handoff: land on TipCard alone, with a close affordance. No Tips
+ // underneath — closing exits, and reopening from home lands on the Tips list.
+ resumed -> TipStep.TipCard
+ // Fresh open, incomplete profile: start setup (Intro → user-profile flow).
+ !hasProfile -> TipStep.Intro
+ // Fresh open, set up: the Tips list.
+ else -> TipStep.Tips
+ }
+ )
+
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
when (event) {
is Event.OnStepChanged -> { state -> state.copy(currentStep = event.step) }
diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipFlowStepsTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipFlowStepsTest.kt
new file mode 100644
index 000000000..da422ab21
--- /dev/null
+++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipFlowStepsTest.kt
@@ -0,0 +1,62 @@
+package com.flipcash.app.tipping.internal
+
+import com.flipcash.app.core.tipping.TipStep
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * The tipping flow doubles as the v2 "Chats" root tab. A root tab must not be swapped out for the
+ * setup screen when the account has no display name: the list — and so its empty state — is the tab,
+ * and the setup screen's Close has no sheet to dismiss there.
+ */
+class TipFlowStepsTest {
+
+ @Test
+ fun `chats tab opens on the list even without a display name`() {
+ assertEquals(
+ listOf(TipStep.Tips),
+ TipFlowViewModel.stepsFor(hasProfile = false, resumed = false, isNewUi = true),
+ )
+ }
+
+ @Test
+ fun `chats tab opens on the list for a named account`() {
+ assertEquals(
+ listOf(TipStep.Tips),
+ TipFlowViewModel.stepsFor(hasProfile = true, resumed = false, isNewUi = true),
+ )
+ }
+
+ /** The resumed handoff is a v1 sheet re-entry; the v2 tip card has its own tab. */
+ @Test
+ fun `chats tab ignores the post-setup handoff`() {
+ assertEquals(
+ listOf(TipStep.Tips),
+ TipFlowViewModel.stepsFor(hasProfile = true, resumed = true, isNewUi = true),
+ )
+ }
+
+ @Test
+ fun `v1 sheet still starts setup for a nameless account`() {
+ assertEquals(
+ listOf(TipStep.Intro),
+ TipFlowViewModel.stepsFor(hasProfile = false, resumed = false, isNewUi = false),
+ )
+ }
+
+ @Test
+ fun `v1 sheet lands on the tip card after setup`() {
+ assertEquals(
+ listOf(TipStep.TipCard),
+ TipFlowViewModel.stepsFor(hasProfile = false, resumed = true, isNewUi = false),
+ )
+ }
+
+ @Test
+ fun `v1 sheet opens on the tips list once set up`() {
+ assertEquals(
+ listOf(TipStep.Tips),
+ TipFlowViewModel.stepsFor(hasProfile = true, resumed = false, isNewUi = false),
+ )
+ }
+}
diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
index f8cf6a99e..5a6a674bc 100644
--- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
+++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
@@ -278,6 +278,15 @@ class TippingCoordinator @Inject constructor(
return currentUserProfile().map { tipCard(userId, it) }
}
+ /**
+ * A stand-in tip card for an account that hasn't claimed one yet — the real scannable payload
+ * for [currentUserId] over the account's own (still nameless) profile, so no network round-trip
+ * is needed. It is only ever drawn blurred behind the claim prompt, which stands a placeholder
+ * name in for the blur's sake. Null when there is no signed-in user.
+ */
+ fun unclaimedTipCard(): Scannable.TipCard? =
+ currentUserId?.let { tipCard(it, userManager.profile ?: UserProfile.Empty) }
+
/**
* Resolves [userId]'s profile and builds their scannable tip card — for generating a card
* for another user (e.g. a scanned counterparty).
diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt
index a8671914c..340c51204 100644
--- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt
+++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt
@@ -255,6 +255,17 @@ class TokenCoordinator @Inject constructor(
suspend fun hasBalance(): Boolean =
_state.value.balances.values.any { it.hasDisplayableValue }
+ /**
+ * Observable "is there money in this account right now?", across every token including reserves.
+ *
+ * Deliberately [Fiat.isPositive] rather than [Fiat.hasDisplayableValue]: this answers whether the
+ * account holds *anything*, so a dust balance that rounds away in the UI still counts. Callers
+ * that need "enough to act on" want [hasGiveableBalance] instead.
+ */
+ val hasAnyBalance: Flow = _state
+ .map { state -> state.balances.values.any { it.isPositive } }
+ .distinctUntilChanged()
+
fun balanceForToken(token: Token): Fiat = _state.value.balances[token.address] ?: Fiat.Zero
fun balanceForToken(tokenAddress: Mint): Flow =