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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import com.flipcash.app.core.navigation.TabBarVisibilityController
import com.flipcash.app.core.navigation.asNavBarTab
import com.flipcash.app.core.navigation.destinationRoute
import com.flipcash.app.core.ui.transitions.CardExpandTransition
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.core.tokens.TokenInfoPresentation
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 @@ -57,11 +59,17 @@ import dev.theolm.rinku.DeepLink
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")
// A drill-in push (TokenInfoPresentation.Push, 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=") && pushedTokenInfoEntries.none { s.contains(it) }
}

/** The `entry=…` fragments of an [AppRoute.Token.Info] key whose entry is presented as a push. */
private val pushedTokenInfoEntries: List<String> =
TokenInfoEntry.all
.filter { it.presentation == TokenInfoPresentation.Push }
.map { "entry=$it" }

/**
* True when a scene key belongs to [AppRoute.Sheets.Give] (the give/cash screen, which is pushed
* rather than presented as a sheet). Same stringified-key match as [isTokenInfoKey].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ fun appEntryProvider(

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
TokenInfoScreen(key.mint, key.shortfall, key.fromDeeplink, key.asPush, key.returnAfterBuy)
TokenInfoScreen(key.mint, key.entry)
}
annotatedEntry<AppRoute.Token.Transactions>(testTag = "transaction_history_screen") { key ->
TransactionHistoryScreen(key.mint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.DeeplinkType
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.router.Router
import com.flipcash.services.user.AuthState
import dev.theolm.rinku.DeepLink
Expand Down Expand Up @@ -194,7 +195,7 @@ class BuildNavGraphForLaunchTest {
fun `token deeplink pushes token info onto the Wallet tab without a sheet`() {
val result = buildReady(
DeeplinkAction.Navigate(
listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, fromDeeplink = true))
listOf(AppRoute.Sheets.Wallet, AppRoute.Token.Info(mint, TokenInfoEntry.Deeplink))
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.flipcash.app.core.tokens.FundingSource
import com.flipcash.app.core.tokens.SwapPurpose
import com.flipcash.app.core.tokens.SwapResult
import com.flipcash.app.core.tokens.SwapStep
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.core.tokens.TokenPurpose
import com.flipcash.app.core.navigation.SteppedFlowRoute
import com.flipcash.app.core.userprofile.UpdateProfileResult
Expand Down Expand Up @@ -210,16 +211,7 @@ sealed interface AppRoute : NavKey, Parcelable {
@Serializable
data class Info(
val mint: Mint,
val shortfall: Fiat? = null,
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,
// Pop back to whatever opened this screen once a buy of [mint] succeeds, rather than
// staying here. Set by a chat's gate: the reader came to buy their way into the group,
// so the purchase finishing is the moment to put them back in front of Join.
val returnAfterBuy: Boolean = false,
val entry: TokenInfoEntry = TokenInfoEntry.Wallet,
) : Token

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.flipcash.app.core.tokens

import android.os.Parcelable
import com.getcode.solana.keys.Mint
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* Where a currency-info screen was opened from. Each origin states how the screen is presented
* and what it does once a buy of its currency succeeds; [afterSwap] turns that into the step the
* screen takes when a swap it launched returns.
*/
@Serializable
@Parcelize
sealed interface TokenInfoEntry : Parcelable {
val presentation: TokenInfoPresentation
val afterBuy: AfterBuy

/** The wallet, or any route that lands on a currency without a more specific origin. */
@Serializable
data object Wallet : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.CardExpand
override val afterBuy get() = AfterBuy.Stay
}

/** A token deeplink, opened or scanned. */
@Serializable
data object Deeplink : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.CardExpand
override val afterBuy get() = AfterBuy.Stay
}

/** Token discovery's list. */
@Serializable
data object Discovery : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.Push
override val afterBuy get() = AfterBuy.Stay
}

/** A currency mentioned in a chat transcript. */
@Serializable
data object Chat : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.Push
override val afterBuy get() = AfterBuy.Stay
}

/**
* A group chat's buy-in gate. The reader came to buy their way into the group, so the
* purchase finishing is the moment to put them back in front of Join.
*/
@Serializable
data object ChatGate : TokenInfoEntry {
override val presentation get() = TokenInfoPresentation.Push
override val afterBuy get() = AfterBuy.ReturnToOpener
}

companion object {
/** Every origin. Covered by a test against the serializer's subclass list. */
val all: List<TokenInfoEntry> = listOf(Wallet, Deeplink, Discovery, Chat, ChatGate)
}
}

enum class TokenInfoPresentation {
/** The wallet card grows into the screen in place; the screen leads with ✕. */
CardExpand,

/** An ordinary stack push: slides in, leads with a back arrow. */
Push,
}

enum class AfterBuy {
/** Stay on the currency's info screen. */
Stay,

/** Pop back to whatever opened the screen. */
ReturnToOpener,
}

/** The step a currency-info screen takes when a swap it launched returns. */
enum class TokenInfoSwapOutcome {
Stay,
Pop,
OpenDeposit,
}

