From 2c8cd3cb25789a6d408367a5d6edb0ffce400224 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 07:50:31 -0400 Subject: [PATCH 1/3] 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 859587f927fe9e1af8b6816647440b3f516c2ff5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 08:02:12 -0400 Subject: [PATCH 2/3] 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 60400d3b39486a059ee1a9e5d945db1defd8f361 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 17 Aug 2026 08:48:13 -0400 Subject: [PATCH 3/3] 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) + } } } },