From 6c573db77e87e3b8910ef7e1cae25326530ff1f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 07:50:31 -0400 Subject: [PATCH 1/8] feat(tokens): revamped v2 currency-info layout Ports the iOS currency-info revamp (code-ios-app#585) to Android's new UI (gated on FeatureFlag.NewUi; legacy screen untouched). Hero card, action tiles (Give/Convert/Withdraw, single Get when not held, USDF = Convert/Withdraw), per-token Recent, Market Cap + chart, About, created footer, and a scroll-revealed title pill. The app bar is an overlay (chat-style): content fills behind it as the haze source, inset by the measured bar height, with a bg->transparent scrim; back/pill/share are frosted liquid glass (via the optional hazeState on the app-bar chrome). Adds a bounded per-token recent-activity query (MessageDao/MessageDataSource/ActivityFeedCoordinator) surfaced as TokenInfoViewModel.State.transactions. All sizing via grid/type/shape/color tokens. --- .../core/src/main/res/values/strings.xml | 4 + .../flipcash/features/tokens/build.gradle.kts | 3 + .../flipcash/app/tokens/TokenInfoScreen.kt | 190 ++++-- .../app/tokens/internal/TokenInfoScreen.kt | 35 +- .../components/info/CurrencyInfoContentV2.kt | 557 ++++++++++++++++++ .../app/persistence/dao/MessageDao.kt | 7 + .../persistence/sources/MessageDataSource.kt | 14 + .../app/tokens/ui/TokenInfoViewModel.kt | 24 + .../ActivityFeedCoordinator.kt | 17 + 9 files changed, 801 insertions(+), 50 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 3db6191d4b..b7d2884abd 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -550,6 +550,10 @@ You can only sell up to %1$s Buy Sell + Convert + Get + Created %1$s + About Amount to Withdraw Solana USDC with Buy %1$s diff --git a/apps/flipcash/features/tokens/build.gradle.kts b/apps/flipcash/features/tokens/build.gradle.kts index 66b92f74a7..aa35182a11 100644 --- a/apps/flipcash/features/tokens/build.gradle.kts +++ b/apps/flipcash/features/tokens/build.gradle.kts @@ -11,8 +11,11 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:transaction-history")) + implementation(libs.bundles.haze) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 0a2177701c..7e527c671a 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -1,12 +1,31 @@ package com.flipcash.app.tokens +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -16,7 +35,10 @@ import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.ui.TokenIconWithName +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tokens.internal.TokenInfoScreen +import com.flipcash.app.tokens.internal.components.info.CurrencyInfoTitlePill import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.features.tokens.R import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -28,8 +50,11 @@ import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.measured import com.getcode.ui.core.rememberAnimationScale import com.getcode.ui.core.scaled +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -43,30 +68,57 @@ fun TokenInfoScreen( fromDeeplink: Boolean, ) { val navigator = LocalCodeNavigator.current + val analytics = rememberAnalytics() + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val analytics = rememberAnalytics() - val viewModel = hiltViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val features = LocalFeatureFlags.current + val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + val listState = rememberLazyListState() + + // v2: the title is a leading "Liquid Glass" pill that fades in once the hero card's own title has + // scrolled up under the bar. Approximate that point by the first item's scroll offset. + val revealThresholdPx = with(LocalDensity.current) { CodeTheme.dimens.staticGrid.x12.toPx() } + val showPill by remember(listState, revealThresholdPx) { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > revealThresholdPx + } + } + val pillProgress by animateFloatAsState( + targetValue = if (showPill) 1f else 0f, + label = "titlePill", + ) + + // For v2 the app bar chrome (back / title pill / share) is frosted "liquid glass" over the content + // scrolling beneath it — [haze] is that content's blur source. + val appBar: @Composable (HazeState?) -> Unit = { haze -> AppBarWithTitle( titleContent = { state.token.dataOrNull?.let { token -> - TokenIconWithName( - token = token, - imageSize = CodeTheme.dimens.staticGrid.x5, - spacing = CodeTheme.dimens.grid.x1, - ) + if (isNewUi) { + CurrencyInfoTitlePill( + token = token, + marketCap = state.marketCap, + progress = pillProgress, + hazeState = haze, + ) + } else { + TokenIconWithName( + token = token, + imageSize = CodeTheme.dimens.staticGrid.x5, + spacing = CodeTheme.dimens.grid.x1, + ) + } } }, - titleAlignment = Alignment.CenterHorizontally, + titleAlignment = if (isNewUi) Alignment.Start else Alignment.CenterHorizontally, onBackIconClicked = { navigator.pop() }, + hazeState = haze, endContent = { state.token.dataOrNull?.let { if (!state.isCashReserve) { - AppBarDefaults.Share { + AppBarDefaults.Share(hazeState = haze) { analytics.buttonTapped(Button.TokenShare) viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) } @@ -74,50 +126,94 @@ fun TokenInfoScreen( } }, ) + } - LaunchedEffect(Unit) { - val source = when { - shortFall != null -> Analytics.TokenInfoSource.Give - fromDeeplink -> Analytics.TokenInfoSource.Deeplink - else -> Analytics.TokenInfoSource.Wallet - } + if (isNewUi) { + // Chat-style overlay: content fills behind the app bar (hazeSource for the frosted chrome), + // inset below it by the measured bar height; the bar draws a bg->transparent scrim so content + // fades softly as it scrolls under it. + val hazeState = rememberHazeState() + var barHeight by remember { mutableStateOf(0.dp) } + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - analytics.openTokenInfo( - source = source, - mint = mint + Box(modifier = Modifier.fillMaxSize()) { + TokenInfoScreen( + viewModel = viewModel, + shortfall = shortFall, + listState = listState, + contentPadding = PaddingValues( + top = barHeight, + bottom = bottomInset + CodeTheme.dimens.grid.x8, + ), + hazeState = hazeState, ) - } - TokenInfoScreen(viewModel, shortFall) + Box( + modifier = Modifier + .fillMaxWidth() + .height(barHeight + CodeTheme.dimens.grid.x5) + .background( + Brush.verticalGradient( + 0f to CodeTheme.colors.background, + 1f to Color.Transparent, + ) + ) + ) - LaunchedEffect(Unit) { - viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, shortFall)) + Box( + modifier = Modifier + .statusBarsPadding() + .measured { barHeight = it.height }, + ) { + appBar(hazeState) + } + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + appBar(null) + TokenInfoScreen(viewModel, shortFall, listState) } + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.pop() } - .launchIn(this) + LaunchedEffect(Unit) { + val source = when { + shortFall != null -> Analytics.TokenInfoSource.Give + fromDeeplink -> Analytics.TokenInfoSource.Deeplink + else -> Analytics.TokenInfoSource.Wallet } + analytics.openTokenInfo(source = source, mint = mint) + } + + LaunchedEffect(Unit) { + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, shortFall)) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { navigator.pop() } + .launchIn(this) + } - LaunchedEffect(viewModel) { - viewModel.eventFlow - .filterIsInstance() - .map { it.screen } - .onEach { screen -> - when (screen) { - is AppRoute.Token.Swap -> { - navigator.navigateForResult(screen) { result -> - if (result is NavResultOrCanceled.ReturnValue && - result.value is SwapResult.OpenDeposit) { - navigator.push(AppRoute.Transfers.Deposit(showOtherOptions = false)) - } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { screen -> + when (screen) { + is AppRoute.Token.Swap -> { + navigator.navigateForResult(screen) { result -> + if (result is NavResultOrCanceled.ReturnValue && + result.value is SwapResult.OpenDeposit) { + navigator.push(AppRoute.Transfers.Deposit(showOtherOptions = false)) } } - else -> navigator.push(screen) } - }.launchIn(this) - } + else -> navigator.push(screen) + } + }.launchIn(this) } } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index 6f398bfd2c..d24d18e64e 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Divider import androidx.compose.material.Text @@ -36,6 +37,9 @@ import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.tokens.SwapPurpose +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.app.tokens.internal.components.info.CurrencyInfoContentV2 import com.flipcash.app.tokens.internal.components.info.MarketCapSection import com.flipcash.app.tokens.internal.components.info.TokenBalance import com.flipcash.app.tokens.internal.components.info.TokenDetailsSection @@ -54,20 +58,45 @@ import com.getcode.ui.theme.CodeScaffold import com.getcode.ui.utils.calculateEndPadding import com.getcode.ui.utils.calculateStartPadding import com.getcode.ui.utils.sheetResignmentBehavior +import dev.chrisbanes.haze.HazeState @Composable -internal fun TokenInfoScreen(viewModel: TokenInfoViewModel, shortfall: Fiat?) { +internal fun TokenInfoScreen( + viewModel: TokenInfoViewModel, + shortfall: Fiat?, + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), + hazeState: HazeState? = null, +) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - TokenInfoScreen(shortfall, state, viewModel::dispatchEvent) + TokenInfoScreen(shortfall, state, listState, contentPadding, hazeState, viewModel::dispatchEvent) } @Composable private fun TokenInfoScreen( shortfall: Fiat?, state: TokenInfoViewModel.State, + listState: LazyListState, + contentPadding: PaddingValues, + hazeState: HazeState?, dispatch: (TokenInfoViewModel.Event) -> Unit ) { - val listState = rememberLazyListState() + val features = LocalFeatureFlags.current + val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + + if (isNewUi) { + // v2 hosts its own overlaid app bar (see the outer TokenInfoScreen); content fills behind it, + // marked as the haze source so the frosted bar chrome frosts it, and inset by [contentPadding]. + CurrencyInfoContentV2( + shortfall = shortfall, + state = state, + listState = listState, + contentPadding = contentPadding, + hazeState = hazeState, + dispatch = dispatch, + ) + return + } CodeScaffold( bottomBar = { BottomBar(shortfall, state, dispatch) } diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt new file mode 100644 index 0000000000..54095b8674 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -0,0 +1,557 @@ +package com.flipcash.app.tokens.internal.components.info + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.SwapVert +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.money.formattedAppreciation +import com.flipcash.app.core.ui.TokenCard +import com.flipcash.app.core.ui.TokenIcon +import com.getcode.opencode.model.financial.Token +import com.flipcash.app.tokens.ui.TokenInfoViewModel +import com.flipcash.features.tokens.R +import com.flipcash.shared.transactionhistory.ActivityFeedRow +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.SocialLink +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall +import com.getcode.ui.components.text.ExpandableText +import com.getcode.ui.core.addIf +import com.getcode.ui.theme.CodeCircularProgressIndicator +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.blurEffect +import com.getcode.util.format + +/** + * V2 currency-info layout: LazyColumn with hero card → action tiles → recent activity + * → market cap → about section → created footer. No bottom bar — actions are inline tiles. + */ +@Composable +internal fun CurrencyInfoContentV2( + shortfall: Fiat?, + state: TokenInfoViewModel.State, + listState: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(), + hazeState: HazeState? = null, + dispatch: (TokenInfoViewModel.Event) -> Unit, +) { + val inset = CodeTheme.dimens.inset + val grid = CodeTheme.dimens.grid + + LazyColumn( + // Content fills behind the overlaid app bar and scrolls under it; [hazeState] marks it as the + // blur source so the frosted (liquid-glass) bar chrome frosts it. [contentPadding] insets the + // first item below the bar (like the chat screen), and the bar draws its own bg->transparent + // scrim for the soft top fade. + modifier = Modifier + .fillMaxSize() + .addIf(hazeState != null) { Modifier.hazeSource(hazeState!!) }, + state = listState, + contentPadding = contentPadding, + ) { + when (state.token) { + is Loadable.Loading -> { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + Box( + modifier = Modifier + .fillParentMaxSize(0.24f) + .aspectRatio(1f) + .align(Alignment.Center), + ) { + CodeCircularProgressIndicator( + modifier = Modifier.matchParentSize(), + strokeWidth = grid.x1, + color = Color.White, + backgroundColor = Color.White.copy(0.30f), + strokeCap = StrokeCap.Butt, + ) + } + } + } + } + + is Loadable.Error -> { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + Box( + modifier = Modifier + .fillParentMaxSize(0.24f) + .aspectRatio(1f) + .align(Alignment.Center), + ) { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(R.drawable.ic_circle_exclamation_large), + contentDescription = null, + ) + } + } + } + } + + is Loadable.Loaded -> { + val loadedToken = state.token as Loadable.Loaded + val token = loadedToken.data + + val isUsdf = state.isCashReserve + val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive + + // 1. Hero bill card + item { + val appreciationText = state.appreciation + ?.takeIf { isHeld && state.showAppreciation && it != LocalFiat.MIN_VALUE } + ?.nativeAmount + ?.formattedAppreciation() + + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x2), + ) + } + + // 2. Action tiles row + item { + CurrencyActionTiles( + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x3), + isHeld = isHeld, + isUsdf = isUsdf, + tokenMint = token.address, + shortfall = shortfall, + dispatch = dispatch, + ) + } + + // 3. Recent transactions (only when held and non-empty) + if (isHeld && state.transactions.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillParentMaxWidth() + .clickable { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Token.Transactions(token.address) + ) + ) + } + .padding(horizontal = inset) + .padding(top = grid.x5, bottom = grid.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.title_recentActivity), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } + + items(state.transactions, key = { it.id }) { item -> + ActivityFeedRow( + item = item, + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset), + ) + } + } + + // 4. Market cap (non-USDF only) + if (!isUsdf) { + state.marketCap?.let { mcap -> + val historicalData = state.historicalMarketCapData[state.selectedPeriod] + ?: Loadable.Loaded(emptyList()) + item { + MarketCapSection( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x5), + contentPadding = PaddingValues(horizontal = inset), + marketCap = mcap, + selectedPeriod = state.selectedPeriod, + rawHistoricalData = historicalData, + onRetry = { + dispatch( + TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( + state.selectedPeriod + ) + ) + }, + onPeriodSelected = { + dispatch(TokenInfoViewModel.Event.OnMarketCapPeriodSelected(it)) + }, + ) + } + } + } + + // 5. About section + val description = token.description + val socialLinks = token.socialLinks + if (description.isNotBlank() || socialLinks.isNotEmpty()) { + item { + CurrencyAboutSection( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x5), + description = description, + socialLinks = socialLinks, + isExpanded = state.descriptionExpanded, + inset = inset, + onToggleExpand = { + dispatch( + TokenInfoViewModel.Event.ExpandDescription(!state.descriptionExpanded) + ) + }, + ) + } + } + + // 6. Created footer (non-USDF with a known creation date) + if (!isUsdf) { + token.createdAt?.let { createdAt -> + item { + val formattedDate = createdAt.format("MMMM dd, yyyy") + Text( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = grid.x6, bottom = grid.x6) + .padding(horizontal = inset), + text = stringResource(R.string.label_createdAt, formattedDate).uppercase(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain.copy(alpha = 0.3f), + textAlign = TextAlign.Center, + ) + } + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Private composables +// --------------------------------------------------------------------------- + +@Composable +private fun CurrencyActionTiles( + isHeld: Boolean, + isUsdf: Boolean, + tokenMint: Mint, + shortfall: Fiat?, + dispatch: (TokenInfoViewModel.Event) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + when { + !isHeld -> { + // Single full-width "Get" tile + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_get), + icon = { + Icon( + imageVector = Icons.Outlined.ArrowDownward, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, + ) + } + + isUsdf -> { + // Held USDF: Convert + Withdraw (no Give) + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_convert), + icon = { + Icon( + imageVector = Icons.Outlined.SwapVert, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, + ) + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_withdraw), + icon = { + Icon( + imageVector = Icons.Outlined.ArrowUpward, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Transfers.Withdrawal(showOtherOptions = false) + ) + ) + }, + ) + } + + else -> { + // Held non-USDF: Give + Convert + Withdraw + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_give), + icon = { + Image( + painter = painterResource(R.drawable.ic_cash_bill), + contentDescription = null, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Sheets.Give(mint = tokenMint, fromTokenInfo = true) + ) + ) + }, + ) + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_convert), + icon = { + Icon( + imageVector = Icons.Outlined.SwapVert, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, + ) + ActionTile( + modifier = Modifier.weight(1f), + label = stringResource(R.string.action_withdraw), + icon = { + Icon( + imageVector = Icons.Outlined.ArrowUpward, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + ) + }, + onClick = { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Transfers.Withdrawal(showOtherOptions = false) + ) + ) + }, + ) + } + } + } +} + +@Composable +private fun ActionTile( + label: String, + icon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .height(CodeTheme.dimens.staticGrid.x18) + .clip(CodeTheme.shapes.extraSmall) + .background(Color.White.copy(alpha = 0.1f)) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + icon() + Spacer(Modifier.height(CodeTheme.dimens.grid.x1)) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } +} + +@Composable +private fun CurrencyAboutSection( + description: String, + socialLinks: List, + isExpanded: Boolean, + inset: Dp, + onToggleExpand: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (description.isNotBlank()) { + Text( + modifier = Modifier.padding(horizontal = inset), + text = stringResource(R.string.subtitle_about), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain.copy(alpha = 0.6f), + ) + ExpandableText( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = description, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + isExpanded = isExpanded, + contentPadding = PaddingValues(horizontal = inset), + onToggle = onToggleExpand, + ) + } + + if (socialLinks.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x4), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + contentPadding = PaddingValues(horizontal = inset), + ) { + items(socialLinks, key = { it.uri }) { link -> + SocialChip(link) + } + } + } + } +} + +/** + * Scroll-revealed leading title pill (icon + name + market cap). Fades in — driven by [progress] + * (0 hidden → 1 shown) — once the hero card's own title has scrolled under the app bar, matching the + * iOS "Liquid Glass" pill. A frosted translucent capsule: a grey lifted off the (near-black) + * background so it reads as glass over the dark chrome. Market cap is omitted for tokens without one + * (e.g. USDF). + */ +@Composable +internal fun CurrencyInfoTitlePill( + token: Token, + marketCap: Fiat?, + progress: Float, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val shape = CircleShape + val glassTint = lerp(CodeTheme.colors.background, Color.White, 0.18f) + // Real liquid glass over the scrolling content when a HazeState is supplied; falls back to a + // translucent capsule otherwise. `clip` precedes `hazeEffect` so the blur is bounded to the pill. + val fill = if (hazeState != null) { + val liquidGlass = HazeBlurStyle( + blurRadius = CodeTheme.dimens.grid.x4, + backgroundColor = CodeTheme.colors.background, + colorEffect = HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)), + ) + Modifier + .clip(shape) + .hazeEffect(hazeState) { blurEffect { style = liquidGlass } } + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) + } else { + Modifier + .clip(shape) + .background(glassTint.copy(alpha = 0.9f), shape) + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) + } + Row( + modifier = modifier + .graphicsLayer { alpha = progress } + .then(fill) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + TokenIcon(token = token, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5)) + Column { + Text( + text = token.name, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + marketCap?.let { + Text( + text = it.formatted(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt index 1b75e8a629..ab975d5964 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/MessageDao.kt @@ -37,6 +37,13 @@ interface MessageDao { @Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit") fun observeRecent(limit: Int): Flow> + /** + * The [limit] most recent messages for a single token, newest first — the token info screen's + * per-token recent-activity preview. + */ + @Query("SELECT * FROM messages WHERE mintBase58 = :mintBase58 ORDER BY timestamp DESC LIMIT :limit") + fun observeRecentForMint(mintBase58: String, limit: Int): Flow> + @Query("SELECT * FROM messages") suspend fun getAllMessages(): List diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index ce45dd3deb..29cc35ab69 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -11,6 +11,8 @@ import com.flipcash.app.persistence.sources.mapper.notifications.NotificationToE import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.persistence.PagingDataSource import com.getcode.opencode.model.core.ID +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -75,6 +77,18 @@ class MessageDataSource @Inject constructor( } ?: flowOf(emptyList()) } + /** + * Observes the [limit] most recent messages for a single token (newest first) as domain models — + * the token info screen's per-token activity preview. Same DB-readiness handling as [observeRecent]. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeRecent(mint: Mint, limit: Int): Flow> = + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.messageDao()?.observeRecentForMint(mint.base58(), limit)?.map { entities -> + entities.map { messageEntityMapper.map(it) } + } ?: flowOf(emptyList()) + } + override fun observe(): PagingSource { return db?.messageDao()?.observeMessages() ?: object : PagingSource() { override fun getRefreshKey(state: PagingState): Int? = null diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index 75b4c020de..aadecb64e7 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -27,6 +27,8 @@ import com.getcode.opencode.model.ui.WindowedRange import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel +import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.TransactionListItem import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -49,6 +52,7 @@ class TokenInfoViewModel @Inject constructor( private val shareController: ShareSheetController, private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, + private val feedCoordinator: ActivityFeedCoordinator, features: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( @@ -69,6 +73,8 @@ class TokenInfoViewModel @Inject constructor( val selectedPeriod: Period = Period.All, val canGiveUsdf: Boolean = false, val fundableBalanceMints: Set = emptySet(), + /** Bounded per-token recent activity preview (newest first) for the v2 currency-info screen. */ + val transactions: List = emptyList(), ) { val canSell: Boolean get() = balance.underlyingTokenAmount.valueNonZero() @@ -99,6 +105,7 @@ class TokenInfoViewModel @Inject constructor( data class OnAppreciatedEnabled(val enabled: Boolean) : Event data class OnTransactionHistoryEnabled(val enabled: Boolean): Event data class OnAppreciationUpdated(val amount: LocalFiat?) : Event + data class OnTransactionsUpdated(val transactions: List) : Event data class ExpandDescription(val expand: Boolean) : Event data object Share : Event data class OnBuy(val shortFall: Fiat? = null) : Event @@ -194,6 +201,19 @@ class TokenInfoViewModel @Inject constructor( dispatchEvent(Event.OnBuy(it)) }.launchIn(viewModelScope) + // Per-token recent activity preview. Read off the IO dispatcher so the section is populated in + // one pass and never gates the page's layout height on a load. + eventFlow + .filterIsInstance() + .map { it.mint } + .distinctUntilChanged() + .flatMapLatest { mint -> + feedCoordinator.recentTransactions(mint, RECENT_PREVIEW_COUNT) + } + .flowOn(dispatchers.IO) + .onEach { dispatchEvent(Event.OnTransactionsUpdated(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .map { it.period } @@ -323,6 +343,9 @@ class TokenInfoViewModel @Inject constructor( } companion object { + /** Rows shown in the token info screen's per-token recent-activity preview. */ + private const val RECENT_PREVIEW_COUNT = 3 + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnMintProvided -> { state -> state.copy(mint = event.mint) } @@ -331,6 +354,7 @@ class TokenInfoViewModel @Inject constructor( is Event.OnBalanceUpdated -> { state -> state.copy(balance = event.balance) } is Event.OnFundableBalancesUpdated -> { state -> state.copy(fundableBalanceMints = event.mints) } is Event.OnAppreciationUpdated -> { state -> state.copy(appreciation = event.amount) } + is Event.OnTransactionsUpdated -> { state -> state.copy(transactions = event.transactions) } is Event.ExpandDescription -> { state -> state.copy(descriptionExpanded = event.expand) } is Event.PresentDepositOptions -> { state -> state } is Event.OnHistoricalMarketCapDataUpdated -> { state -> diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index d3e984632c..8d29d180a7 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -193,6 +193,23 @@ class ActivityFeedCoordinator @Inject internal constructor( } } + /** + * A **preview** of the [limit] most recent transactions for a single [mint] (newest first), + * presentation-ready — the token info screen's per-token activity glimpse. Bounded and non-paged + * like [recentTransactions]; the full paged per-token history uses [transactions]. Same live + * token/profile resolution. + */ + fun recentTransactions(mint: Mint, limit: Int): Flow> = + combine(dataSource.observeRecent(mint, limit), resolvers) { messages, (profiles, tokens) -> + messages.map { msg -> + counterpartyOf(msg.metadata) + ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } + ?.let(::ensureProfile) + val token = msg.amount?.mint?.let { tokens[it] } + transactionItemMapper.map(ActivityFeedMessageWithToken(msg, token) to profiles) + } + } + /** * Observed profile + token caches, paired for a single [combine] against the cached pages. Both * are network-free reads that re-emit as their caches hydrate, so rows resolve reactively from From b3583024d4770cedcec02081c4ed71a90d0e82c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 08:02:12 -0400 Subject: [PATCH 2/8] refactor(tokens): measure the app bar before content (no open-jump) Replaces the onSizeChanged/measured overlay with a SubcomposeLayout (OverlayTopBarScaffold) that measures the app bar BEFORE the content in the same layout pass, so the hero card gets the correct top inset on the very first frame instead of settling on frame 2. Mirrors the chat screen's ChatInputScaffold (top-bar only). The scrim height is kept off the content inset so it can't shift the content on a later frame. --- .../flipcash/app/tokens/TokenInfoScreen.kt | 86 +++++++++++++------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 7e527c671a..175b8f0166 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -24,7 +24,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -129,44 +131,48 @@ fun TokenInfoScreen( } if (isNewUi) { - // Chat-style overlay: content fills behind the app bar (hazeSource for the frosted chrome), - // inset below it by the measured bar height; the bar draws a bg->transparent scrim so content - // fades softly as it scrolls under it. + // Overlay: content fills behind the app bar (hazeSource for the frosted chrome) and is inset by + // the bar height, measured BEFORE the content in the same layout pass (OverlayTopBarScaffold), + // so the hero card sits correctly on the very first frame — no settle/jump. The bar draws its + // own bg->transparent scrim (chat-style) so content fades as it scrolls under it. val hazeState = rememberHazeState() - var barHeight by remember { mutableStateOf(0.dp) } val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - Box(modifier = Modifier.fillMaxSize()) { + OverlayTopBarScaffold( + topBar = { + var barHeight by remember { mutableStateOf(0.dp) } + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .background( + Brush.verticalGradient( + 0f to CodeTheme.colors.background, + 1f to Color.Transparent, + ) + ) + ) + Box( + modifier = Modifier + .statusBarsPadding() + .measured { barHeight = it.height }, + ) { + appBar(hazeState) + } + } + }, + ) { topPadding -> TokenInfoScreen( viewModel = viewModel, shortfall = shortFall, listState = listState, contentPadding = PaddingValues( - top = barHeight, + top = topPadding, bottom = bottomInset + CodeTheme.dimens.grid.x8, ), hazeState = hazeState, ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(barHeight + CodeTheme.dimens.grid.x5) - .background( - Brush.verticalGradient( - 0f to CodeTheme.colors.background, - 1f to Color.Transparent, - ) - ) - ) - - Box( - modifier = Modifier - .statusBarsPadding() - .measured { barHeight = it.height }, - ) { - appBar(hazeState) - } } } else { Column( @@ -217,3 +223,31 @@ fun TokenInfoScreen( }.launchIn(this) } } + +private enum class OverlaySlot { Bar, Content } + +/** + * Top-bar overlay scaffold: [content] fills the whole area (drawn behind the bar) and is inset from + * the top by the bar's height, which is measured BEFORE the content in the same layout pass — so the + * content receives the correct top inset on the very first frame (no settle/jump on open or pop-back). + * Mirrors the chat screen's ChatInputScaffold, top-bar only. + */ +@Composable +private fun OverlayTopBarScaffold( + topBar: @Composable () -> Unit, + content: @Composable (topPadding: Dp) -> Unit, +) { + SubcomposeLayout(modifier = Modifier.fillMaxSize()) { constraints -> + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val barPlaceables = subcompose(OverlaySlot.Bar, topBar).map { it.measure(loose) } + val barHeight = barPlaceables.maxOfOrNull { it.height } ?: 0 + + val contentPlaceables = subcompose(OverlaySlot.Content) { content(barHeight.toDp()) } + .map { it.measure(constraints) } + + layout(constraints.maxWidth, constraints.maxHeight) { + contentPlaceables.forEach { it.place(0, 0) } + barPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, 0) } + } + } +} From b4f4fcf086c1f51c5ef5ef9cf461d92ef28ed60c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 08:48:13 -0400 Subject: [PATCH 3/8] fix(tokens): scope the top scroll fade to the status bar + half the app bar The scrim spanned the full app-bar height, washing content out across the whole bar. Scope it to the status-bar strip plus half the app-bar row, so the hero card dims as it scrolls up behind the frosted chrome (matching iOS) and stays vibrant below the bar's midline. The app bar is measured on its own (status bar excluded) so scrim = statusBar + appBar/2. The scrim never grows the content inset (the scaffold measures the full bar), so there's no jump. --- .../flipcash/app/tokens/TokenInfoScreen.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 175b8f0166..723c3594f3 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable @@ -140,12 +141,18 @@ fun TokenInfoScreen( OverlayTopBarScaffold( topBar = { - var barHeight by remember { mutableStateOf(0.dp) } + // Fade the status-bar strip plus HALF the app-bar row (behind the chrome): the hero card + // dims as it scrolls up under the bar (matching iOS) while staying vibrant below the bar's + // midline. [appBarHeight] is measured on the app bar alone (status bar excluded), so the + // scrim = status bar + half the app bar. The scrim never grows the content inset (the + // scaffold measures the full bar), so there's no jump. + var appBarHeight by remember { mutableStateOf(0.dp) } + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box { Box( modifier = Modifier .fillMaxWidth() - .height(barHeight) + .height(statusBarHeight + appBarHeight * 0.5f) .background( Brush.verticalGradient( 0f to CodeTheme.colors.background, @@ -153,12 +160,10 @@ fun TokenInfoScreen( ) ) ) - Box( - modifier = Modifier - .statusBarsPadding() - .measured { barHeight = it.height }, - ) { - appBar(hazeState) + Box(modifier = Modifier.statusBarsPadding()) { + Box(modifier = Modifier.measured { appBarHeight = it.height }) { + appBar(hazeState) + } } } }, From b8a056d6fc58fd33d8f69225538bdd573096d716 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 16:46:57 -0400 Subject: [PATCH 4/8] feat(wallet): Apple-Wallet card expand, in-wallet host so actions push over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the iOS #587 card-expand to Android: tapping a wallet card flies it to the currency-info hero while the deck reorganises behind it, driven by one progress scalar (:apps:flipcash:card-expand). Deck reorg mirrors iOS TokenCardStack: every non-hero card interpolates linearly from its own current top to a cleared spot and fades out — cards above converge onto the opened card's slot, cards below run off the bottom. Reading each card's own bounds makes it correct at any scroll offset, which fixes hidden cards revealing themselves mid-transition when the deck was scrolled/pinned. Host the expanded detail INSIDE the wallet nav entry (CardExpandHost), matching iOS's WalletScreen structure, so a pushed action screen covers it with the right z-order. The controller stays app-root so its fly-state survives the entry's composition being torn down on push; the hero's fly target is seeded from (and falls back to) the controller's surviving heroBounds, otherwise the card came back invisible after popping from a pushed action. Wire the currency-info action tiles: Give and Withdraw push real screens, Convert opens the buy flow. Give pushes rather than presenting a sheet on v2. Also: - flying hero ramps to full opacity by HeroPhase — opaque before the detail background covers its deck backing (no open dim), while still crossfading on close so the hand-off to the natural-z deck card dissolves instead of snapping - token discovery opens token-info as an ordinary push (asPush) with a back arrow - Convert tile uses the Figma IconArrowBottomTop glyph --- apps/flipcash/app/build.gradle.kts | 1 + .../app/internal/ui/navigation/AppContent.kt | 60 ++- .../ui/navigation/AppScreenContent.kt | 6 +- .../internal/ui/navigation/CardExpandHost.kt | 53 ++ apps/flipcash/card-expand/build.gradle.kts | 14 + .../flipcash/app/cardexpand/CardExpansion.kt | 170 +++++++ .../com/flipcash/app/core/ui/TokenCard.kt | 7 +- .../flipcash/app/core/ui/TokenCardStack.kt | 110 ++++- .../ui/transitions/CardExpandTransition.kt | 65 +++ .../core/ui/transitions/SharedTransitions.kt | 10 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 6 +- .../src/main/res/drawable/ic_banknote.xml | 12 + .../core/src/main/res/drawable/ic_convert.xml | 11 + .../features/balance/build.gradle.kts | 1 + .../balance/internal/WalletScreenContent.kt | 98 ++-- .../app/discovery/TokenDiscoveryScreen.kt | 2 +- .../flipcash/features/tokens/build.gradle.kts | 1 + .../app/tokens/CurrencyInfoExpansion.kt | 466 ++++++++++++++++++ .../flipcash/app/tokens/TokenInfoScreen.kt | 5 + .../components/info/CurrencyInfoContentV2.kt | 184 ++++--- .../components/info/MarketCapSection.kt | 2 + .../components/marketcap/MarketCapChart.kt | 23 +- .../RecentActivitySection.kt | 74 +++ .../opencode/model/ui/BillCustomization.kt | 9 + settings.gradle.kts | 1 + .../com/getcode/ui/components/TitleBar.kt | 17 +- 26 files changed, 1246 insertions(+), 162 deletions(-) create mode 100644 apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt create mode 100644 apps/flipcash/card-expand/build.gradle.kts create mode 100644 apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt create mode 100644 apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/CardExpandTransition.kt create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_banknote.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_convert.xml create mode 100644 apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt create mode 100644 apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 5d177c72b1..367a00c656 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -175,6 +175,7 @@ androidComponents { dependencies { implementation(project(":apps:flipcash:core-ui")) + implementation(project(":apps:flipcash:card-expand")) implementation(libs.bundles.haze) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) // KMP shared core — consumed transitively via :libs:encryption:base58 but declared diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 30d8023e53..bf6888e8a5 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -11,10 +11,13 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import dev.chrisbanes.haze.hazeSource @@ -22,9 +25,12 @@ import dev.chrisbanes.haze.rememberHazeState import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.app.cardexpand.CardExpansionController +import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.asNavBarTab +import com.flipcash.app.core.ui.transitions.CardExpandTransition import com.flipcash.app.internal.ui.AppNavigationBar import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBillOverlayEntryDecorator import com.flipcash.app.internal.ui.navigation.decorators.rememberNavBlockingOverlayEntryDecorator @@ -38,6 +44,19 @@ import com.getcode.ui.components.bars.BarManager import com.getcode.ui.core.measured import dev.theolm.rinku.DeepLink +/** + * True when a scene key belongs to [AppRoute.Token.Info]. The transition scope only exposes the + * scene's stringified content key (the [NavEntry] route itself is private), so match on its + * `toString()` — the `Info(mint=…)` data-class form is unique to the currency-info route. + */ +private fun isTokenInfoKey(key: Any?): Boolean { + val s = key?.toString() ?: return false + // The bespoke fade-in-place card-expand transition is only for the wallet/balance presentation. + // A drill-in push (asPush=true, e.g. from token discovery) is an ordinary stack push, so let it fall + // through to the default horizontal slide (and slide-back on pop / predictive-pop). + return s.startsWith("Info(") && s.contains("mint=") && !s.contains("asPush=true") +} + @Composable internal fun AppContent( codeNavigator: CodeNavigator, @@ -152,6 +171,25 @@ internal fun NewAppContent( val hazeState = rememberHazeState() val tabBarHeight = remember { mutableStateOf(0.dp) } + // Card-expand (iOS #587): the wallet requests an expansion (via LocalCardExpansion); the detail is + // drawn HERE as an overlay above the nav content, driven by one progress scalar, so the deck stays + // composed and reorganises behind it. See CardExpansionController / CurrencyInfoExpansion. + val context = LocalContext.current + val cardExpansion = remember(context) { + CardExpansionController().apply { + // Feed the controller the user's real animation-scale so the expand honours "animations + // off" (accessibility/battery) yet isn't fooled by a stale ambient MotionDurationScale. + val resolver = context.contentResolver + animationScale = { + android.provider.Settings.Global.getFloat( + resolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + } + } + } + CompositionLocalProvider(LocalCardExpansion provides cardExpansion) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. Box(modifier = Modifier.hazeSource(hazeState)) { @@ -191,6 +229,11 @@ internal fun NewAppContent( when { targetState is OverlayScene<*> || initialState is OverlayScene<*> -> EnterTransition.None togetherWith ExitTransition.None + // Wallet card-expand: the wallet holds and fades in place (no slide) while the + // tapped card flies to the currency-info hero and the deck reorganises. See + // CardExpandTransition + TokenCardStack. + isTokenInfoKey(targetState.key) -> + CardExpandTransition.openEnter togetherWith CardExpandTransition.openExit landsOnTab -> fadeIn(tween(300)) togetherWith fadeOut(tween(300)) else -> @@ -202,6 +245,8 @@ internal fun NewAppContent( when { targetState is OverlayScene<*> || initialState is OverlayScene<*> -> EnterTransition.None togetherWith ExitTransition.None + isTokenInfoKey(initialState.key) -> + CardExpandTransition.closeEnter togetherWith CardExpandTransition.closeExit else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -211,6 +256,10 @@ internal fun NewAppContent( when { targetState is OverlayScene<*> || initialState is OverlayScene<*> -> EnterTransition.None togetherWith ExitTransition.None + // Seeked by the drag: gradual specs so the deck reassembles + card returns in step. + isTokenInfoKey(initialState.key) -> + CardExpandTransition.predictiveCloseEnter togetherWith + CardExpandTransition.predictiveCloseExit else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -234,7 +283,16 @@ internal fun NewAppContent( hazeState = hazeState, modifier = Modifier .align(Alignment.BottomCenter) - .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height }, + .measured { if (it.height > tabBarHeight.value) tabBarHeight.value = it.height } + // Fades out with the expansion and back in with the collapse (no abrupt snap on return), + // like iOS's tab bar. At rest progress is 0, so it's fully shown. + .graphicsLayer { alpha = 1f - cardExpansion.progress.value }, ) + + // The expanded currency-info overlay is hosted INSIDE the wallet nav entry (see CardExpandHost), + // not here — so a pushed Give/Convert/Withdraw naturally covers it (correct z-order) and the deck + // reorganises behind it. The controller is provided app-root (above) so its fly-state survives the + // wallet entry's own composition churn on push/pop. + } } } \ No newline at end of file diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index c51d2fff9a..2e6b295480 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -103,7 +103,9 @@ fun appEntryProvider( annotatedEntry { TipAmountEntryScreen() } annotatedEntry { if (isNewUi) { - WalletScreen() + // v2 wallet hosts the card-expand overlay in-entry so a pushed action (Give/Convert/Withdraw) + // covers the expanded currency-info with correct z-order (iOS WalletScreen structure). + CardExpandHost { WalletScreen() } } else { BalanceScreen() } @@ -119,7 +121,7 @@ fun appEntryProvider( // Tokens annotatedEntry(testTag = "token_info_screen") { key -> - TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink) + TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink, key.asPush) } annotatedEntry(testTag = "transaction_history_screen") { key -> TransactionHistoryScreen(key.mint) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt new file mode 100644 index 0000000000..51973bbdfe --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/CardExpandHost.kt @@ -0,0 +1,53 @@ +package com.flipcash.app.internal.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.flipcash.app.cardexpand.CardExpansionController +import com.flipcash.app.cardexpand.LocalCardExpansion +import com.flipcash.app.tokens.CurrencyInfoExpansion +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.launch + +/** + * Hosts the wallet card-expand overlay INSIDE the wallet nav entry (iOS `WalletScreen` structure): the + * expanded currency-info is drawn over [content] but still WITHIN this entry, so a pushed action screen + * (Give / Convert / Withdraw) covers it with correct z-order and the deck reorganises behind it — while + * a plain dismiss collapses the overlay back into the deck. + * + * The [CardExpansionController] itself is provided at the app root (see NewAppContent), so its fly-state + * (progress, source/hero bounds, expandedKey) SURVIVES this entry's composition being torn down when a + * screen is pushed over the wallet — the overlay re-inflates from that surviving controller state on the + * way back, rather than reopening from scratch. + */ +@Composable +internal fun CardExpandHost(content: @Composable () -> Unit) { + val controller = LocalCardExpansion.current + if (controller == null) { + content() + return + } + + val scope = rememberCoroutineScope() + val collapse: () -> Unit = { + scope.launch { + controller.animateTo(0f, CardExpansionController.CollapseSpring) + controller.clear() + } + } + + Box(modifier = Modifier) { + content() + + (controller.expandedKey as? Mint)?.let { mint -> + CurrencyInfoExpansion( + controller = controller, + mint = mint, + onCollapse = collapse, + ) + BackHandler(onBack = collapse) + } + } +} diff --git a/apps/flipcash/card-expand/build.gradle.kts b/apps/flipcash/card-expand/build.gradle.kts new file mode 100644 index 0000000000..3def3a94a6 --- /dev/null +++ b/apps/flipcash/card-expand/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.flipcash.android.library.compose) +} + +android { + namespace = "${Gradle.flipcashNamespace}.cardexpand" +} + +dependencies { + implementation(libs.compose.foundation) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) +} diff --git a/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt b/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt new file mode 100644 index 0000000000..c43da417cc --- /dev/null +++ b/apps/flipcash/card-expand/src/main/kotlin/com/flipcash/app/cardexpand/CardExpansion.kt @@ -0,0 +1,170 @@ +package com.flipcash.app.cardexpand + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.geometry.Rect +import kotlinx.coroutines.withContext + +/** + * Coordinates an "expand a card into a full-screen overlay" presentation (Apple-Wallet / iOS #587 style) + * between the screen that owns the source card (e.g. the wallet deck) and an app-level host that renders + * the expanded content as an overlay above the nav content. + * + * It is deliberately **key-agnostic**: [expandedKey] is an opaque token (the owner passes whatever + * identifies the card — a mint, an id, …) and the host matches on it to decide what to render. No domain + * types leak into this module. + * + * A single [progress] scalar (0 = collapsed into the deck, 1 = fully expanded detail) drives everything: + * the deck reorganisation, the hero card's travel from [sourceBounds] to its expanded frame, and the + * detail fade. Tapping animates it to 1; a dismiss (button or interactive drag) drives it back to 0. + */ +@Stable +class CardExpansionController { + + /** The opaque key of the expanding card, or null when fully collapsed / at rest. */ + var expandedKey: Any? by mutableStateOf(null) + private set + + /** Window-space bounds of the tapped source card — the hero's start frame. */ + var sourceBounds: Rect? by mutableStateOf(null) + private set + + /** + * Window-space bounds of the expanded hero frame (reported by the overlay once measured). The deck's + * source card flies to exactly this frame in step with the overlay's hero, so the two coincide and + * the deck card — which stays at its natural z-order, under its neighbours — carries the hand-off on + * collapse with no z snap. + */ + var heroBounds: Rect? by mutableStateOf(null) + + fun reportHeroBounds(bounds: Rect) { heroBounds = bounds } + + /** + * The overlay hero's current pull-to-close translation (px, downward), published so the deck's own + * card (drawn in the wallet) can add the same offset and stay coincident with the overlay hero + * throughout the pull + release — otherwise the two separate and read as two cards. + */ + var pullOffset: Float by mutableStateOf(0f) + + /** + * Supplies the user's real animation-duration scale (0 = "animations off"). Defaults to 1; the + * host wires this to the live system setting (`Settings.Global.ANIMATOR_DURATION_SCALE`). We read + * the setting explicitly rather than relying on the ambient [MotionDurationScale], because in some + * hosting scopes that ambient value is stale/0 even when the user has animations enabled — which + * would snap the expand instantly. Reading the setting keeps us honest both ways: a user who truly + * disabled animations still gets an instant (scale 0) transition. + */ + var animationScale: () -> Float = { 1f } + + /** + * 0 = collapsed (in the deck), 1 = fully expanded (detail). + * + * Sub-pixel visibility threshold (vs the 0.01 default). The tween specs drive the settle cleanly on + * their own, but the tiny threshold keeps any interruption/retarget (e.g. an interactive drag handed to + * an animation) from ending a few-px short of the target — which, given the hero's several-hundred-px + * travel, would read as a jump right before it rests. + */ + val progress: Animatable = Animatable(0f, visibilityThreshold = 0.0001f) + + val isExpanded: Boolean get() = expandedKey != null + + /** + * Begin expanding [key] from the on-screen [bounds] of its source card. The caller then animates + * [progress] toward 1 (see [animateTo]). + */ + fun begin(key: Any, bounds: Rect) { + expandedKey = key + sourceBounds = bounds + // Clear per-open transient state so a new expansion never inherits the PREVIOUS card's fly target + // or a residual pull. (Progress is snapped to 0 by the caller before animating — see the open path + // in the wallet — so opening a card mid-collapse of another starts from a clean deck, not a + // half-reorganised one.) + heroBounds = null + pullOffset = 0f + } + + /** Animate [progress] to [target] (0 collapse / 1 expand) with the shared card-expand spring. */ + suspend fun animateTo(target: Float, spec: AnimationSpec = ExpandSpring) { + // Drive the animation under the user's real duration scale (read once per run) rather than the + // ambient MotionDurationScale, which can be a stale 0 in this scope and would snap the whole + // transition. A genuinely-disabled setting (0) still snaps — as it should. + val scale = animationScale().coerceAtLeast(0f) + withContext(object : MotionDurationScale { override val scaleFactor: Float = scale }) { + progress.animateTo(target, spec) + } + } + + /** Directly set [progress] — for scrubbing with an interactive drag. */ + suspend fun snapTo(target: Float) { + progress.snapTo(target.coerceIn(0f, 1f)) + } + + /** Drop the expansion once fully collapsed (progress back at 0), returning the deck to its own screen. */ + fun clear() { + expandedKey = null + sourceBounds = null + } + + companion object { + /** + * Timed curve for the expand — a **tween, deliberately not a physics spring**. A `spring()` + * approaches its target asymptotically and terminates on a velocity/threshold condition; that final + * approach, amplified by the hero card's several-hundred-px travel, reads as a small settle JITTER + * right before it rests (and again on reinsert). A tween eases to the target and lands EXACTLY on it + * on its final frame — a clean, definitive stop with no tail. This is the spec the transition shipped + * smooth with; the later spring swap is what introduced the both-ends settle jitter. [FastOutSlowInEasing] + * gives the responsive-start-into-gentle-settle that matches the iOS reference. Tune the duration for + * speed — it does not affect settle cleanliness. + */ + val ExpandSpring: AnimationSpec = + tween(durationMillis = 520, easing = FastOutSlowInEasing) + + /** + * Collapse/dismiss curve — a tween like [ExpandSpring] (same clean, definitive settle), just shorter + * so the exit is snappier than the enter (matches iOS, where the dismiss is quicker). + */ + val CollapseSpring: AnimationSpec = + tween(durationMillis = 440, easing = FastOutSlowInEasing) + + /** + * Split point between the two phases. Phase A [0→HeroPhase]: the hero card rises to the top while + * the deck COMPRESSES into a tight, still-visible stack beneath it. Phase B [HeroPhase→1]: only + * then does the detail (background + content) fade in, tucking over the squeezed deck. Running them + * sequentially — rather than fading the detail in over the still-compressing deck — is what keeps + * the compressed deck VISIBLE under the rising card (the iOS look) instead of the background + * erasing it mid-transition. Because the compressed deck is an on-screen tight stack (not off the + * edges), the collapse still crossfades cleanly: the detail fades to reveal the compressed deck, + * which then re-opens — no flash of black. + * + * Set fairly late (0.70) so phase A owns most of the transition: the hero visibly rises and the + * deck compresses into a tight, still-uncovered stack before the detail background (which ramps + * opaque over the first third of phase B) covers it. Too early a split and the background erased + * the deck while it was still mid-compression, so the open read as a crossfade rather than the + * iOS lift-then-cover. + */ + const val HeroPhase = 0.70f + + /** + * Sub-progress (0→1) for the hero rise + deck compression — tracks the FULL progress so the spring + * drives the visible motion across its whole curve (fluid, and stiffness actually reads). The detail + * still starts fading only at [HeroPhase]; its opaque background (which ramps in fast) covers the + * still-finishing deck, so the sequential "card then detail" read holds without clipping the motion. + */ + fun heroProgress(progress: Float): Float = progress.coerceIn(0f, 1f) + + /** Sub-progress (0→1) for the detail (background + content) fade — starts at [HeroPhase]. */ + fun detailProgress(progress: Float): Float = + ((progress - HeroPhase) / (1f - HeroPhase)).coerceIn(0f, 1f) + } +} + +/** Provided by the app-level host; read by the source screen (to request/scrub) and the overlay host. */ +val LocalCardExpansion = staticCompositionLocalOf { null } diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt index 5bc2bae22c..b09823fd32 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -185,8 +185,11 @@ fun TokenCard( } } -/** USDF's fixed gold branding (Figma) — used instead of a user-chosen bill color. */ -private val UsdfBrush = Brush.horizontalGradient(listOf(Color(0xFFC4980B), Color(0xFFB06B00))) +/** + * USDF's fixed gold branding (Figma) — used instead of a user-chosen bill color. Derived from the + * shared [BillBackground.Usdf] gradient so the card matches the full-screen bill exactly. + */ +private val UsdfBrush = Brush.horizontalGradient(BillBackground.Usdf.colors.map { hexToColor(it) }) // USDF "$" watermark tuning (Figma 9120:15335: ~213px glyph on a ~214px-tall card, top -17 / right // +17, 30% opacity). The glyph size is proportional to the card height so it scales with `height`; diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt index 1dd681c47a..62ca4aa2b6 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCardStack.kt @@ -2,12 +2,22 @@ package com.flipcash.app.core.ui import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.util.lerp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.runtime.mutableStateOf import com.getcode.opencode.model.financial.TokenWithLocalizedBalance -import kotlin.collections.forEach +import com.getcode.solana.keys.Mint /** * A vertical stack of [TokenCard]s that fans out (each card revealing its [fannedReveal] header) and @@ -23,6 +33,22 @@ import kotlin.collections.forEach * * When [collapsedReveal] is 0 (the default), back cards collapse completely behind the front card — * no slivers are visible at rest — matching the iOS wallet card-stack behaviour. + * + * ## Card-expand transition + * Every card carries a mint-keyed [SharedTransition.TokenCard] so that when the currency-info screen + * is pushed, the card whose mint matches the destination hero flies between the deck slot and the + * hero (shared-bounds overlay). The tapped card is hosted in the overlay, so it keeps its natural + * opacity while the rest of the wallet fades. + * + * [expandingMint] + [expandProgress] drive the **deck reorganisation** around the tapped card as the + * push progresses (0 → 1), ported from iOS: cards **above** the tapped one gather onto exactly the spot + * it lands (collapsing into its slot underneath it), cards **below** run off the bottom, and both fade + * out linearly. Because each card reads its own current top, this holds at any scroll position. The + * tapped card itself is skipped here (shared-bounds overlay owns it) and flies to the hero. + * + * The flying card is hosted in the transition overlay while **opening** (so it lifts cleanly above the + * parting deck) but **in-layer** while closing, so on the way back it re-inserts at its natural deck + * z-order and slides under its neighbours instead of landing on top and snapping under. */ @Composable fun TokenCardStack( @@ -33,16 +59,88 @@ fun TokenCardStack( collapsedReveal: Dp = 0.dp, pinInset: Dp = 0.dp, scrolledPast: () -> Float = { 0f }, - onCardClick: (TokenWithLocalizedBalance) -> Unit = {}, + expandingMint: Mint? = null, + expandProgress: () -> Float = { 0f }, + heroTarget: Rect? = null, + pullOffset: () -> Float = { 0f }, + onCardClick: (TokenWithLocalizedBalance, Rect) -> Unit = { _, _ -> }, ) { + val tappedIndex = remember(expandingMint, tokens) { + if (expandingMint == null) -1 else tokens.indexOfFirst { it.token.address == expandingMint } + } + // Screen height, so cards below the selected one travel off the bottom edge (read live in the reorg + // layer; changes rarely). + val windowHeightPx = with(LocalDensity.current) { + LocalConfiguration.current.screenHeightDp.dp.toPx() + } + Layout( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth(), content = { - tokens.forEach { token -> + tokens.forEachIndexed { index, token -> + val isTapped = index == tappedIndex + val cardBounds = remember(token.token.address) { mutableStateOf(Rect.Zero) } TokenCard( tokenWithBalance = token, + modifier = Modifier + .onGloballyPositioned { cardBounds.value = it.boundsInWindow() } + // Deck reorganisation. The tapped card FLIES to the expanded hero frame in step + // with the overlay's own hero, so the two coincide — the overlay draws the crisp + // card on top, while this one stays at its natural deck z-order (under its + // neighbours) and carries the hand-off on collapse without a z snap. The other + // cards part around it: above slide off the top, below off the bottom, dissolving. + .graphicsLayer { + if (isTapped) { + val src = cardBounds.value + val tgt = heroTarget + val p = expandProgress() + if (tgt != null && tgt.width > 0f && src.width > 0f) { + transformOrigin = TransformOrigin(0f, 0f) + val scale = lerp(1f, tgt.width / src.width, p) + scaleX = scale + scaleY = scale + translationX = (tgt.left - src.left) * p + // Match the overlay hero's pull-to-close translation so the two stay + // coincident (no second card during a pull or the release). + translationY = (tgt.top - src.top) * p + pullOffset() + } + // Opacity backing for the overlay hero's cross-fade. It reaches full + // opacity well before the slot (so it's a solid, opaque backing under the + // fading overlay hero — no mid-transition dip where the dark background + // bleeds through), yet is fully hidden at the expanded frame (p→1) so it + // never doubles the overlay hero while expanded or during a pull-to-close. + alpha = ((1f - p) * 2.5f).coerceIn(0f, 1f) + } else if (tappedIndex >= 0) { + val hp = expandProgress() + val tgt = heroTarget + if (hp > 0f && tgt != null) { + // Deck reorganisation, ported from iOS `TokenCardStack.visualEffect`. + // Every non-hero card interpolates LINEARLY from its rest position to a + // "cleared" spot and fades out (opacity 1 → 0) over the SAME progress that + // flies the hero — so the whole thing reads as one animation. + // + // • ABOVE the opened card: gather onto EXACTLY the top the opened card lands + // at (`tgt.top`) — every above-card converges on that one spot, collapsing + // into its slot UNDERNEATH it (they share the hero's z-order-below position). + // • BELOW: run off the bottom edge. + // + // Each card reads its OWN current top (`cardBounds`), which is what makes this + // correct at ANY scroll position: when the deck is scrolled so the cards above + // the opened one are collapsed/pinned at the top, they simply converge into the + // opened card from wherever they are — they never fan DOWN into view. And the + // cards converging from their fanned spread onto one point IS the fan tightening + // closed (and, in reverse, breathing back open on reinsert) — no explicit + // per-card slivers needed. A single linear fade avoids any bright-then-covered + // "reveal": they are already dissolving as they gather. + val clearedTop = if (index < tappedIndex) tgt.top else windowHeightPx + translationY = (clearedTop - cardBounds.value.top) * hp + alpha = 1f - hp + } + } + }, height = cardHeight, - onClick = { onCardClick(token) }, + onClick = { onCardClick(token, cardBounds.value) }, ) } }, @@ -72,4 +170,4 @@ fun TokenCardStack( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/CardExpandTransition.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/CardExpandTransition.kt new file mode 100644 index 0000000000..771ffc59d7 --- /dev/null +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/CardExpandTransition.kt @@ -0,0 +1,65 @@ +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package com.flipcash.app.core.ui.transitions + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.ui.geometry.Rect + +/** + * Timings + specs for the wallet card-expand transition (fanned deck ⇄ currency-info hero), porting + * iOS #587's single `.smooth(0.32)` scalar. + * + * The wallet does **not** slide horizontally for this push. It holds and fades **in place** + * ([openExit]) while the tapped card flies to the hero via the shared-bounds overlay and the deck + * reorganises around the vacated slot. Because the flying card is hosted in the transition overlay it + * is unaffected by the screen fade, so it stays fully opaque throughout — matching iOS's tapped card + * (opacity 1). The destination fades in slightly delayed ([openEnter]) so the deck reorg is visible + * first, mirroring iOS's `pageFollowDelay`. + */ +object CardExpandTransition { + const val OpenMillis = 340 + + // Kept ≥ the fly-back spring so the wallet's enter transition (which gates in-layer hosting via + // currentState == PreEnter) stays active for the whole flight — otherwise the card would flip back + // into the overlay mid-landing and snap on top of its neighbours. + const val CloseMillis = 360 + + /** Long enough to outlast the card's fly-back spring, keeping the detail source composed. */ + private const val FlightMillis = 440 + + val openEnter: EnterTransition = + fadeIn(tween(durationMillis = 160, delayMillis = OpenMillis - 160)) + val openExit: ExitTransition = fadeOut(tween(OpenMillis)) + + // Close: the wallet appears immediately (no fade) so the returning card — rendered in-layer at its + // natural deck z-order — is fully visible as it flies home and slides under its neighbours (in-layer + // rendering avoids the overlay's land-on-top-then-snap). The detail screen is held composed but + // invisible (alpha snaps to 0 via the flat easing, yet the composable lingers) so it still anchors + // the shared element's source for the full flight. + val closeEnter: EnterTransition = EnterTransition.None + val closeExit: ExitTransition = fadeOut(tween(FlightMillis, easing = Easing { 1f })) + + // Interactive (drag) close: NavDisplay SEEKS this to the drag progress, so the specs must map + // fraction → visible state (not snap). The wallet is revealed underneath (enter None) while the + // detail fades out on top; the deck reorg + shared card seek in step underneath. Used only by the + // predictive-pop path so the button close keeps its snap-invisible hand-off above. + // Enter has a real (seekable) spec — NOT None — so the wallet's enter transition actually seeks with + // the drag, driving the deck-reorg reassembly and the shared card's fly-back in step (None would snap + // the wallet to rest and you'd only get a crossfade). + val predictiveCloseEnter: EnterTransition = fadeIn(tween(FlightMillis)) + val predictiveCloseExit: ExitTransition = fadeOut(tween(FlightMillis)) + + /** Critically-damped spring (≈ iOS `.smooth`) driving the card's fly between deck slot and hero. */ + val boundsTransform: BoundsTransform = BoundsTransform { _, _ -> + spring(dampingRatio = 1f, stiffness = 380f, visibilityThreshold = Rect.VisibilityThreshold) + } +} diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/SharedTransitions.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/SharedTransitions.kt index a5fee05fd7..d06eb4ad3b 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/SharedTransitions.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/transitions/SharedTransitions.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.getcode.animation.LocalSharedTransitionScope +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 sealed class SharedTransition( val key: String, @@ -45,6 +47,14 @@ sealed class SharedTransition( } data object CurrencyBill: SharedTransition("currency-bill") + + /** + * A wallet bill card flying between the fanned deck and the currency-info hero card. Keyed by mint + * so only the tapped card matches its hero on the destination. Rendered in the transition overlay + * (the default) so the card lifts out of the deck and flies independently to the hero — in-layer + * rendering would keep it pinned in the deck and it would just slide off with the wallet screen. + */ + data class TokenCard(val mint: Mint) : SharedTransition("token-card-${mint.base58()}") } private val DefaultTransform = BoundsTransform { _, _ -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 93937f2cf8..6416f35189 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -198,7 +198,11 @@ sealed interface AppRoute : NavKey, Parcelable { data class Info( val mint: Mint, val shortfall: Fiat? = null, - val fromDeeplink: Boolean = false + val fromDeeplink: Boolean = false, + // A normal stack PUSH (slide in, back arrow) rather than the wallet card-expand presentation + // (fade-in-place, ✕ dismiss). Set when the screen is reached by drilling in from a list — e.g. + // token discovery — where a back arrow that slides back is the expected navigation. + val asPush: Boolean = false, ) : Token @Serializable diff --git a/apps/flipcash/core/src/main/res/drawable/ic_banknote.xml b/apps/flipcash/core/src/main/res/drawable/ic_banknote.xml new file mode 100644 index 0000000000..5389432a60 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_banknote.xml @@ -0,0 +1,12 @@ + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_convert.xml b/apps/flipcash/core/src/main/res/drawable/ic_convert.xml new file mode 100644 index 0000000000..11c6463855 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_convert.xml @@ -0,0 +1,11 @@ + + + + diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index dfe9882d54..ca9898ebd0 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":apps:flipcash:card-expand")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index fbdf5dda76..fde636ee5f 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -23,14 +23,20 @@ import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.cardexpand.CardExpansionController +import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.launch import com.flipcash.app.core.ui.AppreciationStyle import com.flipcash.app.core.ui.TokenCardStack import com.flipcash.app.balance.internal.components.BalanceHeader @@ -41,7 +47,7 @@ import com.flipcash.app.core.ui.TileButton import com.flipcash.app.core.ui.TileButtonStyle import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.features.balance.R -import com.flipcash.shared.transactionhistory.ActivityFeedRow +import com.flipcash.shared.transactionhistory.recentActivitySection import com.getcode.theme.CodeTheme private const val TokenStackKey = "tokenStack" @@ -75,6 +81,19 @@ internal fun WalletScreenContent( ?.let { -it.offset.toFloat() } ?: 0f } val statusBarInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val grid = CodeTheme.dimens.grid + + // Card-expand (iOS #587): tapping a card asks the app-level host (LocalCardExpansion) to expand its + // currency-info as an overlay above this screen. The deck stays composed and reorganises in step with + // the shared progress scalar — cards part around the tapped one (which is hidden here; the overlay + // draws the flying hero) and the header fades. + val cardExpansion = LocalCardExpansion.current + val expansionScope = rememberCoroutineScope() + val expandingMint = cardExpansion?.expandedKey as? Mint + val heroProgress = { + cardExpansion?.let { CardExpansionController.heroProgress(it.progress.value) } ?: 0f + } + LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -89,7 +108,9 @@ internal fun WalletScreenContent( // v2 wallet header: 96 dp top / 44 dp bottom per Figma node 8966:1578. BalanceHeader( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + // Fade the balance out as the deck parts behind the opening card (iOS deckOpacity). + .graphicsLayer { alpha = 1f - heroProgress() }, balance = tokenState.totalBalance, appreciation = tokenState.aggregateAppreciation, topPadding = 96.dp, @@ -131,56 +152,41 @@ internal fun WalletScreenContent( modifier = Modifier.fillMaxWidth(), pinInset = statusBarInset + CodeTheme.dimens.grid.x2, scrolledPast = scrolledPast, - onCardClick = { token -> - dispatchEvent( - WalletViewModel.Event.OpenScreen( - AppRoute.Token.Info(mint = token.token.address) - ) - ) + expandingMint = expandingMint, + expandProgress = heroProgress, + heroTarget = cardExpansion?.heroBounds, + pullOffset = { cardExpansion?.pullOffset ?: 0f }, + onCardClick = { token, bounds -> + // Expand the tapped card's currency-info as an overlay (app-level host draws it); + // the deck reorganises here in step with the shared progress scalar. + cardExpansion?.let { controller -> + controller.begin(token.token.address, bounds) + expansionScope.launch { + // Snap to a fully-collapsed deck first: tapping a card while a prior one is + // still collapsing would otherwise open from that card's mid-progress, so the + // whole deck (including the one just closed) jumps into a half-reorganised + // pose and animates from there ("stacked" enter animations). + controller.snapTo(0f) + controller.animateTo(1f) + } + } }, ) } } if (balanceState.transactions.isNotEmpty()) { - item(key = "recentHeader") { - // Tap the header to dive into the full paged activity history. - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x2) - .clickable { - dispatchEvent( - WalletViewModel.Event.OpenScreen(AppRoute.Sheets.ActivityHistory) - ) - } - .padding( - top = CodeTheme.dimens.grid.x4, - bottom = CodeTheme.dimens.grid.x1, - ), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.title_recentActivity), - style = CodeTheme.typography.screenTitle, - color = CodeTheme.colors.textMain, - ) - Icon( - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - } - } - // Preview of the most recent activity (newest first); the full history lives on its own - // screen. The VM/coordinator already bounds this list, so just render it. - items( - items = balanceState.transactions, - key = { it.id }, - ) { item -> - ActivityFeedRow(item = item, modifier = Modifier.fillMaxWidth()) - } + // Preview of the most recent activity (newest first); the full paged history lives on its own + // screen. Tap the header to dive in. Shared with the token-info screen for consistency. + recentActivitySection( + transactions = balanceState.transactions, + modifier = Modifier + .padding(top = grid.x2) + .clickable { + dispatchEvent(WalletViewModel.Event.OpenScreen(AppRoute.Sheets.ActivityHistory)) + } + .padding(top = grid.x4, bottom = grid.x1), + ) } if (balanceState.hasAddedMoney) { diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt index fdaebbb4e3..1aea1e7e3a 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/TokenDiscoveryScreen.kt @@ -54,7 +54,7 @@ private fun TokenDiscoveryEventHandler(viewModel: TokenDiscoveryViewModel, navig viewModel.eventFlow .filterIsInstance() .map { it.mint } - .onEach { navigator.navigate(AppRoute.Token.Info(it)) } + .onEach { navigator.navigate(AppRoute.Token.Info(it, asPush = true)) } .launchIn(this) } diff --git a/apps/flipcash/features/tokens/build.gradle.kts b/apps/flipcash/features/tokens/build.gradle.kts index aa35182a11..25a1a368e5 100644 --- a/apps/flipcash/features/tokens/build.gradle.kts +++ b/apps/flipcash/features/tokens/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(project(":apps:flipcash:card-expand")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt new file mode 100644 index 0000000000..8e2da015e1 --- /dev/null +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt @@ -0,0 +1,466 @@ +package com.flipcash.app.tokens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.cardexpand.CardExpansionController +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.tokens.SwapResult +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult +import com.flipcash.app.core.money.formatted +import com.flipcash.app.core.money.formattedAppreciation +import com.flipcash.app.core.ui.TokenCard +import com.flipcash.app.tokens.internal.components.info.CurrencyInfoContentV2 +import com.flipcash.app.tokens.internal.components.info.CurrencyInfoTitlePill +import com.flipcash.app.tokens.ui.TokenInfoViewModel +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import dev.chrisbanes.haze.rememberHazeState +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 +import com.getcode.theme.CodeTheme +import kotlin.math.roundToInt + +/** + * The expanded currency-info detail, drawn as an overlay above the wallet (iOS #587 model). The whole + * thing is driven by [CardExpansionController.progress]: + * - phase A (0 → [CardExpansionController.HeroPhase]): the hero card flies between the tapped deck + * slot ([CardExpansionController.sourceBounds]) and its expanded frame; the wallet deck (behind) + * reorganises in step. + * - phase B ([CardExpansionController.HeroPhase] → 1): the detail content + chrome + background fade + * in while the card stays fixed at its expanded frame. + * + * So expand reads card-flies-then-detail-appears and collapse reads detail-fades-then-card-returns. + * The hero itself is drawn HERE (flying); the detail's own hero slot is an invisible placeholder that + * reports its window bounds as the fly target. + */ +@Composable +fun CurrencyInfoExpansion( + controller: CardExpansionController, + mint: Mint, + onCollapse: () -> Unit, + modifier: Modifier = Modifier, +) { + // ONE reused VM for the overlay — deliberately NO per-mint key. The overlay is hosted at the app + // level, so a key-per-mint hiltViewModel parked a distinct VM (each with ~14 always-on polling + // collectors: balance, rate, market cap, recent activity) in the Activity store for every token ever + // opened; they never stopped and piled up, starving later open transitions (the intermittent lag over + // many opens). A single keyless instance is reused across opens and re-pointed by OnMintProvided + // below — its collectors are set up once and its flatMapLatest chains cancel the previous mint's work + // — so nothing accumulates. + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + LaunchedEffect(mint) { + viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) + } + + // The wallet-tap detail is an app-root overlay, not a nav entry — so route its action tiles + // (Give / Convert / Withdraw) through the reused VM's events the same way the pushed + // TokenInfoScreen does. Pushed screens land ON the back stack and cover this overlay, which stays + // composed (moved aside by NewAppContent while covered) so returning reveals it with no reopen. + // Mirrors iOS, where CurrencyInfoScreen.give pushes over the currency-info overlay. Exit collapses. + val navigator = LocalCodeNavigator.current + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { onCollapse() } + .launchIn(this) + } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .map { it.screen } + .onEach { screen -> + when (screen) { + is AppRoute.Token.Swap -> { + navigator.navigateForResult(screen) { result -> + if (result is NavResultOrCanceled.ReturnValue && + result.value is SwapResult.OpenDeposit) { + navigator.push(AppRoute.Transfers.Deposit(showOtherOptions = false)) + } + } + } + else -> navigator.push(screen) + } + }.launchIn(this) + } + + val listState = rememberLazyListState() + + // Frosted-glass state: the scrolling detail registers as the haze source (inside CurrencyInfoContentV2) + // and the app bar's close/share buttons + title pill sample it for their liquid-glass blur. + val hazeState = rememberHazeState() + + // Title pill reveal: it fades in once the hero card has scrolled up under the bar (matches the pushed + // token-info screen). Mirrors TokenInfoScreen's threshold + spring. + val revealThresholdPx = with(LocalDensity.current) { CodeTheme.dimens.staticGrid.x12.toPx() } + val showPill by remember(listState, revealThresholdPx) { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > revealThresholdPx + } + } + val pillProgress by animateFloatAsState( + targetValue = if (showPill) 1f else 0f, + label = "titlePill", + ) + + val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val appBarHeight = 56.dp + // Breathing room between the app bar and the hero card (the bare bar height alone reads too tight). + val heroTopGap = CodeTheme.dimens.grid.x4 + + // Seed from the controller's surviving hero bounds: when this overlay is hosted inside the wallet nav + // entry, a pushed action tears down its composition, so a plain `remember(null)` would come back null + // on return — the flying hero is gated on a non-null target, so it would stay INVISIBLE until the + // placeholder re-measured. The controller keeps the last measured bounds (it's app-root), so re-seed + // from it and the hero is drawable again on the very first frame back. + var heroTarget by remember { mutableStateOf(controller.heroBounds) } + + // Pull-to-close: the detail's own scroll drives it. Once the list is at the very top, further + // DOWNWARD drag is overscroll — it comes back to us as leftover in the nested-scroll connection below, + // rides the card down and fades the detail. When the list can still scroll, the drag scrolls it (so + // dragging anywhere — including over the card — is never dead space). Release past the threshold + // collapses (resuming from the finger via releasedOffset), release short springs back. + val pullDistancePx = with(LocalDensity.current) { PullFullDistance.toPx() } + var pullPx by remember { mutableFloatStateOf(0f) } + var releasedOffset by remember { mutableFloatStateOf(0f) } + val pullScope = rememberCoroutineScope() + val onPullEnd = { + if (pullPx >= pullDistancePx * PullCloseFraction) { + releasedOffset = pullPx + pullPx = 0f + onCollapse() + } else if (pullPx > 0f) { + pullScope.launch { animate(pullPx, 0f) { v, _ -> pullPx = v } } + } + } + // The overlay/real-card handoff pivots on this: the flying overlay card is only shown while the list + // is at the very TOP (where the flight happens and a pull-to-close lives, and where it's coincident + // with the real card's slot). The instant the list scrolls, we hand off to the real in-list card so it + // scrolls NATIVELY under the app bar (full fling, no overlay tracking to jank or halt at the bar). + // Read live in deferred layers so the swap costs no recomposition. + val atTop = { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + // Pull-to-close: while the list is pinned at the top, leftover DOWNWARD drag (overscroll) rides the + // card down and fades the detail; dragging back up unwinds it; release past the threshold collapses. + // Normal scrolling is untouched (native fling), so this only engages as a genuine top-overscroll — + // which in this unified card+content scroll simply IS pulling the card down. + val pullConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (pullPx > 0f && available.y < 0f && source == NestedScrollSource.UserInput) { + val raw = minOf(-available.y, pullPx / PullResistance) + pullPx = (pullPx - raw * PullResistance).coerceAtLeast(0f) + return Offset(0f, -raw) + } + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (available.y > 0f && source == NestedScrollSource.UserInput) { + pullPx = (pullPx + available.y * PullResistance).coerceAtLeast(0f) + return Offset(0f, available.y) + } + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (pullPx > 0f) { + onPullEnd() + return available + } + return Velocity.Zero + } + } + } + + // Publish the hero's live pull translation so the deck's own card coincides with it (no second card). + // Do this from a snapshotFlow collector, NOT by reading progress/pullPx in the composition body — those + // are per-frame animating states, so reading them here would recompose this whole (large) overlay on + // every animation frame, loading UI-thread work onto exactly the frames that must stay cheap (the + // settle). That dropped the settle frame and read as a "snap" (and it's CPU-bound, so it showed up + // even on a software-GPU emulator). snapshotFlow observes them off the composition — no recomposition. + LaunchedEffect(controller) { + snapshotFlow { + pullPx + releasedOffset * CardExpansionController.heroProgress(controller.progress.value) + }.collect { controller.pullOffset = it } + } + + Box(modifier = modifier.fillMaxSize()) { + // Only the card rides the overscroll down; the detail + chrome stay put and just fade — faster + // than the pull travels, so the screen is mostly gone by the time the card is barely moved (iOS). + // Fade off the SAME effective offset the card rides — pullPx while dragging, then releasedOffset*p + // once released — so committing a dismiss doesn't zero pullPx and snap the detail back to full + // opacity (a flash) before the collapse re-fades it; it stays faded continuously through release. + val pullFade = { + val effective = pullPx + + releasedOffset * CardExpansionController.heroProgress(controller.progress.value) + (1f - effective / (pullDistancePx * PullFadeFraction)).coerceIn(0f, 1f) + } + // The detail BACKGROUND ramps to opaque FAST at the start of phase B (≈first third), so it covers + // the compressed deck before the translucent Give/Convert/Withdraw tiles fade in over it — otherwise + // the squeezed deck bleeds through those tiles. It also stays opaque through a pull so the wallet + // never peeks through while the card is dragged down. + val bgAlpha = { + (CardExpansionController.detailProgress(controller.progress.value) * 3f).coerceIn(0f, 1f) + } + // The detail CONTENT + chrome fade in at the slower phase-B pace (and additionally fade with the + // pull), appearing over the already-opaque background rather than over the deck. + val detailAlpha = { + CardExpansionController.detailProgress(controller.progress.value) * pullFade() + } + val heroP = { CardExpansionController.heroProgress(controller.progress.value) } + + // Detail background — fades in during phase B (transparent early so the reorganising deck shows). + // ModulateAlpha: a solid fill needs no offscreen layer to fade, so skip the per-frame 1440×3120 + // saveLayer (and its alloc/free at the settle) that CompositingStrategy.Auto would incur. + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = bgAlpha() + compositingStrategy = CompositingStrategy.ModulateAlpha + } + .background(CodeTheme.colors.background), + ) + + // Detail content. The nested-scroll connection turns top-overscroll into the pull-to-close; + // normal scrolling stays native (fling). The hero item is the REAL card, shown once the list + // scrolls (see heroPlaceholderAlpha) so it scrolls under the app bar natively; while pinned at + // the top it stays invisible and the flying overlay card (below) stands in. + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(pullConnection) + // ModulateAlpha: the content is opaque cards/text over the already-opaque background, so + // per-op alpha modulation looks identical to compositing the whole list as one layer — but + // it avoids rendering the entire scrolling list (incl. the market-cap chart) into a + // full-screen offscreen buffer every frame of the fade, which was the dominant transition + // GPU cost and the source of the dropped settle frame ("snap") on high-refresh panels. + .graphicsLayer { + alpha = detailAlpha() + compositingStrategy = CompositingStrategy.ModulateAlpha + }, + ) { + CurrencyInfoContentV2( + shortfall = null, + state = state, + listState = listState, + contentPadding = PaddingValues( + top = statusBar + appBarHeight + heroTopGap, + bottom = bottomInset + CodeTheme.dimens.grid.x8, + ), + hazeState = hazeState, + heroAsPlaceholder = true, + // Hidden while pinned at the top (the flying overlay card stands in there); shown the + // instant the list scrolls, so the real card carries the scroll natively. The overlay and + // this card are coincident at offset 0, so the swap is invisible. Read in a deferred layer. + heroPlaceholderAlpha = { if (atTop()) 0f else 1f }, + // Latch the hero's fly target on the first measurement and hold it — a stable target for the + // flight (the overlay only ever flies to/pulls from the top slot; scrolling is the real card). + onHeroBounds = { + if (heroTarget == null) { + heroTarget = it + controller.reportHeroBounds(it) + } + }, + dispatch = viewModel::dispatchEvent, + ) + } + + // The flying hero card — from the tapped deck slot to its expanded frame, staying opaque. + // Only draw it once the reused VM's state has actually re-pointed to THIS mint. Until then + // state still carries the previously-opened card's token/balance, and drawing it would flash + // that stale card — worse, its balance would visibly roll from the old amount to the new one + // (TokenCard renders the amount with AnimatedNumberText). During that brief stale window we're + // at progress ≈ 0, where the deck's own (coincident) card stands in, so skipping the overlay + // hero here is invisible — the correct-mint hero simply appears a frame or two into the flight. + val token = (state.token as? Loadable.Loaded)?.data?.takeIf { it.address == mint } + val source = controller.sourceBounds + // Prefer the surviving controller bounds so a torn-down/re-inflated overlay (returning from a + // pushed action) can draw the hero before its own placeholder re-measures. + val target = heroTarget ?: controller.heroBounds + if (token != null && source != null && target != null && target.width > 0f) { + val density = LocalDensity.current + val isHeld = state.showTransactionHistory || state.balance.nativeAmount.isPositive + val appreciationText = state.appreciation + ?.takeIf { isHeld && state.showAppreciation && it != LocalFiat.MIN_VALUE } + ?.nativeAmount + ?.formattedAppreciation() + Box( + modifier = Modifier + .offset { IntOffset(target.left.roundToInt(), target.top.roundToInt()) } + .size(with(density) { target.width.toDp() }, with(density) { target.height.toDp() }) + .graphicsLayer { + // A single opaque card — no offscreen needed to fade it as it crossfades with the + // deck's own card, so modulate alpha per-op instead of compositing a layer. + compositingStrategy = CompositingStrategy.ModulateAlpha + val p = heroP() + transformOrigin = TransformOrigin(0f, 0f) + val scale = lerp(source.width / target.width, 1f, p) + scaleX = scale + scaleY = scale + translationX = (source.left - target.left) * (1f - p) + // The card is the only thing that rides the pull-to-close overscroll down; on + // release the offset is carried home by the collapse (releasedOffset * p). + translationY = (source.top - target.top) * (1f - p) + pullPx + releasedOffset * p + // Shown while the list is at the top (flight, settle, and pull all happen there); + // the instant the list scrolls we hand off to the coincident real in-list card, so + // this overlay hides and never fights native scroll. + // + // Opacity ramps to FULL by [HeroPhase] and holds — it is NOT a plain `p` fade, and + // NOT a flat 1. Both extremes break one end of the transition: + // • A plain `alpha = p` DIMS the card on OPEN: the detail background ramps opaque + // just after [HeroPhase] and covers the deck's own card (the opaque backing this + // hero rode on top of), after which the only card left is this hero at p<1 over a + // dark background — a ~10% dim that recovers at p=1 (a one-time flicker). + // • A flat `alpha = 1` fixes that but SNAPS the z-order on CLOSE: this overlay draws + // above the whole deck, so a fully-opaque hero sits ON TOP of its neighbours the + // entire way down and only yields to the natural-z (under-neighbour) deck card when + // the overlay clears at p=0 — the card pops from above the stack to behind it in one + // frame. + // Ramping to 1 by [HeroPhase] gives both: opaque before the background covers the + // backing (no open dim), and — since the hero is pixel-coincident with the deck's + // identical card — a fade back through the low-p range on close, where the card + // descends among its neighbours, so the hand-off to the natural-z card dissolves. + alpha = if (atTop()) (p / CardExpansionController.HeroPhase).coerceIn(0f, 1f) else 0f + }, + ) { + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = Modifier.fillMaxSize(), + ) + } + } + + // Chrome (top scrim + frosted app bar: close, title pill, share), fading with the detail. + // ModulateAlpha: no offscreen layer for the fade (the chrome doesn't self-overlap), so no + // per-frame buffer alloc/free at the settle. + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = detailAlpha() + compositingStrategy = CompositingStrategy.ModulateAlpha + }, + ) { + // Soft top fade so content dims as it scrolls up under the app bar (matches the pushed screen). + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .height(statusBar + appBarHeight * 0.6f) + .background( + Brush.verticalGradient( + 0f to CodeTheme.colors.background, + 1f to Color.Transparent, + ), + ), + ) + // The same frosted app bar the pushed token-info screen uses: leading close (✕), a title pill + // that reveals on scroll, and a share action — all liquid-glass, sampling the detail via haze. + Box( + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding(), + ) { + AppBarWithTitle( + titleContent = { + token?.let { + CurrencyInfoTitlePill( + token = it, + marketCap = state.marketCap, + progress = pillProgress, + hazeState = hazeState, + ) + } + }, + titleAlignment = Alignment.Start, + onBackIconClicked = onCollapse, + leadingDismiss = true, + hazeState = hazeState, + endContent = { + if (!state.isCashReserve) { + AppBarDefaults.Share(hazeState = hazeState) { + viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) + } + } + }, + ) + } + } + } +} + +/** Drag distance at which the pull-to-close reaches full progress. */ +private val PullFullDistance = 260.dp + +/** How far the card trails the finger during the pull (1 = point-per-point). */ +private const val PullResistance = 0.5f + +/** Fraction of [PullFullDistance] past which releasing commits the collapse (else it springs back). */ +private const val PullCloseFraction = 0.55f + +/** Fraction of [PullFullDistance] over which the detail content fades fully out (fast, iOS-like). */ +private const val PullFadeFraction = 0.4f diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 723c3594f3..3b2ca08259 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -69,6 +69,7 @@ fun TokenInfoScreen( mint: Mint, shortFall: Fiat?, fromDeeplink: Boolean, + asPush: Boolean = false, ) { val navigator = LocalCodeNavigator.current val analytics = rememberAnalytics() @@ -117,6 +118,10 @@ fun TokenInfoScreen( }, titleAlignment = if (isNewUi) Alignment.Start else Alignment.CenterHorizontally, onBackIconClicked = { navigator.pop() }, + // v2 currency-info is a modal dismiss, not a true back nav — lead with a close (✕). But when + // it was PUSHED onto the stack (e.g. drilled into from token discovery) it IS a back nav, so + // lead with a back arrow instead. + leadingDismiss = isNewUi && !asPush, hazeState = haze, endContent = { state.token.dataOrNull?.let { diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt index 54095b8674..81e0a438c0 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/CurrencyInfoContentV2.kt @@ -1,5 +1,8 @@ package com.flipcash.app.tokens.internal.components.info +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -27,16 +30,21 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.ArrowUpward -import androidx.compose.material.icons.outlined.SwapVert import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -46,10 +54,13 @@ import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.money.formattedAppreciation import com.flipcash.app.core.ui.TokenCard import com.flipcash.app.core.ui.TokenIcon +import com.flipcash.app.core.ui.transitions.CardExpandTransition +import com.flipcash.app.core.ui.transitions.SharedTransition +import com.flipcash.app.core.ui.transitions.sharedBoundsTransition import com.getcode.opencode.model.financial.Token import com.flipcash.app.tokens.ui.TokenInfoViewModel import com.flipcash.features.tokens.R -import com.flipcash.shared.transactionhistory.ActivityFeedRow +import com.flipcash.shared.transactionhistory.recentActivitySection import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SocialLink @@ -78,6 +89,14 @@ internal fun CurrencyInfoContentV2( listState: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(), hazeState: HazeState? = null, + // Overlay (card-expand) presentation: the hero is drawn by the expansion host (flying from the deck + // slot), so here the hero item only RESERVES its slot and reports its window bounds as the fly target. + // Its alpha is driven by [heroPlaceholderAlpha]: hidden (0) while the list is pinned at the top (the + // flying overlay card stands in there), shown (1) once the list scrolls so THIS real card carries the + // scroll natively under the app bar. Default (pushed/deeplink presentation) draws the hero normally. + heroAsPlaceholder: Boolean = false, + heroPlaceholderAlpha: () -> Float = { 0f }, + onHeroBounds: (Rect) -> Unit = {}, dispatch: (TokenInfoViewModel.Event) -> Unit, ) { val inset = CodeTheme.dimens.inset @@ -149,16 +168,48 @@ internal fun CurrencyInfoContentV2( ?.nativeAmount ?.formattedAppreciation() - TokenCard( - token = token, - balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", - displayName = token.name, - appreciationText = appreciationText, - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = inset) - .padding(top = grid.x2), - ) + val heroModifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = inset) + .padding(top = grid.x2) + + if (heroAsPlaceholder) { + // Reserve the slot, report its window bounds as the fly target, and reveal this + // real card once the list scrolls (the flying overlay hides then — they're + // coincident at the top, so the swap is invisible). Deferred alpha = no recompose. + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = heroModifier + .onGloballyPositioned { onHeroBounds(it.boundsInWindow()) } + .graphicsLayer { alpha = heroPlaceholderAlpha() }, + ) + } else { + // Pushed/deeplink presentation: fly from the tapped wallet deck card (same mint + // key), overlay-hosted while opening and in-layer while closing. + val heroInOverlay = if (LocalInspectionMode.current) { + true + } else { + LocalNavAnimatedContentScope.current.transition.targetState != + EnterExitState.PostExit + } + TokenCard( + token = token, + balanceText = if (isHeld) state.balance.nativeAmount.formatted() else "", + displayName = token.name, + appreciationText = appreciationText, + modifier = heroModifier + .sharedBoundsTransition( + key = SharedTransition.TokenCard(token.address).key, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = CardExpandTransition.boundsTransform, + renderInOverlayDuringTransition = heroInOverlay, + ), + ) + } } // 2. Action tiles row @@ -169,53 +220,28 @@ internal fun CurrencyInfoContentV2( .padding(horizontal = inset) .padding(top = grid.x3), isHeld = isHeld, - isUsdf = isUsdf, tokenMint = token.address, shortfall = shortfall, dispatch = dispatch, ) } - // 3. Recent transactions (only when held and non-empty) + // 3. Recent transactions (only when held and non-empty) — shared with the wallet screen. if (isHeld && state.transactions.isNotEmpty()) { - item { - Row( - modifier = Modifier - .fillParentMaxWidth() - .clickable { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Token.Transactions(token.address) - ) + recentActivitySection( + transactions = state.transactions, + modifier = Modifier + .clickable { + dispatch( + TokenInfoViewModel.Event.OpenScreen( + AppRoute.Token.Transactions(token.address) ) - } - .padding(horizontal = inset) - .padding(top = grid.x5, bottom = grid.x1), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.title_recentActivity), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = CodeTheme.colors.textSecondary, - ) - } - } - - items(state.transactions, key = { it.id }) { item -> - ActivityFeedRow( - item = item, - modifier = Modifier - .fillParentMaxWidth() - .padding(horizontal = inset), - ) - } + ) + } + .padding(horizontal = inset) + .padding(top = grid.x5, bottom = grid.x1), + itemPadding = PaddingValues(horizontal = inset), + ) } // 4. Market cap (non-USDF only) @@ -232,6 +258,8 @@ internal fun CurrencyInfoContentV2( marketCap = mcap, selectedPeriod = state.selectedPeriod, rawHistoricalData = historicalData, + // New v2 UI: no chart draw-in on open (it appears with the card-expand). + animateChartOpen = false, onRetry = { dispatch( TokenInfoViewModel.Event.LoadHistoricalDataForPeriod( @@ -299,7 +327,6 @@ internal fun CurrencyInfoContentV2( @Composable private fun CurrencyActionTiles( isHeld: Boolean, - isUsdf: Boolean, tokenMint: Mint, shortfall: Fiat?, dispatch: (TokenInfoViewModel.Event) -> Unit, @@ -327,51 +354,16 @@ private fun CurrencyActionTiles( ) } - isUsdf -> { - // Held USDF: Convert + Withdraw (no Give) - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_convert), - icon = { - Icon( - imageVector = Icons.Outlined.SwapVert, - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { dispatch(TokenInfoViewModel.Event.OnBuy(shortfall)) }, - ) - ActionTile( - modifier = Modifier.weight(1f), - label = stringResource(R.string.action_withdraw), - icon = { - Icon( - imageVector = Icons.Outlined.ArrowUpward, - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - ) - }, - onClick = { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Transfers.Withdrawal(showOtherOptions = false) - ) - ) - }, - ) - } - else -> { - // Held non-USDF: Give + Convert + Withdraw + // Held (incl. USDF/Dollars in v2): Give + Convert + Withdraw ActionTile( modifier = Modifier.weight(1f), label = stringResource(R.string.action_give), icon = { - Image( - painter = painterResource(R.drawable.ic_cash_bill), + Icon( + painter = painterResource(R.drawable.ic_banknote), contentDescription = null, + tint = CodeTheme.colors.textMain, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), ) }, @@ -388,7 +380,7 @@ private fun CurrencyActionTiles( label = stringResource(R.string.action_convert), icon = { Icon( - imageVector = Icons.Outlined.SwapVert, + painter = painterResource(R.drawable.ic_convert), contentDescription = null, tint = CodeTheme.colors.textMain, modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), @@ -530,8 +522,12 @@ internal fun CurrencyInfoTitlePill( .graphicsLayer { alpha = progress } .then(fill) .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1, + // Extra trailing room after the name/market-cap so the capsule breathes on the right + // like iOS (the leading side is tighter — the icon sits close to the edge). + start = CodeTheme.dimens.grid.x2, + end = CodeTheme.dimens.grid.x3, + top = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x1, ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt index 6e731493f3..a332e0eccb 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/info/MarketCapSection.kt @@ -75,6 +75,7 @@ internal fun MarketCapSection( selectedPeriod: Period, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), + animateChartOpen: Boolean = true, onRetry: () -> Unit, onPeriodSelected: (Period) -> Unit ) { @@ -189,6 +190,7 @@ internal fun MarketCapSection( modifier = Modifier .fillMaxWidth() .requiredHeight(240.dp), + animateOpen = animateChartOpen, chartPadding = PaddingValues(end = contentPadding.calculateEndPadding()), periodPadding = PaddingValues( start = contentPadding.calculateStartPadding(), diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt index 4adf85c398..2e4bcee993 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/components/marketcap/MarketCapChart.kt @@ -75,6 +75,7 @@ internal fun MarketCapChart( placeholder: @Composable BoxScope.() -> Unit = {}, chartPadding: PaddingValues = PaddingValues(), periodPadding: PaddingValues = PaddingValues(), + animateOpen: Boolean = true, onPointHighlighted: (MarketCapPoint?) -> Unit, onPeriodSelected: (Period) -> Unit, ) { @@ -102,14 +103,18 @@ internal fun MarketCapChart( } } - // Update the model when the window changes - LaunchedEffect(windowedData) { - if (windowedData.isNotEmpty()) { + // Update the model only when the DATASET or period changes — deliberately NOT on every [currentValue] + // tick. `windowedData` folds the live current value into its last point, so keying on it re-ran the + // transaction as the market cap settled async, snapping the line's end point repeatedly (the "jitter" + // at the end of the open). Keying on the stable inputs renders the line once, settled. + LaunchedEffect(historicalData, dataPeriod) { + val window = windowedData + if (window.isNotEmpty()) { modelProducer.runTransaction { lineModel { series( - x = windowedData.indices.map { it.toDouble() }, - y = windowedData.map { it.y }, + x = window.indices.map { it.toDouble() }, + y = window.map { it.y }, ) } } @@ -129,6 +134,7 @@ internal fun MarketCapChart( modifier = modifier, chartPadding = chartPadding, periodPadding = periodPadding, + animateOpen = animateOpen, onPeriodSelected = onPeriodSelected, onPointHighlighted = { target -> val datum = windowedData.getOrNull(target?.x?.toInt() ?: -1) @@ -147,6 +153,7 @@ private fun MarketCapChart( placeholder: @Composable BoxScope.() -> Unit = {}, chartPadding: PaddingValues = PaddingValues(), periodPadding: PaddingValues = PaddingValues(), + animateOpen: Boolean = true, onPointHighlighted: (CartesianMarker.Target?) -> Unit, onPeriodSelected: (Period) -> Unit, ) { @@ -162,6 +169,7 @@ private fun MarketCapChart( .weight(1f) .testTag("market_cap_chart"), trend = trend, + animateOpen = animateOpen, onPointHighlighted = onPointHighlighted, placeholder = placeholder, ) @@ -220,6 +228,7 @@ private fun MarketCapChartContent( trend: LineTrend, modifier: Modifier = Modifier, placeholder: @Composable BoxScope.() -> Unit, + animateOpen: Boolean = true, onPointHighlighted: (CartesianMarker.Target?) -> Unit ) { val trendColor = trend.color @@ -323,7 +332,9 @@ private fun MarketCapChartContent( modifier = modifier, chart = chart, modelProducer = producer, - animationSpec = tween(durationMillis = 300), + // New UI (card-expand overlay) opens with its own transition, so the chart's draw-in animation + // is suppressed there — it just appears with the rest of the detail instead of sweeping up. + animationSpec = if (animateOpen) tween(durationMillis = 300) else null, scrollState = rememberVicoScrollState(scrollEnabled = false), placeholder = placeholder ) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt new file mode 100644 index 0000000000..5f80524245 --- /dev/null +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/RecentActivitySection.kt @@ -0,0 +1,74 @@ +package com.flipcash.shared.transactionhistory + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.getcode.theme.CodeTheme +import com.getcode.util.resources.R + +/** + * The "Recent" activity section — a tappable header (label + trailing chevron) followed by the list of + * [ActivityFeedRow]s — shared by the wallet screen and the token-info screen so both stay consistent + * (chevron sits right after the label, same row spacing and item rendering). + * + * It's a [LazyListScope] extension so callers drop it straight into their own `LazyColumn`. Screen-specific + * chrome (title text/style, click target, and horizontal insets) is supplied by the caller; the internal + * layout of the header and rows is owned here. + * + * @param modifier caller-owned modifier for the header row (padding + `clickable`); full-width is applied + * internally. + * @param itemPadding padding applied to each activity row (e.g. the screen's horizontal inset). + */ +fun LazyListScope.recentActivitySection( + transactions: List, + modifier: Modifier = Modifier, + itemPadding: PaddingValues = PaddingValues(), +) { + item(key = "recent_activity_header", contentType = "recent_activity_header") { + RecentActivityHeader(modifier = modifier) + } + items(transactions, key = { it.id }, contentType = { "recent_activity_row" }) { item -> + ActivityFeedRow( + item = item, + modifier = Modifier + .fillMaxWidth() + .padding(itemPadding), + ) + } +} + +@Composable +private fun LazyItemScope.RecentActivityHeader(modifier: Modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(com.flipcash.core.R.string.title_recentActivity), + style = CodeTheme.typography.screenTitle, + color = CodeTheme.colors.textMain, + ) + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/ui/BillCustomization.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/ui/BillCustomization.kt index d8ec2c2b29..2c8f156724 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/ui/BillCustomization.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/ui/BillCustomization.kt @@ -59,6 +59,15 @@ sealed interface BillBackground : Parcelable { } } } + + companion object { + /** + * USDF / "Dollars" fixed gold branding (Figma) — the single source of truth shared by the + * wallet card and the full-screen cash bill, so the two always match. USDF carries no + * user-chosen bill colors, so both surfaces fall back to this gradient. + */ + val Usdf: Gradient = Gradient(listOf("#C4980B", "#B06B00")) + } } @Serializable diff --git a/settings.gradle.kts b/settings.gradle.kts index ee0056f8c1..73dea3dcf8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,6 +44,7 @@ include( // flipcash modules ":apps:flipcash:core", ":apps:flipcash:core-ui", + ":apps:flipcash:card-expand", ":libs:test-utils", // shared flipcash coordinators/controllers/viewmodels/services ":apps:flipcash:shared:amount-entry", diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index c12ae2269f..be4651f45f 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -194,6 +194,10 @@ fun AppBarWithTitle( titleAlignment: Alignment.Horizontal = Alignment.Start, contentPadding: PaddingValues = AppBarDefaults.ContentPadding, onBackIconClicked: (() -> Unit)? = null, + // Force the leading control to be a Close (✕) in the LEADING (left) slot rather than a back arrow — + // for screens that are a modal dismiss, not a true back nav (e.g. currency-info). This differs from + // the sheet-root Close, which sits on the trailing edge. + leadingDismiss: Boolean = false, hazeState: HazeState? = null, endContent: @Composable RowScope.() -> Unit = { }, ) { @@ -213,14 +217,21 @@ fun AppBarWithTitle( !navigator.isFlowNavigator && navigator.backStack.size <= 1 val showBack = onBackIconClicked != null - val showClose = showBack && (flowDismissStyle == FlowDismissStyle.Close || isSheetRoot) + // A trailing-edge Close for sheet roots / flows that opted in — suppressed when the caller asks for a + // leading Close, which owns the dismiss instead. + val showClose = showBack && !leadingDismiss && + (flowDismissStyle == FlowDismissStyle.Close || isSheetRoot) TopAppBarBase( modifier = modifier, contentPadding = contentPadding, leftIcon = { - if (showBack && !showClose) { - AppBarDefaults.UpNavigation(hazeState = hazeState) { onBackIconClicked() } + if (showBack) { + if (leadingDismiss) { + AppBarDefaults.Close(hazeState = hazeState) { onBackIconClicked() } + } else if (!showClose) { + AppBarDefaults.UpNavigation(hazeState = hazeState) { onBackIconClicked() } + } } }, titleRegion = { From d768d5be3de47284c885be420e8546bb5d2cad1a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 17:02:46 -0400 Subject: [PATCH 5/8] fix(ui): centre the amount-entry title pill in the app bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The give/chat amount-entry screens pass a fillMaxWidth() token pill as the app bar title but left titleAlignment at its Start default. That used to look centred only by accident: the phantom leading slot was measured unconditionally, so leftIconWidth was never 0 and a Start title got pushed right by roughly a back button's width. Reserving that phantom width is now conditional on the title actually being centred, so with no leading control leftIconWidth is 0 and the title snaps flush to the inset — on the give screen the pill sat 64px left of centre (content centred at 476 against a 540 screen centre). Declare the centring instead of leaning on the leading slot's width. The pill now centres at exactly 540 with or without a back arrow. --- .../cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt | 5 +++++ .../messenger/internal/screens/cash/ChatAmountEntryScreen.kt | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt index e45f910b0b..2e29c07117 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt @@ -52,6 +52,11 @@ fun CashScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithTitle( + // The pill is meant to sit centred in the bar. Say so explicitly rather than relying on + // the leading slot's width to nudge a Start-aligned title into place — an empty leading + // slot no longer reserves a phantom control's width, so a Start title sits flush at the + // inset and the pill drifted left of centre whenever there was no back arrow. + titleAlignment = Alignment.CenterHorizontally, title = { TokenSelectionPill( modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt index 84f5860ff5..da83e3ea6e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/ChatAmountEntryScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose @@ -60,6 +61,9 @@ internal fun ChatAmountEntryContent( onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) }, appBar = { AppBarWithTitle( + // Same centred pill as the give screen — declare the centring rather than leaning on + // the leading slot's width to position a Start-aligned title. + titleAlignment = Alignment.CenterHorizontally, title = { TokenSelectionPill( modifier = Modifier From 098d49ad7cf4ee2c1a5b25a34c9be5411bfed8c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 17:05:40 -0400 Subject: [PATCH 6/8] feat(menu): make the menu the v2 "You" tab, fix NewUi leaking into v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 "You" tab is now the menu surface: the viewer's own tip card (tappable to present full screen) plus "Share as a Link" above the settings list, with the version footer scrolling in the footer slot. v1 keeps the Settings sheet — title, close, Add Money / Withdraw tiles and a pinned footer. Collect the NewUi flag reactively instead of snapshotting it. observe() is a StateFlow seeded with the flag's DEFAULT (NewUi defaults to true) until DataStore emits the stored value, so `remember { observe(flag).value }` captured that default on first composition and froze it — a v1 build rendered the v2 "You" screen (no close, no money tiles) because the read never saw the stored false. The app root already collected it properly, which is why only these screens disagreed with the rest of the app. Same fix applied to the tip card, tips and token-info screens, which shared the pattern. The tip card is now owned by the You tab, so the v2 TipCard step is just the post-profile-setup landing (full-bleed, no settings hamburger) and the "You" tab routes to the menu. Tip-code preview rendering moves to :shared:bills so the menu can warm the share preview without depending on the tipping feature. Also drops the GiveUsdf flag — USDF is giveable via the v2 currency-info tiles, and the legacy reserve layout offers Withdraw + Deposit only. --- .../kotlin/com/flipcash/app/FlipcashApp.kt | 2 +- .../com/flipcash/app/core/bill/Scannable.kt | 31 +-- .../app/core/navigation/NavBarRoutes.kt | 11 +- .../core/src/main/res/values/strings.xml | 2 + .../flipcash/app/core/bill/ScannableTest.kt | 4 +- .../app/core/navigation/NavBarRoutesTest.kt | 43 ++++ apps/flipcash/features/menu/build.gradle.kts | 4 + .../app/menu/internal/MenuScreenContent.kt | 203 ++++++++++++++---- .../app/menu/internal/MenuScreenViewModel.kt | 46 ++++ .../com/flipcash/app/tipping/TipCardScreen.kt | 35 +-- .../com/flipcash/app/tipping/TipsScreen.kt | 4 +- .../app/tipping/internal/TipFlowViewModel.kt | 2 +- .../flipcash/app/tokens/TokenInfoScreen.kt | 4 +- .../app/tokens/internal/TokenInfoScreen.kt | 23 +- .../app/bills/components/bills/CashBill.kt | 13 +- .../app/bills/decor/ScannableDecorator.kt | 10 +- .../share/ComposeTipCodePreviewRenderer.kt | 2 +- .../app/bills}/share/TipCodePreviewCache.kt | 2 +- .../app/bills}/share/TipCodePreviewModule.kt | 2 +- .../app/bills}/share/TipCodeShareCard.kt | 2 +- .../flipcash/app/featureflags/FeatureFlag.kt | 12 -- .../flipcash/app/session/SessionController.kt | 7 + .../session/internal/RealSessionController.kt | 6 +- .../delegates/BillPresentationDelegate.kt | 9 + .../internal/delegates/TipCardDelegate.kt | 4 + .../flipcash/app/tokens/TokenCoordinator.kt | 19 +- .../app/tokens/ui/SelectTokenViewModel.kt | 3 +- .../app/tokens/ui/TokenInfoViewModel.kt | 24 +-- 28 files changed, 372 insertions(+), 157 deletions(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt rename apps/flipcash/{features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal => shared/bills/src/main/kotlin/com/flipcash/app/bills}/share/ComposeTipCodePreviewRenderer.kt (99%) rename apps/flipcash/{features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal => shared/bills/src/main/kotlin/com/flipcash/app/bills}/share/TipCodePreviewCache.kt (98%) rename apps/flipcash/{features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal => shared/bills/src/main/kotlin/com/flipcash/app/bills}/share/TipCodePreviewModule.kt (90%) rename apps/flipcash/{features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal => shared/bills/src/main/kotlin/com/flipcash/app/bills}/share/TipCodeShareCard.kt (98%) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt index 7c83d517ec..11bd864172 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt @@ -15,7 +15,7 @@ import com.flipcash.app.auth.AuthManager import okio.Path.Companion.toOkioPath import com.flipcash.app.core.android.ActivityProvider import com.flipcash.app.currency.PreferredCurrencyController -import com.flipcash.app.tipping.internal.share.TipCodePreviewCache +import com.flipcash.app.bills.share.TipCodePreviewCache import com.getcode.opencode.repositories.EventRepository import com.getcode.utils.trace import dev.bmcreations.phantom.connect.PhantomSdk diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt index bb34c0302d..a627a21cfe 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/Scannable.kt @@ -4,7 +4,6 @@ import com.flipcash.services.models.UserProfile import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint import kotlin.time.Duration /** @@ -38,7 +37,11 @@ sealed interface Scannable { fun stamped(code: List, nonce: List): Payable companion object { - /** Applies the USDF->[GoldBar] rule; every other token -> [CashBill]. */ + /** + * Every payable token renders as a [CashBill]. USDF used to map to [GoldBar]; it now + * renders as a regular cash bill (painted with its fixed gold gradient — see + * `BillBackground.Usdf`) like every other token. + */ fun forToken( token: Token, amount: LocalFiat, @@ -49,19 +52,11 @@ sealed interface Scannable { kind: Kind = Kind.cash, verifiedState: VerifiedState? = null, nonce: List = emptyList(), - ): Payable = if (token.address == Mint.usdf) { - GoldBar( - token = token, amount = amount, didReceive = didReceive, - disableGestures = disableGestures, confirmationDelay = confirmationDelay, - data = data, kind = kind, verifiedState = verifiedState, nonce = nonce, - ) - } else { - CashBill( - token = token, amount = amount, didReceive = didReceive, - disableGestures = disableGestures, confirmationDelay = confirmationDelay, - data = data, kind = kind, verifiedState = verifiedState, nonce = nonce, - ) - } + ): Payable = CashBill( + token = token, amount = amount, didReceive = didReceive, + disableGestures = disableGestures, confirmationDelay = confirmationDelay, + data = data, kind = kind, verifiedState = verifiedState, nonce = nonce, + ) } } @@ -96,5 +91,11 @@ sealed interface Scannable { data class TipCard( override val data: List, val user: UserProfile, + /** + * True when this is the viewer's *own* tip card, presented for display (e.g. the You tab's + * full-screen card) rather than scanned from someone else. Suppresses the Send-a-Tip modal + * and its add-money prompt — you can't tip yourself. + */ + val isSelf: Boolean = false, ) : Scannable } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt index ed9a64f29a..1af421d265 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt @@ -11,10 +11,10 @@ import com.flipcash.app.core.AppRoute fun NavBarButton.destinationRoute(): AppRoute? = when (this) { NavBarButton.Scanner -> AppRoute.Main.Scanner NavBarButton.Wallet -> AppRoute.Sheets.Wallet - // Both tabs route through the tipping flow, seeded at the right step: the chats list vs the tip - // card (resumed = true lands the flow on TipCard alone). Deeplinks/decorators use the same route. + // The chats tab routes through the tipping flow seeded at the list. NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false) - NavBarButton.TipCard -> AppRoute.Sheets.Tips(resumed = true) + // The "You" tab is the menu (settings) surface, augmented with the tip card + share. + NavBarButton.TipCard -> AppRoute.Sheets.Menu // v1-only buttons never appear in the v2 bar. NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips -> null } @@ -23,7 +23,8 @@ fun NavBarButton.destinationRoute(): AppRoute? = when (this) { fun AppRoute.asNavBarTab(): NavBarButton? = when (this) { AppRoute.Main.Scanner -> NavBarButton.Scanner AppRoute.Sheets.Wallet -> NavBarButton.Wallet - // The tipping flow is home to both tabs; `resumed` distinguishes the tip card from the list. - is AppRoute.Sheets.Tips -> if (resumed) NavBarButton.TipCard else NavBarButton.Chats + // The tipping flow is home to the chats tab (the tip card moved to the You/menu tab). + is AppRoute.Sheets.Tips -> NavBarButton.Chats + AppRoute.Sheets.Menu -> NavBarButton.TipCard else -> null } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b7d2884abd..c9caa95e96 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -195,6 +195,7 @@ Withdraw Withdraw Money + Share as a Link Yes, Withdraw Are You Sure? @@ -767,6 +768,7 @@ The amount you entered is too small to transfer\nPlease enter a larger amount Settings + You Withdraw as USDC Your USDF will be converted 1:1 to Solana USDC on withdrawal diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/bill/ScannableTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/bill/ScannableTest.kt index 38d371be80..46195e8db6 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/bill/ScannableTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/bill/ScannableTest.kt @@ -46,9 +46,9 @@ class ScannableTest { private val nonUsdfMint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") // USDC — not USDF @Test - fun `forToken returns GoldBar for usdf mint`() { + fun `forToken returns CashBill for usdf mint`() { val bill = Scannable.Payable.forToken(token = tokenWith(Mint.usdf), amount = localFiat()) - assertIs(bill) + assertIs(bill) } @Test diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt new file mode 100644 index 0000000000..936f7986d5 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarRoutesTest.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.core.navigation + +import com.flipcash.app.core.AppRoute +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The v2 "You" tab is the menu (settings) surface. Mirrors iOS `YouTabRoutingTests`: the TipCard tab + * routes to the menu, and the menu route belongs to the TipCard tab — the tipping flow is now home + * only to the Chats tab. + */ +class NavBarRoutesTest { + + @Test + fun `the TipCard (You) tab routes to the menu`() { + assertEquals(AppRoute.Sheets.Menu, NavBarButton.TipCard.destinationRoute()) + } + + @Test + fun `the menu route belongs to the TipCard (You) tab`() { + assertEquals(NavBarButton.TipCard, AppRoute.Sheets.Menu.asNavBarTab()) + } + + @Test + fun `the Chats tab still routes through the tipping flow`() { + assertEquals(AppRoute.Sheets.Tips(resumed = false), NavBarButton.Chats.destinationRoute()) + } + + @Test + fun `the tipping flow maps back to the Chats tab, never TipCard`() { + // Even the post-setup resumed form is the Chats tab now — the tip card moved to the You tab. + assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = true).asNavBarTab()) + assertEquals(NavBarButton.Chats, AppRoute.Sheets.Tips(resumed = false).asNavBarTab()) + } + + @Test + fun `v1-only buttons have no v2 destination`() { + assertNull(NavBarButton.Give.destinationRoute()) + assertNull(NavBarButton.Discover.destinationRoute()) + assertNull(NavBarButton.Tips.destinationRoute()) + } +} diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index e745bcafdd..66aece0b5f 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -10,9 +10,13 @@ dependencies { implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:funding")) + implementation(project(":apps:flipcash:shared:session")) + implementation(project(":apps:flipcash:shared:shareable")) + implementation(project(":apps:flipcash:shared:tipping")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) 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 d9fe6a18bf..51d91e6464 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 @@ -1,30 +1,49 @@ package com.flipcash.app.menu.internal +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.bills.ScannableRenderer +import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha +import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.ui.TileButton 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.session.LocalSessionController import com.flipcash.app.updates.LocalAppUpdater 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.ui.components.AppBarDefaults @@ -40,6 +59,12 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val appUpdater = LocalAppUpdater.current + val features = LocalFeatureFlags.current + // v2: this screen is the "You" tab (card + share + settings). v1: it's the Settings sheet. + // Collect, don't snapshot: observe() is a StateFlow seeded with the flag's DEFAULT (NewUi + // defaults to true) until DataStore emits the stored value. Reading `.value` inside a remember + // froze that default, so a v1 build rendered the v2 "You" screen. + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.eventFlow @@ -52,34 +77,15 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { topBar = { AppBarWithTitle( modifier = Modifier.fillMaxWidth(), - title = stringResource(R.string.title_settings), + title = stringResource(if (isNewUi) R.string.title_you else R.string.title_settings), titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close { navigator.hide() } }, + // The You tab is entered by tab selection, so it has no Close; the v1 sheet keeps it. + endContent = { if (!isNewUi) AppBarDefaults.Close { navigator.hide() } }, ) }, bottomBar = { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .noRippleClickable { - viewModel.dispatchEvent(Event.OnVersionInfoClicked) - } - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), - text = stringResource( - R.string.subtitle_appVersionInfoFooter, - state.appVersionInfo.versionName, - state.appVersionInfo.versionCode, - state.releaseTrack, - ), - color = CodeTheme.colors.textSecondary, - style = CodeTheme.typography.textSmall.copy( - textAlign = TextAlign.Center - ), - ) - } + // v1 pins the version footer above the nav bar; v2 scrolls it with the content (footer slot). + if (!isNewUi) VersionFooter(viewModel, state) } ) { padding -> MenuList( @@ -88,34 +94,137 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { .padding(padding), items = state.items, header = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), - ) { - TileButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_addMoney), - icon = painterResource(R.drawable.ic_menu_deposit) - ) { - viewModel.dispatchEvent(Event.PresentDepositOptions) - } - - TileButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_withdrawMoney), - icon = painterResource(R.drawable.ic_menu_withdraw) - ) { - navigator.push(AppRoute.Transfers.Withdrawal()) - } + if (isNewUi) { + YouHeader( + card = state.tipCard, + onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, + ) + } else { + MoneyTiles(viewModel, navigator) } }, + footer = { if (isNewUi) VersionFooter(viewModel, state) }, contentPadding = PaddingValues(top = CodeTheme.dimens.grid.x3), onItemClick = { viewModel.dispatchEvent(it.action) } ) } -} \ No newline at end of file +} + +/** + * The "You" tab header: the viewer's own tip card, tappable to present full screen via the app-root + * bill overlay, plus a "Share as a Link" button. The in-page card fades out while its expanded copy + * is presented in the overlay (opacity, not removal, so nothing reflows on dismiss). + */ +@Composable +private fun YouHeader(card: Scannable.TipCard?, onShare: () -> Unit) { + if (card == null) return + val session = LocalSessionController.current ?: return + val billState by session.billState.collectAsStateWithLifecycle() + val presented = billState.bill is Scannable.TipCard + val cardAlpha by animateFloatAsState( + targetValue = if (presented) 0f else 1f, + animationSpec = tween(durationMillis = 200), + label = "youCardAlpha", + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x6), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5), + ) { + // Static display (no camera behind the card): render it opaque at the design's flattened + // colour rather than the translucent frosted fill. Figma flattens the card to rgb(16,16,17). + CompositionLocalProvider( + LocalTipCardColor provides Color(0xFF101011), + LocalTipCardBaseAlpha provides 1f, + ) { + Box( + modifier = Modifier + .graphicsLayer { alpha = cardAlpha } + .noRippleClickable { session.presentOwnTipCard(card) }, + contentAlignment = Alignment.Center, + ) { + ScannableRenderer(scannable = card, tipCardWidth = 230.dp) + } + } + + Text( + modifier = Modifier + .clip(CircleShape) + .background(CodeTheme.colors.surfaceVariant) + .clickable { onShare() } + .padding( + horizontal = CodeTheme.dimens.grid.x4, + vertical = CodeTheme.dimens.grid.x3, + ), + text = stringResource(R.string.action_shareAsLink), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } +} + +/** v1 Settings-sheet header: the Add Money / Withdraw tiles (removed from the v2 You tab). */ +@Composable +private fun MoneyTiles( + viewModel: MenuScreenViewModel, + navigator: CodeNavigator, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + TileButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_addMoney), + icon = painterResource(R.drawable.ic_menu_deposit) + ) { + viewModel.dispatchEvent(Event.PresentDepositOptions) + } + + TileButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_withdrawMoney), + icon = painterResource(R.drawable.ic_menu_withdraw) + ) { + navigator.push(AppRoute.Transfers.Withdrawal()) + } + } +} + +/** The "Version … • Build …" footer; its repeated tap toggles beta access (see the ViewModel). */ +@Composable +private fun VersionFooter( + viewModel: MenuScreenViewModel, + state: MenuScreenViewModel.State, +) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .noRippleClickable { + viewModel.dispatchEvent(Event.OnVersionInfoClicked) + } + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource( + R.string.subtitle_appVersionInfoFooter, + state.appVersionInfo.versionName, + state.appVersionInfo.versionCode, + state.releaseTrack, + ), + color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.copy( + textAlign = TextAlign.Center + ), + ) + } +} 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 62d06baa8d..b38fa986c6 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 @@ -4,14 +4,18 @@ import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager +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.bill.Scannable import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.core.toast.SystemToastController import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.shareable.Shareable import com.flipcash.app.updates.ReleaseStage import com.flipcash.app.updates.ReleaseStageProvider import com.flipcash.app.userflags.UserFlagsCoordinator @@ -19,12 +23,15 @@ import com.flipcash.features.menu.BuildConfig import com.flipcash.features.menu.R import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager +import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.opencode.managers.MnemonicManager import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest @@ -55,6 +62,10 @@ internal class MenuScreenViewModel @Inject constructor( releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, analytics: FlipcashAnalyticsService, + private val tippingCoordinator: TippingCoordinator, + private val tipCodePreviewCache: TipCodePreviewCache, + private val shareable: ShareSheetController, + private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), @@ -69,6 +80,9 @@ 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, ) sealed interface Event { @@ -83,6 +97,8 @@ internal class MenuScreenViewModel @Inject constructor( data class OpenScreen(val screen: AppRoute) : Event data object OnSwitchAccountsClicked : Event data class OnSwitchAccountTo(val entropy: String): Event + data class OnTipCardPopulated(val card: Scannable.TipCard) : Event + data object ShareTipCard : Event } init { @@ -170,6 +186,31 @@ internal class MenuScreenViewModel @Inject constructor( purchaseMethodController.presentDepositOptions(popToRoot = true) }.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". + userManager.state + .mapNotNull { it.userProfile } + .distinctUntilChanged() + .map { tippingCoordinator.resolveTipCard() } + .onResult(onSuccess = { card -> + dispatchEvent(Event.OnTipCardPopulated(card)) + tippingCoordinator.currentUserId?.let { tipCodePreviewCache.prepare(it, card) } + }) + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { tippingCoordinator.currentUserId } + .map { userId -> + // Title shown above the link, e.g. "Tip Ada" (same label as the card). + val title = stateFlow.value.tipCard?.user?.displayName + ?.let { resources.getString(R.string.label_tipUser, it) } + // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. + shareable.present(Shareable.TipCard(userId, tipCodePreviewCache.get(userId), title)) + } + .launchIn(viewModelScope) } internal companion object { @@ -242,9 +283,14 @@ internal class MenuScreenViewModel @Inject constructor( ) } + is Event.OnTipCardPopulated -> { state -> + state.copy(tipCard = event.card) + } + Event.PresentDepositOptions, Event.CheckForUpdate, Event.OnSwitchAccountsClicked, + Event.ShareTipCard, is Event.OpenScreen, is Event.OnSwitchAccountTo -> { state -> state } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt index f4dcbc2fa2..5b0e868f40 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipCardScreen.kt @@ -1,6 +1,5 @@ package com.flipcash.app.tipping -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,7 +18,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -30,9 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor -import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.featureflags.FeatureFlag @@ -40,46 +34,35 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.tipping.internal.TipFlowViewModel import com.flipcash.app.tipping.internal.TipFlowViewModel.Event import com.flipcash.features.tipping.R -import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton -import com.getcode.ui.core.unboundedClickable import com.getcode.ui.theme.CodeScaffold /** - * The user's own tip card — always a step in the tipping [TippingFlowScreen] flow, so it shares the - * flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]: - * - **v2**: a root tab (the flow seeded at TipCard) — the card centered, with the app menu reachable - * via the hamburger in the top-right. + * The user's own tip card — a step in the tipping [TippingFlowScreen] flow, so it shares the flow's + * [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]: + * - **v2**: the post-profile-setup landing (the flow seeded at TipCard) — the card full-bleed, no + * chrome. The primary home for the card is now the "You" tab (the menu), which also owns settings. * - **v1**: a sheet step — title bar with back, and a Share action. */ @Composable fun TipCardScreen() { val features = LocalFeatureFlags.current - val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until + // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() if (isNewUi) { - val navigator = LocalCodeNavigator.current + // Post-profile-setup landing: the user's newly created tip card, full-bleed. Settings now + // live in the "You" tab (the menu), so this screen no longer carries a settings hamburger. Box(modifier = Modifier.fillMaxSize()) { TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize()) - - Image( - painter = painterResource(R.drawable.ic_home_options), - contentDescription = null, - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(vertical = CodeTheme.dimens.grid.x2) - .padding(horizontal = CodeTheme.dimens.grid.x3) - .clip(CircleShape) - .unboundedClickable { navigator.openAsSheet(AppRoute.Sheets.Menu) }, - ) } } else { val flowNavigator = rememberFlowNavigator() diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt index 4a3a204bf1..06f88e60a6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipsScreen.kt @@ -44,7 +44,9 @@ import com.getcode.ui.theme.CodeScaffold @Composable fun TipsScreen() { val features = LocalFeatureFlags.current - val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until + // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() 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 6bae02ce95..ebc8a01c5b 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 @@ -7,7 +7,7 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable -import com.flipcash.app.tipping.internal.share.TipCodePreviewCache +import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatType diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index 3b2ca08259..1a10d8e5ef 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -77,7 +77,9 @@ fun TokenInfoScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val features = LocalFeatureFlags.current - val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until + // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val listState = rememberLazyListState() // v2: the title is a leading "Liquid Glass" pill that fades in once the hero card's own title has diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt index d24d18e64e..627b0eef49 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenInfoScreen.kt @@ -82,7 +82,9 @@ private fun TokenInfoScreen( dispatch: (TokenInfoViewModel.Event) -> Unit ) { val features = LocalFeatureFlags.current - val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value } + // Collect rather than snapshot `.value` — the flow is seeded with the flag's default until + // DataStore emits, so a remembered read freezes the default (see MenuScreenContent). + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() if (isNewUi) { // v2 hosts its own overlaid app bar (see the outer TokenInfoScreen); content fills behind it, @@ -323,8 +325,6 @@ private fun BottomBarButtons( ) { if (state.isCashReserve) { ReserveButtonOptions( - mint = loadable.data.address, - state = state, dispatch = dispatch, ) @@ -346,27 +346,14 @@ private fun BottomBarButtons( @Composable private fun RowScope.ReserveButtonOptions( - mint: Mint, state: TokenInfoViewModel.State, dispatch: (TokenInfoViewModel.Event) -> Unit ) { val hasBalance = state.balance.nativeAmount.isPositive if (hasBalance) { - if (mint == Mint.usdf && state.canGiveUsdf || mint != Mint.usdf) { - CodeButton( - modifier = Modifier.weight(1f), - buttonState = ButtonState.Filled, - text = stringResource(R.string.action_give), - ) { - dispatch( - TokenInfoViewModel.Event.OpenScreen( - AppRoute.Sheets.Give(mint = mint, fromTokenInfo = true) - ) - ) - } - } - + // USDF/Dollars is only giveable in the new UI (v2 currency-info tiles); the legacy reserve + // layout offers Withdraw + Deposit only. CodeButton( modifier = Modifier.weight(1f), buttonState = ButtonState.Filled20, diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt index 45e562a608..e752ffb9e0 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt @@ -82,6 +82,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.ui.BillBackground import com.getcode.opencode.model.ui.TokenBillCustomizations import com.getcode.opencode.model.ui.BlendMode as PlaygroundBlendMode +import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 import com.getcode.theme.CodeTheme import com.getcode.ui.core.patternBlend @@ -293,11 +294,21 @@ internal fun CashBill( payloadData = payloadData, amount = amount, mint = token.address.base58(), - billCustomizations = token.billCustomizations, + // USDF/"Dollars" carries no user-chosen bill colors; paint it with its fixed gold gradient + // (the same one the wallet card uses) instead of the dark-green fallback. + billCustomizations = if (token.address == Mint.usdf) UsdfBillCustomizations + else token.billCustomizations, modifier = modifier, ) } +/** USDF/"Dollars" bill background — the shared gold gradient, matching the wallet card. */ +private val UsdfBillCustomizations = TokenBillCustomizations( + background = BillBackground.Usdf, + texture = null, + icon = null, +) + @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt index ff5add171c..fc81970526 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/decor/ScannableDecorator.kt @@ -59,11 +59,19 @@ sealed interface ScannableDecorator { /** Resolves the decor that own the below-bill content for [scannable]. */ fun forScannable(scannable: Scannable): ScannableDecorator = when (scannable) { is Scannable.Payable -> PayableDecorator(scannable) - is Scannable.TipCard -> TipCardDecorator(scannable) + // The viewer's own tip card (e.g. the You tab's full-screen present) has no below-bill + // content — no Send-a-Tip modal, no add-money prompt. You can't tip yourself. + is Scannable.TipCard -> if (scannable.isSelf) NoOpScannableDecorator else TipCardDecorator(scannable) } } } +/** A decorator that renders nothing below the bill. Used for display-only scannables. */ +internal data object NoOpScannableDecorator : ScannableDecorator { + @Composable + override fun BoxScope.Content(context: ScannableDecoratorContext) = Unit +} + /** * Container-owned state the decorator read. Rebuilt on each recomposition so decorators gate * their visibility on the live bill without reaching into container internals. diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/ComposeTipCodePreviewRenderer.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/ComposeTipCodePreviewRenderer.kt similarity index 99% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/ComposeTipCodePreviewRenderer.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/ComposeTipCodePreviewRenderer.kt index 366469705f..0352da02b4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/ComposeTipCodePreviewRenderer.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/ComposeTipCodePreviewRenderer.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tipping.internal.share +package com.flipcash.app.bills.share import android.app.Activity import android.content.Context diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewCache.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewCache.kt similarity index 98% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewCache.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewCache.kt index ea97b9d559..622bc98fcc 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewCache.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewCache.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tipping.internal.share +package com.flipcash.app.bills.share import android.content.Context import com.flipcash.app.core.bill.Scannable diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewModule.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewModule.kt similarity index 90% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewModule.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewModule.kt index 5e6080fe4d..422fcbb8b2 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodePreviewModule.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodePreviewModule.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tipping.internal.share +package com.flipcash.app.bills.share import com.flipcash.app.core.share.TipCodePreviewRenderer import dagger.Binds diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodeShareCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodeShareCard.kt similarity index 98% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodeShareCard.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodeShareCard.kt index 2ced9bd4df..fdc2c4772c 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/share/TipCodeShareCard.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/share/TipCodeShareCard.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.tipping.internal.share +package com.flipcash.app.bills.share import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 21ca205beb..2ec50bfcbe 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -100,16 +100,6 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = true } - @FeatureFlagMarker - data object GiveUsdf: FeatureFlag { - override val key: String = "give_usdf_enabled" - override val default: Boolean = false - override val launched: Boolean = false - override val visible: Boolean = true - override val persistLogOut: Boolean = false - override val minTrack: FeatureTrack = FeatureTrack.Production - } - @FeatureFlagMarker data object NavBar : FeatureFlag { override val key: String = "nav_bar_config" @@ -168,7 +158,6 @@ val FeatureFlag<*>.title: String FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ContactPickerMode -> "Contact Picker Mode" FeatureFlag.NavBar -> "Navigation Bar" - FeatureFlag.GiveUsdf -> "Give/Send USDF" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" FeatureFlag.NewUi -> "New UI" @@ -184,7 +173,6 @@ val FeatureFlag<*>.message: String FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" - FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly and give it as cash" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" FeatureFlag.NewUi -> "When enabled, the app will use the tipping first UI" diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index ede1a11ff5..af8328ef6b 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -23,6 +23,13 @@ interface BillOperations { val billState: StateFlow fun showBill(bill: Scannable.Payable) fun dismissBill(action: BillDeterminationResult) + + /** + * Presents the viewer's *own* tip card full screen in the bill container (e.g. from the You tab), + * for display only — no Send-a-Tip modal, no submission. Dismissal reuses the overlay's + * drag-to-dismiss. A no-op if a bill is already showing (re-entrancy guard). + */ + fun presentOwnTipCard(card: Scannable.TipCard) } interface CodeScanOperations { diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 2dfc5d4005..6522289d0c 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -252,11 +252,11 @@ class RealSessionController @Inject constructor( .onEach { enabled -> stateHolder.update { it.copy(vibrateOnScan = enabled) } } .launchIn(scope) - // Re-evaluate on balance changes and on GiveUsdf toggles — hasGiveableBalance() - // filters out USDF when that flag is off. + // Re-evaluate on balance changes and on NewUi toggles — hasGiveableBalance() only counts + // USDF as giveable in the new UI. combine( tokenCoordinator.tokenBalances, - featureFlagController.observe(FeatureFlag.GiveUsdf), + featureFlagController.observe(FeatureFlag.NewUi), ) { _, _ -> tokenCoordinator.hasGiveableBalance() } .distinctUntilChanged() .onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt index 4fb65d8866..15dd13ed12 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -127,6 +127,15 @@ class BillPresentationDelegate @Inject constructor( stateHolder.update { it.copy(billResult = Grabbed) } } + /** + * Presents the viewer's own tip card for display. Flags it [Scannable.TipCard.isSelf] so the + * overlay attaches the no-op decorator (no Send-a-Tip modal / add-money prompt). Reuses + * [presentTipCard]'s single-slot guard as the double-present guard. + */ + override fun presentOwnTipCard(card: Scannable.TipCard) { + presentTipCard(card.copy(isSelf = true)) + } + override fun dismissBill(action: BillDeterminationResult) { scope.launch { stateHolder.update { it.copy(billResult = action) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 6d49b0a0a2..21d78e3d90 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -54,6 +54,10 @@ class TipCardDelegate @Inject constructor( private val inFlight = MutableStateFlow>(emptySet()) override fun resolveTipCard(user: ID) { + // You can't tip yourself: ignore a scanned or deeplinked own tip card. Own-card display + // goes through BillOperations.presentOwnTipCard (the You tab), not this resolve path. + // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. + if (user == tippingCoordinator.currentUserId) return if (!inFlight.add(user)) return scope.launch { 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 3a581257d9..a664bfcedb 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 @@ -123,6 +123,13 @@ class TokenCoordinator @Inject constructor( override fun observeTokenCache(): Flow> = _state.map { it.tokens }.distinctUntilChanged() + /** + * Synchronous, network-free read of the in-memory token cache — used to seed the currency-info + * screen's hero card on the very first frame (so the wallet card-expand shared element has a target + * to fly to) instead of waiting on the async [getTokenMetadata]. + */ + fun cachedToken(mint: Mint): Token? = _state.value.tokens[mint] + val tokenBalances: Flow> = _hydrated .filter { it } .flatMapLatest { @@ -175,8 +182,8 @@ class TokenCoordinator @Inject constructor( } .launchIn(scope) - // Re-evaluate selected token when GiveUsdf flag changes - featureFlags.observe(FeatureFlag.GiveUsdf) + // USDF givability is tied to the new UI; re-evaluate the selected token when it toggles. + featureFlags.observe(FeatureFlag.NewUi) .filter { _hydrated.value } .onEach { ensureValidTokenSelection() } .launchIn(scope) @@ -199,9 +206,8 @@ class TokenCoordinator @Inject constructor( /** Can I hand money to a person right now? */ suspend fun hasGiveableBalance(atLeast: Fiat = Fiat.Zero): Boolean { - // USDF is only giveable when the GiveUsdf flag is on; otherwise a USDF-only - // balance must not count as giveable. - val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf) + // USDF is only giveable in the new UI; otherwise a USDF-only balance must not count. + val canGiveUsdf = featureFlags.get(FeatureFlag.NewUi) val state = _state.value return state.balances.filterKeys { canGiveUsdf || it != Mint.usdf } .values @@ -559,7 +565,8 @@ class TokenCoordinator @Inject constructor( ?.get(mintPreferenceKey) ?.let { Mint(it) } - val canGiveUsdf = featureFlags.get(FeatureFlag.GiveUsdf) + // USDF is a valid selection only in the new UI (where it's giveable). + val canGiveUsdf = featureFlags.get(FeatureFlag.NewUi) val excludedMints = if (!canGiveUsdf) setOf(Mint.usdf) else emptySet() val resolved = resolveTokenSelection( diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 0526beeb66..7c02f5d148 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -94,7 +94,8 @@ class SelectTokenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnRateChanged(it)) } .launchIn(viewModelScope) - featureFlags.observe(FeatureFlag.GiveUsdf) + // USDF givability is tied to the new UI. + featureFlags.observe(FeatureFlag.NewUi) .onEach { dispatchEvent(Event.OnCanGiveUsdf(it)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt index aadecb64e7..a11d56bd18 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/TokenInfoViewModel.kt @@ -5,8 +5,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.tokens.SwapPurpose -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.shareable.ShareSheetController @@ -53,7 +51,6 @@ class TokenInfoViewModel @Inject constructor( private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, private val feedCoordinator: ActivityFeedCoordinator, - features: FeatureFlagController, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -71,7 +68,6 @@ class TokenInfoViewModel @Inject constructor( val descriptionExpanded: Boolean = false, val historicalMarketCapData: Map>> = emptyMap(), val selectedPeriod: Period = Period.All, - val canGiveUsdf: Boolean = false, val fundableBalanceMints: Set = emptySet(), /** Bounded per-token recent activity preview (newest first) for the v2 currency-info screen. */ val transactions: List = emptyList(), @@ -88,7 +84,6 @@ class TokenInfoViewModel @Inject constructor( } sealed interface Event { - data class CanGiveUsdf(val enabled: Boolean): Event data class OnMintProvided(val mint: Mint, val shortFall: Fiat? = null) : Event data class OnTokenChanged(val token: Loadable, val shortFall: Fiat? = null) : Event data class OnMarketCapChanged(val mcap: Fiat?) : Event @@ -115,14 +110,20 @@ class TokenInfoViewModel @Inject constructor( } init { - features.observe(FeatureFlag.GiveUsdf) - .onEach { - dispatchEvent(Event.CanGiveUsdf(it)) - }.launchIn(viewModelScope) - eventFlow .filterIsInstance() - .onEach { dispatchEvent(Event.OnTokenChanged(Loadable.Loading())) } + .onEach { event -> + // Seed the hero card synchronously from the token cache so it's present immediately — + // the wallet card-expand shared element needs a target to fly to. Fall back to Loading + // only when the token isn't cached; the async fetch below refreshes it either way. + val cached = tokenCoordinator.cachedToken(event.mint) + dispatchEvent( + Event.OnTokenChanged( + if (cached != null) Loadable.Loaded(cached) else Loadable.Loading(), + event.shortFall, + ) + ) + } .onEach { tokenCoordinator.getTokenMetadata(it.mint) .onSuccess { result -> @@ -372,7 +373,6 @@ class TokenInfoViewModel @Inject constructor( is Event.LoadHistoricalDataForPeriod -> { state -> state } is Event.Share -> { state -> state } is Event.Exit -> { state -> state } - is Event.CanGiveUsdf -> { state -> state.copy(canGiveUsdf = event.enabled) } } } } From eb0613eb896f30cc26403e45e907ecf4919b48f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 17:25:50 -0400 Subject: [PATCH 7/8] fix(menu,lab): inset the You tab for the tab bar, land on it when enabling v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 tab bar is a hoisted overlay drawn above the nav content, so the You tab's list needs its height as bottom content padding — otherwise the settings rows and version footer scroll under the bar with no way to clear it. Reserve it via LocalTabBarPadding, which the tab-bar inset decorator only makes non-zero for tab homes. No navigationBarsPadding on the v2 footer: the bar measures itself with that padding already in, so applying it again would double-count. Give the footer its own top spacing too — in the footer slot it scrolls with the list and was sitting flush against the last row's divider. Enabling New UI also used to leave the app on whatever the v1 stack was — the settings sheet the toggle lives in — so v2 rendered the menu as a floating sheet instead of its "You" tab. Re-home onto the target shell's landing screen (You tab for v2, scanner for v1) when the flag is toggled. Order matters: reset the stack BEFORE flipping the flag. Resetting after races the shell swap, and an animated sheet dismiss can't survive it at all — the host driving the dismiss is disposed mid-animation, stranding the sheet on screen. Resetting first means the new shell composes against a stack that already makes sense in it. The reset also has to target the real app-root navigator. Inside a sheet, LocalCodeNavigator is the sheet's own navigator, and that one is built without a parent — so rootNavigator resolves back to itself and the reset only cleared the sheet's inner stack (dropping Lab back to Menu, still in the sheet). The sheet scene publishes its host as LocalSheetNavigator; use that when present. --- .../app/lab/internal/LabsScreenContent.kt | 23 +++++++++- .../app/menu/internal/MenuScreenContent.kt | 43 ++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index c9343c1bc6..a4b6e2a33f 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureTrack import com.flipcash.app.featureflags.FlagOption import com.flipcash.app.featureflags.LocalFeatureFlags @@ -36,6 +37,7 @@ import com.flipcash.app.featureflags.message import com.flipcash.app.featureflags.title import com.flipcash.features.lab.R import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.ListItem import com.getcode.ui.components.SettingsSwitchRow @@ -50,6 +52,11 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole val allFlags by betaFlagsController.observe().collectAsStateWithLifecycle() val betaOverride by viewModel.betaOverride.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current + // When this screen is inside a sheet, LocalCodeNavigator is the sheet's OWN navigator — and it's + // created without a parent, so `rootNavigator` would resolve back to itself and any reset would + // only clear the sheet's inner stack. The sheet scene publishes its host (the app-level + // navigator that owns the sheet entry) as LocalSheetNavigator, so prefer that. + val appRootNavigator = LocalSheetNavigator.current ?: navigator.rootNavigator val isStaff by viewModel.isStaff.collectAsStateWithLifecycle() // Keep showing all flags even after toggling off override, until leaving the screen @@ -123,7 +130,21 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole subtitle = feature.flag.message.takeIf { showAllFlags }, checked = feature.enabled ) { - betaFlagsController.set(feature.flag, !feature.enabled) + val enabling = !feature.enabled + // Switching UI shells swaps the whole nav host out from under the current + // stack, and that stack is this settings sheet — meaningless in the other + // shell (v2 would render the menu as a floating sheet instead of its "You" + // tab). Re-home onto the target shell's landing screen first, THEN flip the + // flag: resetting after the flag would race the shell swap, and an animated + // sheet dismiss can't survive it at all (the host that drives the dismiss is + // disposed mid-animation, stranding the sheet). Resetting first means the + // new shell composes against a stack that already makes sense in it. + if (feature.flag == FeatureFlag.NewUi) { + appRootNavigator.replaceAll( + if (enabling) AppRoute.Sheets.Menu else AppRoute.Main.Scanner + ) + } + betaFlagsController.set(feature.flag, enabling) } } 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 51d91e6464..a3e73a51a3 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 @@ -35,6 +35,7 @@ import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.core.ui.TileButton import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags @@ -85,7 +86,15 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { }, bottomBar = { // v1 pins the version footer above the nav bar; v2 scrolls it with the content (footer slot). - if (!isNewUi) VersionFooter(viewModel, state) + if (!isNewUi) { + VersionFooter( + viewModel = viewModel, + state = state, + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + ) + } } ) { padding -> MenuList( @@ -103,8 +112,29 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { MoneyTiles(viewModel, navigator) } }, - footer = { if (isNewUi) VersionFooter(viewModel, state) }, - contentPadding = PaddingValues(top = CodeTheme.dimens.grid.x3), + footer = { + if (isNewUi) { + // Scrolls with the list, so it needs its own breathing room off the last row's + // divider. No navigationBarsPadding here — the reserved tab-bar inset below + // already clears the system bar (the bar measures itself with that padding in). + VersionFooter( + viewModel = viewModel, + state = state, + modifier = Modifier.padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ), + ) + } + }, + // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as + // bottom content padding — the list then scrolls clear of the bar instead of running + // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, + // which is only non-zero for tab homes. v1 has no such bar. + contentPadding = PaddingValues( + top = CodeTheme.dimens.grid.x3, + bottom = LocalTabBarPadding.current.calculateBottomPadding(), + ), onItemClick = { viewModel.dispatchEvent(it.action) } @@ -204,17 +234,16 @@ private fun MoneyTiles( private fun VersionFooter( viewModel: MenuScreenViewModel, state: MenuScreenViewModel.State, + modifier: Modifier = Modifier, ) { - Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = modifier.fillMaxWidth()) { Text( modifier = Modifier .fillMaxWidth() .align(Alignment.Center) .noRippleClickable { viewModel.dispatchEvent(Event.OnVersionInfoClicked) - } - .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x3), + }, text = stringResource( R.string.subtitle_appVersionInfoFooter, state.appVersionInfo.versionName, From b8979de6f22378faa6486f871d4e5d47598ab5a2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 19 Aug 2026 17:36:40 -0400 Subject: [PATCH 8/8] fix(cash): pop the v2 give screen back to currency-info when presenting a bill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 reaches the give screen as a push from currency-info, not as a sheet, so hide() — which only pops when a Sheet is on the stack — left it up once a bill was presented. Pop instead so the bill presents over the currency-info beneath it. v1 still opens give as a sheet and keeps hide(). The pop is untransitioned. The bill overlay and its scrim are drawn PER nav entry, so an animated pop slides the outgoing give entry's copy of them away while the incoming currency-info entry composes its own — which reads as a flash behind the bill. Swapping in a single frame keeps the scrim continuously up. --- .../app/internal/ui/navigation/AppContent.kt | 14 ++++++++++++++ apps/flipcash/features/cash/build.gradle.kts | 1 + .../kotlin/com/flipcash/app/cash/CashScreen.kt | 12 +++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index bf6888e8a5..26f43b3c09 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -57,6 +57,13 @@ private fun isTokenInfoKey(key: Any?): Boolean { return s.startsWith("Info(") && s.contains("mint=") && !s.contains("asPush=true") } +/** + * True when a scene key belongs to [AppRoute.Sheets.Give] (the v2 give/cash screen, which is pushed + * rather than presented as a sheet). Same stringified-key match as [isTokenInfoKey]. + */ +private fun isGiveKey(key: Any?): Boolean = + key?.toString()?.startsWith("Give(") == true + @Composable internal fun AppContent( codeNavigator: CodeNavigator, @@ -247,6 +254,13 @@ internal fun NewAppContent( EnterTransition.None togetherWith ExitTransition.None isTokenInfoKey(initialState.key) -> CardExpandTransition.closeEnter togetherWith CardExpandTransition.closeExit + // Leaving the give screen is untransitioned. It pops once a bill has been + // presented, and the bill overlay + its scrim are drawn PER nav entry — so an + // animated pop would slide the outgoing entry's copy away while the incoming + // currency-info entry composes its own, reading as a flash behind the bill. + // Swapping in a single frame keeps the scrim continuously up. + isGiveKey(initialState.key) -> + EnterTransition.None togetherWith ExitTransition.None else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) diff --git a/apps/flipcash/features/cash/build.gradle.kts b/apps/flipcash/features/cash/build.gradle.kts index d4d70a7f10..06c25c654d 100644 --- a/apps/flipcash/features/cash/build.gradle.kts +++ b/apps/flipcash/features/cash/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:session")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt index 2e29c07117..cfc34e2bae 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/CashScreen.kt @@ -16,6 +16,8 @@ import com.flipcash.app.cash.internal.GiveScreenContent import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.core.ui.TokenSelectionPill +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.session.LocalSessionController import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.solana.keys.Mint @@ -33,6 +35,8 @@ fun CashScreen( ) { val navigator = LocalCodeNavigator.current val session = LocalSessionController.current!! + val features = LocalFeatureFlags.current + val isNewUi by features.observe(FeatureFlag.NewUi).collectAsStateWithLifecycle() val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -42,7 +46,13 @@ fun CashScreen( .filterIsInstance() .onEach { session.showBill(it.bill) - navigator.hide() + // v2 reaches this screen as a PUSH (from currency-info), not a sheet, so hide() — + // which only pops when a Sheet is on the stack — would leave it up. Pop back to the + // currency-info underneath so the bill presents over it. The pop is deliberately + // untransitioned (see NewAppContent's popTransitionSpec): the bill overlay + scrim are + // drawn per nav entry, so an animated pop slides the outgoing entry's copy away while + // the incoming entry composes its own — which reads as a flash behind the bill. + if (isNewUi) navigator.pop() else navigator.hide() } .launchIn(this) }