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..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
@@ -1,12 +1,34 @@
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.statusBars
+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.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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -16,7 +38,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 +53,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 +71,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 +129,130 @@ fun TokenInfoScreen(
}
},
)
+ }
- LaunchedEffect(Unit) {
- val source = when {
- shortFall != null -> Analytics.TokenInfoSource.Give
- fromDeeplink -> Analytics.TokenInfoSource.Deeplink
- else -> Analytics.TokenInfoSource.Wallet
- }
+ if (isNewUi) {
+ // 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()
+ val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- analytics.openTokenInfo(
- source = source,
- mint = mint
+ OverlayTopBarScaffold(
+ topBar = {
+ // 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(statusBarHeight + appBarHeight * 0.5f)
+ .background(
+ Brush.verticalGradient(
+ 0f to CodeTheme.colors.background,
+ 1f to Color.Transparent,
+ )
+ )
+ )
+ Box(modifier = Modifier.statusBarsPadding()) {
+ Box(modifier = Modifier.measured { appBarHeight = it.height }) {
+ appBar(hazeState)
+ }
+ }
+ }
+ },
+ ) { topPadding ->
+ TokenInfoScreen(
+ viewModel = viewModel,
+ shortfall = shortFall,
+ listState = listState,
+ contentPadding = PaddingValues(
+ top = topPadding,
+ bottom = bottomInset + CodeTheme.dimens.grid.x8,
+ ),
+ hazeState = hazeState,
)
}
-
- TokenInfoScreen(viewModel, shortFall)
-
- LaunchedEffect(Unit) {
- viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint, shortFall))
+ } 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()
- .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()
+ .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))
}
}
- else -> navigator.push(screen)
}
- }.launchIn(this)
+ else -> navigator.push(screen)
+ }
+ }.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) }
}
}
}
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