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
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:phone"))
implementation(project(":apps:flipcash:shared:shareable"))
implementation(project(":apps:flipcash:shared:invite"))
implementation(project(":apps:flipcash:shared:tipping"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":apps:flipcash:shared:web"))
implementation(project(":apps:flipcash:shared:workers"))
Expand Down
12 changes: 12 additions & 0 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@
android:scheme="https" />
</intent-filter>

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="app.flipcash.com"
android:pathPattern="/tip/.*"
android:scheme="https" />
</intent-filter>


<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.flipcash.app.billing.BillingClient
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.LocalContactCoordinator
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.tipping.LocalTipCoordinator
import com.flipcash.app.core.toast.LocalToastController
import com.flipcash.app.core.toast.ToastController
import com.flipcash.app.core.verification.email.EmailCodeChannel
Expand All @@ -44,6 +45,7 @@ import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.updates.AppUpdateController
import com.flipcash.app.updates.LocalAppUpdater
import com.flipcash.services.user.UserManager
import com.flipcash.shared.tipping.TippingCoordinator
import com.getcode.libs.analytics.LocalAnalytics
import com.getcode.opencode.compose.LocalExchange
import com.getcode.opencode.exchange.Exchange
Expand Down Expand Up @@ -136,6 +138,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var coinbaseOnRampController: CoinbaseOnRampController

