Skip to content
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,26 @@ 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
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
Expand All @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 ->
Expand All @@ -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 })
Expand All @@ -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 })
Expand All @@ -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.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Sheets.TipAmountEntry> { TipAmountEntryScreen() }
annotatedEntry<AppRoute.Sheets.Wallet> {
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()
}
Expand All @@ -119,7 +121,7 @@ fun appEntryProvider(

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink)
TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink, key.asPush)
}
annotatedEntry<AppRoute.Token.Transactions>(testTag = "transaction_history_screen") { key ->
TransactionHistoryScreen(key.mint)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
14 changes: 14 additions & 0 deletions apps/flipcash/card-expand/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading