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 =