From 8f517a535509a95b9aacb03a1ad1cf089c6db00e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 23 Aug 2026 19:45:39 -0400 Subject: [PATCH 1/4] fix(balance): complete the add-money milestone from a held balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add Money" onboarding milestone read only `MessageDao.hasEverReceivedMoney()` — an EXISTS over the *local* message cache. An account funded before this install, or on another device, has money but no local row to prove it, so the wallet tab told it to add money it already had and kept the action tiles gated behind that same flag. Feed the milestone `hasEverReceivedMoney || holdsBalance`, sourced from a new `TokenCoordinator.hasAnyBalance`. That flow uses `Fiat.isPositive` rather than `hasDisplayableValue` — the question is whether the account holds anything at all, so a dust balance that rounds away in the UI still counts. A held balance also short-circuits `isAwaitingActivity`: the balance is a live read of the account, not of the cache, so there is nothing left to mistake for a new account and no reason to keep spinning on the first sync. --- .../app/balance/internal/WalletViewModel.kt | 40 +++++++++++++------ .../balance/internal/BalanceViewModelTest.kt | 7 ++++ .../internal/WalletLoadingStateTest.kt | 37 +++++++++++++++++ .../flipcash/app/tokens/TokenCoordinator.kt | 11 +++++ 4 files changed, 83 insertions(+), 12 deletions(-) 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/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 = From c05d220625e1e427d9001804f86911809e65310d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 23 Aug 2026 19:47:40 -0400 Subject: [PATCH 2/4] fix(tipping): keep the Chats tab on the list for a nameless account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tipping flow doubles as the v2 "Chats" root tab, and it opened on TipStep.Intro whenever the account had no display name. As a sheet step that was fine; as a root tab it replaced the whole tab — the NoChatsYet empty state was unreachable for a nameless account, and TipInfoScreen's Close had no sheet to dismiss. Under NewUi the flow always opens on TipStep.Tips, whatever the profile or tip-card state looks like. The claim-your-tip-card prompt belongs on the You tab. The v1 sheet keeps Intro and the post-setup TipCard handoff unchanged. Extracts the step derivation to `stepsFor` so both chromes are covered by tests. --- .../features/tipping/build.gradle.kts | 2 + .../app/tipping/internal/TipFlowViewModel.kt | 48 +++++++++----- .../app/tipping/internal/TipFlowStepsTest.kt | 62 +++++++++++++++++++ 3 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipFlowStepsTest.kt 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), + ) + } +} From f4c3cd316b8cd22db9d0a78cf40d1de9e0647ef2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 23 Aug 2026 19:54:01 -0400 Subject: [PATCH 3/4] fix(menu): prompt a nameless account to claim its tip card on the You tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The You tab modelled the tip card as a nullable, so `tipCard == null` meant both "still resolving" and "this account has no display name". YouHeader opened with `if (card == null) return`, which drew nothing for either — a nameless account got a page with no card, no prompt, and no way to claim one from the tab that owns it. Replaces the nullable with a Unknown / Unclaimed / Claimed tri-state. Unclaimed draws the card the account *would* have, blurred out behind the claim prompt and its CTA into display-name entry, mirroring iOS `YouScreen.setupPrompt`. The stand-in is the account's real scannable payload over a placeholder name, built locally, so an account whose profile the server has never returned still gets it — with the card's own fill turned off so the 8% ground is what's blurred. Below API 31 the ground and outline stand alone rather than falling back to a sharp code. The tip link and the Share / Download tiles are absent while unclaimed, expansion is disabled, and the Sharesheet preview is no longer warmed for a card that can't be shared. Resolution is gated on AuthState.Ready so a named account, which restores its cached profile before auth completes, never flashes the prompt. --- .../core/src/main/res/values/strings.xml | 3 + apps/flipcash/features/menu/build.gradle.kts | 4 + .../app/menu/internal/MenuScreenContent.kt | 212 +++++++++++++++++- .../app/menu/internal/MenuScreenViewModel.kt | 105 +++++++-- .../app/menu/internal/TipCardStateTest.kt | 46 ++++ .../shared/tipping/TippingCoordinator.kt | 9 + 6 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt 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/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..fcb178095 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 @@ -74,18 +78,27 @@ 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.updates.LocalAppUpdater +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.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 +121,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 +231,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 +242,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 +339,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 +393,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 +407,6 @@ private fun YouHeader( onShare: () -> Unit, onDownload: () -> Unit, ) { - if (card == null) return - Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -434,6 +493,147 @@ 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. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun UnclaimedTipCardPrompt( + placeholder: Scannable.TipCard?, + cardWidth: Dp, + enabled: Boolean, + onClaim: () -> Unit, +) { + 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 { + 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 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/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). From f10eec01ca0c7877aaa7a2ac697091ce39164382 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 23 Aug 2026 20:32:47 -0400 Subject: [PATCH 4/4] test(menu): preview the unclaimed tip card's API 29/30 blur fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haze only blurs on API 31+, and minSdk is 29. Below that it falls through to a scrim delegate that draws nothing unless given a colour effect — so the stand-in's fallbackColorEffect is the only thing standing between an API 29/30 device and a fully legible tip code behind the claim prompt. Nothing exercised that branch. Surfaces haze's own blurEnabled gate as a parameter on UnclaimedTipCardPrompt, defaulted to HazeBlurDefaults.isBlurEnabledByDefault() so app code is unchanged, and adds a preview pair: the blurred API 31+ rendering, and the scrim fallback an older device draws. The fallback preview is the one that matters — if the tint ever comes off, the code shows through it. --- .../app/menu/internal/MenuScreenContent.kt | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 fcb178095..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 @@ -61,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 @@ -79,7 +81,9 @@ 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 @@ -94,6 +98,7 @@ 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 @@ -501,6 +506,9 @@ private fun ClaimedTipCard( * 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 @@ -509,6 +517,7 @@ private fun UnclaimedTipCardPrompt( cardWidth: Dp, enabled: Boolean, onClaim: () -> Unit, + blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(), ) { val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction) val hazeState = rememberHazeState() @@ -521,6 +530,7 @@ private fun UnclaimedTipCardPrompt( // 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 @@ -851,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, + ) +}