@Inject
lateinit var tippingCoordinator: TippingCoordinator

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUncaughtException()
Expand Down Expand Up @@ -168,6 +173,7 @@ class MainActivity : FragmentActivity() {
LocalContactCoordinator provides contactCoordinator,
LocalToastController provides toastController,
LocalCoinbaseOnRampController provides coinbaseOnRampController,
LocalTipCoordinator provides tippingCoordinator,
LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false),
) {
ProvidePermissionChecker(permissionChecker) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.OverlayScene
Expand Down Expand Up @@ -302,6 +301,7 @@ internal fun App(
onDismissed = { }
)

is DeeplinkAction.PresentTipCard -> session.resolveTipCard(action.userId)
is DeeplinkAction.OpenCashLink -> session.openCashLink(
action.entropy
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.flipcash.app.balance.BalanceScreen
import com.flipcash.app.cash.CashScreen
import com.flipcash.app.contact.verification.VerificationFlowScreen
import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen
import com.flipcash.app.tipping.TipAmountEntryScreen
import com.flipcash.app.tipping.TippingFlowScreen
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.navigation.DeeplinkAction
Expand Down Expand Up @@ -97,6 +98,7 @@ fun appEntryProvider(
TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Sheets.TokenSelection> { key -> TokenSelectScreen(key.purpose) }
annotatedEntry<AppRoute.Sheets.TipAmountEntry> { TipAmountEntryScreen() }
annotatedEntry<AppRoute.Sheets.Wallet> { BalanceScreen() }
annotatedEntry<AppRoute.Sheets.ShareApp> { ShareAppScreen() }
annotatedEntry<AppRoute.Sheets.Menu> { MenuScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ internal fun buildNavGraphForLaunch(
)

is DeeplinkAction.OpenCashLink,
is DeeplinkAction.PresentTipCard,
is DeeplinkAction.Login -> LaunchNavGraph(
baseRoutes = listOf(AppRoute.Main.Scanner),
pendingAction = action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ sealed interface AppRoute : NavKey, Parcelable {
data class Tips(val resumed: Boolean = false): Sheets {
}

/**
* Custom tip-amount entry, opened over the still-visible tip card + modal. The entered
* amount is written back to the shared tip selection so the modal reflects it on dismiss.
*/
@Serializable
data object TipAmountEntry : Sheets

@Serializable
data object Wallet : Sheets
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@ sealed interface Scannable {

data class TipCard(
override val data: List<Byte>,
val user: UserProfile
val user: UserProfile,
) : Scannable
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,15 @@ class BillController @Inject constructor(
private val transactionManager: BillTransactionManager,
private val userFlags: UserFlagsCoordinator,
) {
private val _state = MutableStateFlow(BillState.Default)
val state: StateFlow<BillState>
get() = _state
field = MutableStateFlow(BillState.Default)

fun update(function: (BillState) -> BillState) {
_state.update(function)
state.update(function)
}

fun reset(showToast: Boolean = false) {
_state.update { state ->
state.update { state ->
BillState.Default.copy(showToast = showToast, toast = state.toast)
}
transactionManager.reset()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.flipcash.app.core.navigation

import com.getcode.opencode.model.core.ID

sealed interface DeeplinkAction {
data class Navigate(val routes: List<com.flipcash.app.core.AppRoute>) : DeeplinkAction
data class Login(val entropy: String) : DeeplinkAction
data class OpenCashLink(val entropy: String) : DeeplinkAction
data class PresentTipCard(val userId: ID): DeeplinkAction
data object None : DeeplinkAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcelable
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.services.models.chat.ChatId
import com.getcode.opencode.model.core.ID
import com.getcode.solana.keys.Mint
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
Expand All @@ -19,6 +20,8 @@ sealed interface DeeplinkType: Parcelable {

@Serializable data class Chat(val identifier: ChatIdentifier): DeeplinkType, Navigatable

@Serializable data class Tipcard(val userId: ID): DeeplinkType

@Serializable
data class EmailVerification(
val email: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.flipcash.app.core.tipping

import androidx.compose.runtime.staticCompositionLocalOf
import com.flipcash.app.core.chat.ChatIdentifier
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Token
import com.flipcash.app.core.AppRoute
import com.getcode.view.LoadingSuccessState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow

/**
* A chosen tip amount, tagged by how it was chosen: picked from a [Preset] or entered as a
* [Custom] value. Both carry the underlying [value].
*/
sealed interface TipAmount {
val value: Fiat

/** An amount picked from the tip card's suggested presets. */
data class Preset(override val value: Fiat) : TipAmount

/** An amount entered via the custom-amount sheet. */
data class Custom(override val value: Fiat) : TipAmount
}

/**
* The current tip selection: the [amount] the sender has chosen, the [token] they'll tip with, and
* the [sendState] of an in-flight tip submission.
*/
data class TipSelectionState(
/** user ID for the scanned and presented tip card */
val userId: ID? = null,
/** The chosen tip amount (preset or custom), or `null` when nothing is selected. */
val amount: TipAmount? = null,
/** The token to tip with — mirrors the app-global selected token; `null` until resolved. */
val token: Token? = null,
/** Processing state of the tip submission (idle / loading / success). */
val sendState: LoadingSuccessState = LoadingSuccessState(),
/**
* Whether the viewer can afford at least the minimum tip. The tip card is always
* shown; when false the scanner hides the tip modal and prompts to add money.
* Defaults to false so the modal stays hidden until affordability is confirmed.
*/
val canTip: Boolean = false,
/** Suggested tip presets in the sender's preferred currency; updates when the region changes. */
val presets: List<Fiat> = emptyList(),
) {
/** A positive amount is chosen and no submission is currently in flight. */
val canConfirm: Boolean get() = amount != null && sendState.isIdle
}

/**
* Selection state for the tip modal.
*
* Backed by the tipping coordinator (a singleton) and surfaced through [LocalTipCoordinator] so the
* tip modal, the custom-amount sheet, and the token-selection sheet all read and write the same
* [selection]. This lets a sheet opened over the still-visible tip card mutate the selection and
* have the modal reflect it immediately, without the scanner feature depending on the tipping module.
*/
interface TipSelectionHolder {
/** The combined tip selection (amount + token + send state). */
val selection: StateFlow<TipSelectionState>

/** Selects [amount] (a [TipAmount.Preset] or [TipAmount.Custom]); pass `null` to clear it. */
fun selectAmount(amount: TipAmount?)

/** Submits the current tip selection, driving [TipSelectionState.sendState] loading → success. */
fun confirmTip()

/**
* One-shot UI events the tip decorator should act on — e.g. opening the deposit flow
* after the user picks "Add Money" from an insufficient-balance prompt. Emitted by the
* coordinator and collected where the navigator lives.
*/
val events: Flow<TipEvent>

/** No-op holder used as the [LocalTipCoordinator] default so consumers never see null. */
object Empty : TipSelectionHolder {
override val selection: StateFlow<TipSelectionState> = MutableStateFlow(TipSelectionState())
override fun selectAmount(amount: TipAmount?) = Unit
override fun confirmTip() = Unit
override val events: Flow<TipEvent> = emptyFlow()
}
}

/** One-shot events emitted by a [TipSelectionHolder] for the tip UI to handle. */
sealed interface TipEvent {
/** Open [route] (e.g. the deposit flow) using the tip UI's navigator. */
data class OpenRoute(val route: AppRoute) : TipEvent

/**
* Launch the DM chat for a completed tip. The tip UI opens it through the tips flow so the
* tips list sits beneath the chat in the back stack (back returns to the list).
*/
data class LaunchChat(val identifier: ChatIdentifier) : TipEvent
}

/** Provides the active [TipSelectionHolder] (the tipping coordinator) to composables. */
val LocalTipCoordinator = staticCompositionLocalOf<TipSelectionHolder> { TipSelectionHolder.Empty }
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
sealed interface TokenPurpose: Parcelable {
@Serializable data object Select : TokenPurpose

@Parcelize
sealed interface TriggersChange: TokenPurpose

@Serializable data object Select : TriggersChange {

}
@Serializable data class Swap(val desiredToken: Mint, val amount: Fiat) : TokenPurpose
@Serializable data class LaunchFunding(val amount: Fiat): TokenPurpose
@Serializable data class Tip(val amount: Fiat?): TriggersChange
@Serializable data object Withdraw: TokenPurpose
@Serializable data object Deposit: TokenPurpose
@Serializable data object Balance : TokenPurpose
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,71 @@
package com.flipcash.app.core.ui

import androidx.compose.foundation.Image
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import com.getcode.opencode.model.financial.Token
import com.getcode.theme.CodeTheme
import com.getcode.ui.core.R
import androidx.compose.foundation.clickable

@Composable
fun TokenSelectionPill(token: Token?, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
fun TokenSelectionPill(
token: Token?,
modifier: Modifier = Modifier,
background: Color = Color.Transparent,
contentPadding: PaddingValues = PaddingValues(
horizontal = CodeTheme.dimens.grid.x3,
vertical = CodeTheme.dimens.grid.x2,
),
textStyle: TextStyle = CodeTheme.typography.screenTitle,
imageSize: Dp = CodeTheme.dimens.staticGrid.x5,
onClick: () -> Unit
) {
// [contentPadding] only applies when a background is supplied, so call sites without one
// (e.g. app-bar titles) keep their original wrap-content layout.
val hasBackground = background != Color.Transparent
Row(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
contentAlignment = Alignment.Center
.clip(CircleShape)
.background(color = background, shape = CircleShape)
.clickable { onClick() }
.then(if (hasBackground) Modifier.padding(contentPadding) else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
space = CodeTheme.dimens.grid.x1,
alignment = Alignment.CenterHorizontally
)
) {
Row(
modifier = Modifier
.clip(CircleShape)
.clickable { onClick() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
space = CodeTheme.dimens.grid.x1,
alignment = Alignment.CenterHorizontally
token?.let {
TokenIconWithName(
token = token,
imageSize = imageSize,
spacing = CodeTheme.dimens.grid.x1,
textStyle = textStyle,
)
) {
token?.let {
TokenIconWithName(
token = token,
imageSize = CodeTheme.dimens.staticGrid.x5,
spacing = CodeTheme.dimens.grid.x1,
)

Image(
modifier = Modifier
.width(CodeTheme.dimens.grid.x4),
painter = painterResource(R.drawable.ic_dropdown),
contentDescription = ""
)
}

Image(
modifier = Modifier
.width(CodeTheme.dimens.grid.x4),
painter = painterResource(R.drawable.ic_dropdown),
contentDescription = ""
)
}
}
}
Expand Down
Loading
Loading