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