Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,10 @@
<string name="subtitle_sellHintLimitExceeded">You can only sell up to %1$s</string>
<string name="action_buy">Buy</string>
<string name="action_sell">Sell</string>
<string name="action_convert">Convert</string>
<string name="action_get">Get</string>
<string name="label_createdAt">Created %1$s</string>
<string name="subtitle_about">About</string>
<string name="title_amountToWithdraw">Amount to Withdraw</string>
<string name="label_solanaUsdc">Solana USDC with</string>
<string name="title_buyToken">Buy %1$s</string>
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/features/tokens/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -43,81 +71,188 @@ fun TokenInfoScreen(
fromDeeplink: Boolean,
) {
val navigator = LocalCodeNavigator.current
val analytics = rememberAnalytics()
val viewModel = hiltViewModel<TokenInfoViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val analytics = rememberAnalytics()
val viewModel = hiltViewModel<TokenInfoViewModel>()
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)
}
}
}
},
)
}

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<TokenInfoViewModel.Event.Exit>()
.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<TokenInfoViewModel.Event.OpenScreen>()
.map { it.screen }
.onEach { screen ->
when (screen) {
is AppRoute.Token.Swap -> {
navigator.navigateForResult<SwapResult>(screen) { result ->
if (result is NavResultOrCanceled.ReturnValue &&
result.value is SwapResult.OpenDeposit) {
navigator.push(AppRoute.Transfers.Deposit(showOtherOptions = false))
}
LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<TokenInfoViewModel.Event.Exit>()
.onEach { navigator.pop() }
.launchIn(this)
}

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<TokenInfoViewModel.Event.OpenScreen>()
.map { it.screen }
.onEach { screen ->
when (screen) {
is AppRoute.Token.Swap -> {
navigator.navigateForResult<SwapResult>(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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) }
Expand Down
Loading
Loading