/**
* The step to take when a swap launched from the info screen for [mint] returns [result].
*
* [afterBuy] applies only to a buy of [mint] itself. Add Money from this screen also ends in a
* successful buy, but of USDF to fund this one, and the reader still has this purchase to make.
*/
fun TokenInfoEntry.afterSwap(mint: Mint, purpose: SwapPurpose, result: SwapResult): TokenInfoSwapOutcome =
when (result) {
SwapResult.OpenDeposit -> TokenInfoSwapOutcome.OpenDeposit
SwapResult.Canceled -> TokenInfoSwapOutcome.Stay
is SwapResult.Success -> {
val boughtThisCurrency = purpose is SwapPurpose.Buy && purpose.mint == mint
when {
!boughtThisCurrency -> TokenInfoSwapOutcome.Stay
afterBuy == AfterBuy.ReturnToOpener -> TokenInfoSwapOutcome.Pop
else -> TokenInfoSwapOutcome.Stay
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.flipcash.app.core.tokens

import com.getcode.solana.keys.Mint
import kotlinx.serialization.descriptors.elementNames
import kotlin.test.Test
import kotlin.test.assertEquals

class TokenInfoEntryTest {

private val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
private val buyThis = SwapPurpose.Buy(mint)
private val success = SwapResult.Success()

@Test
fun `all lists every origin`() {
// AppContent picks the transition from TokenInfoEntry.all, so an origin missing from it
// would silently get the card-expand transition.
val subclasses = TokenInfoEntry.serializer().descriptor.getElementDescriptor(1).elementNames.toSet()
assertEquals(subclasses, TokenInfoEntry.all.map { it::class.qualifiedName }.toSet())
}

@Test
fun `wallet and deeplink expand the card, list and chat origins push`() {
assertEquals(
mapOf(
TokenInfoEntry.Wallet to TokenInfoPresentation.CardExpand,
TokenInfoEntry.Deeplink to TokenInfoPresentation.CardExpand,
TokenInfoEntry.Discovery to TokenInfoPresentation.Push,
TokenInfoEntry.Chat to TokenInfoPresentation.Push,
TokenInfoEntry.ChatGate to TokenInfoPresentation.Push,
),
TokenInfoEntry.all.associateWith { it.presentation },
)
}

@Test
fun `buying this currency returns to the opener only from a chat gate`() {
assertEquals(
mapOf(
TokenInfoEntry.Wallet to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Deeplink to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Discovery to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.Chat to TokenInfoSwapOutcome.Stay,
TokenInfoEntry.ChatGate to TokenInfoSwapOutcome.Pop,
),
TokenInfoEntry.all.associateWith { it.afterSwap(mint, buyThis, success) },
)
}

@Test
fun `adding money from a chat gate stays, since this currency is still unbought`() {
assertEquals(
TokenInfoSwapOutcome.Stay,
TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Buy(Mint.usdf), success),
)
}

@Test
fun `selling or converting from a chat gate stays`() {
assertEquals(TokenInfoSwapOutcome.Stay, TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Sell(mint), success))
assertEquals(
TokenInfoSwapOutcome.Stay,
TokenInfoEntry.ChatGate.afterSwap(mint, SwapPurpose.Convert(mint, Mint.usdf), success),
)
}

@Test
fun `a canceled buy stays`() {
TokenInfoEntry.all.forEach {
assertEquals(TokenInfoSwapOutcome.Stay, it.afterSwap(mint, buyThis, SwapResult.Canceled))
}
}

@Test
fun `open deposit opens deposit from every origin`() {
TokenInfoEntry.all.forEach {
assertEquals(TokenInfoSwapOutcome.OpenDeposit, it.afterSwap(mint, buyThis, SwapResult.OpenDeposit))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.discovery.internal.TokenDiscoveryScreen
import com.flipcash.app.discovery.internal.TokenDiscoveryViewModel
import com.flipcash.core.R
Expand Down Expand Up @@ -54,7 +55,7 @@ private fun TokenDiscoveryEventHandler(viewModel: TokenDiscoveryViewModel, navig
viewModel.eventFlow
.filterIsInstance<TokenDiscoveryViewModel.Event.OpenTokenInfo>()
.map { it.mint }
.onEach { navigator.navigate(AppRoute.Token.Info(it, asPush = true)) }
.onEach { navigator.navigate(AppRoute.Token.Info(it, TokenInfoEntry.Discovery)) }
.launchIn(this)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
Expand Down Expand Up @@ -75,8 +76,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
// is no card here for it to grow from.
AppRoute.Token.Info(
action.mint,
asPush = true,
returnAfterBuy = action.returnAfterBuy,
if (action.returnAfterBuy) TokenInfoEntry.ChatGate else TokenInfoEntry.Chat,
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.flipcash.app.core.AppRoute.Token.*
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.navigation.DeeplinkType
import com.flipcash.app.core.tipping.TipCardOwner
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.router.LocalRouter
import com.flipcash.app.scanner.internal.bills.ScannableContainer
import com.flipcash.app.session.CodeScanEvent
Expand Down Expand Up @@ -110,7 +111,7 @@ internal fun Scanner() {
val routes: List<AppRoute> = when (deeplink) {
is DeeplinkType.TokenInfo -> listOf(
AppRoute.Sheets.Wallet,
Info(deeplink.mint, fromDeeplink = true)
Info(deeplink.mint, TokenInfoEntry.Deeplink)
)
// Scanned tip-DM code — same destination as the
// /tip/chat/{id} deeplink.
Expand Down
Loading
Loading