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