diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts
index 6a3b76287a..fed6dd7cd1 100644
--- a/apps/flipcash/app/build.gradle.kts
+++ b/apps/flipcash/app/build.gradle.kts
@@ -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"))
diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index e351cf0b2e..df1327d637 100644
--- a/apps/flipcash/app/src/main/AndroidManifest.xml
+++ b/apps/flipcash/app/src/main/AndroidManifest.xml
@@ -177,6 +177,18 @@
android:scheme="https" />
+
+
+
+
+
+
+
+
+
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt
index 61bf63f792..03501dfef4 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt
@@ -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
@@ -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
@@ -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()
@@ -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) {
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
index 054a6af914..b6ff8427e8 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt
@@ -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
@@ -302,6 +301,7 @@ internal fun App(
onDismissed = { }
)
+ is DeeplinkAction.PresentTipCard -> session.resolveTipCard(action.userId)
is DeeplinkAction.OpenCashLink -> session.openCashLink(
action.entropy
)
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 e8aca90166..066ac4732f 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
@@ -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
@@ -97,6 +98,7 @@ fun appEntryProvider(
TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry { key -> TokenSelectScreen(key.purpose) }
+ annotatedEntry { TipAmountEntryScreen() }
annotatedEntry { BalanceScreen() }
annotatedEntry { ShareAppScreen() }
annotatedEntry { MenuScreen() }
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
index 1931507674..f3b6fea9c1 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
@@ -219,6 +219,7 @@ internal fun buildNavGraphForLaunch(
)
is DeeplinkAction.OpenCashLink,
+ is DeeplinkAction.PresentTipCard,
is DeeplinkAction.Login -> LaunchNavGraph(
baseRoutes = listOf(AppRoute.Main.Scanner),
pendingAction = action,
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 f04985db3c..a6592fdd67 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
@@ -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
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 1fae035002..bb34c0302d 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
@@ -95,6 +95,6 @@ sealed interface Scannable {
data class TipCard(
override val data: List,
- val user: UserProfile
+ val user: UserProfile,
) : Scannable
}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt
index a0b3a9fbae..2a8f4ce93c 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt
@@ -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
- 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()
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
index 8bc6901ef4..5d90d335d8 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
@@ -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) : 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
}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
index a0bdb4a8e7..e90780bce5 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt
@@ -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
@@ -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,
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt
new file mode 100644
index 0000000000..baf33284b3
--- /dev/null
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt
@@ -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 = 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
+
+ /** 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
+
+ /** No-op holder used as the [LocalTipCoordinator] default so consumers never see null. */
+ object Empty : TipSelectionHolder {
+ override val selection: StateFlow = MutableStateFlow(TipSelectionState())
+ override fun selectAmount(amount: TipAmount?) = Unit
+ override fun confirmTip() = Unit
+ override val events: Flow = 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.Empty }
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt
index 2ace76ca51..1fb093d570 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenPurpose.kt
@@ -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
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt
index 10feb1bfaa..5726bdc8de 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/TokenSelectionPill.kt
@@ -1,10 +1,14 @@
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
@@ -12,45 +16,56 @@ 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 = ""
+ )
}
}
}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
index 7928e1fc9c..26ed1d31c7 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
@@ -1,14 +1,18 @@
package com.flipcash.app.core.util
import com.flipcash.services.models.chat.ChatId
+import com.getcode.opencode.model.core.ID
+import com.getcode.opencode.model.core.uuid
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.base58
import com.getcode.utils.encodeBase64
+import com.getcode.utils.hexEncodedString
import com.getcode.utils.urlEncode
object Linkify {
fun cashLink(entropy: String): String = "https://send.flipcash.com/c/#/e=${entropy}"
+ fun tipcard(userId: ID): String = "https://app.flipcash.com/tip/${userId.uuid}"
fun download(shareRef: String): String = "https://flipcash.com/download?r=${shareRef}"
fun whatsApp(phoneNumber: String, message: String): String =
"https://wa.me/${phoneNumber.removePrefix("+")}?text=${message.urlEncode()}"
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index d5a30c0b46..7b8e66d90c 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -274,6 +274,7 @@
Add money to get started
Add money to create a currency
Buy your first currency to get started
+ Add money to send tips
Dismiss
Success
@@ -884,5 +885,16 @@
Your Name
500x500 Recommended
My Tip Card
+ Tip
+ Share Your Tip Card to Get Tipped
+ Show My Tip Card
+ Send a Tip
+ Amount to Tip
+ of
+ Swipe to Tip
+ Enter a custom amount
+ Minimum tip %1$s
+ Tips Start at %1$s
+ Enter a larger amount to send this tip
\ No newline at end of file
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 3fa78b7410..e45f910b0b 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
@@ -2,6 +2,8 @@ package com.flipcash.app.cash
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+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.runtime.getValue
@@ -51,7 +53,12 @@ fun CashScreen(
) {
AppBarWithTitle(
title = {
- TokenSelectionPill(state.token?.token) {
+ TokenSelectionPill(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ token = state.token?.token
+ ) {
navigator.push(
AppRoute.Sheets.TokenSelection(TokenPurpose.Select)
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
index f021ac3416..c5348ab3af 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatAmountEntryScreen.kt
@@ -1,8 +1,11 @@
package com.flipcash.app.messenger
+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.runtime.getValue
+import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
@@ -88,7 +91,12 @@ internal fun ChatAmountEntryContent(
appBar = {
AppBarWithTitle(
title = {
- TokenSelectionPill(token) {
+ TokenSelectionPill(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ token = token
+ ) {
navigator.push(
AppRoute.Sheets.TokenSelection(TokenPurpose.Select)
)
diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts
index 49f4661341..6bf13d2c98 100644
--- a/apps/flipcash/features/scanner/build.gradle.kts
+++ b/apps/flipcash/features/scanner/build.gradle.kts
@@ -23,4 +23,5 @@ dependencies {
implementation(project(":libs:vibrator:bindings"))
implementation(project(":ui:biometrics"))
implementation(project(":ui:scanner"))
+ implementation(libs.androidx.foundation.layout)
}
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt
index dc1bac0cb7..c652482b0a 100644
--- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt
@@ -14,6 +14,8 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.analytics.rememberAnalytics
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.AppRoute.Token.*
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.core.navigation.DeeplinkType
@@ -127,8 +129,8 @@ internal fun Scanner() {
is DeeplinkType.Navigatable -> {
val routes = when (deeplink) {
is DeeplinkType.TokenInfo -> listOf(
- com.flipcash.app.core.AppRoute.Sheets.Wallet,
- com.flipcash.app.core.AppRoute.Token.Info(deeplink.mint, fromDeeplink = true)
+ AppRoute.Sheets.Wallet,
+ Info(deeplink.mint, fromDeeplink = true)
)
else -> emptyList()
}
@@ -137,6 +139,9 @@ internal fun Scanner() {
}
}
is DeeplinkType.Login -> Unit
+ is DeeplinkType.Tipcard -> {
+ session.resolveTipCard(deeplink.userId)
+ }
}
}
}
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt
index 66c8818025..540f127acc 100644
--- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt
@@ -1,20 +1,17 @@
package com.flipcash.app.scanner.internal.bills
-import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterTransition
-import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
@@ -26,8 +23,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment.Companion.BottomCenter
+import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -37,9 +35,9 @@ import androidx.compose.ui.unit.dp
import com.flipcash.app.bills.AnimatedScannable
import com.flipcash.app.core.android.extensions.launchAppSettings
import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.core.tipping.LocalTipCoordinator
import com.flipcash.app.scanner.internal.ScannerDecorItem
import com.flipcash.app.scanner.internal.ui.components.DecorView
-import com.flipcash.app.scanner.internal.ui.modals.ReceivedFundsConfirmation
import com.flipcash.app.session.BillDeterminationResult
import com.flipcash.app.session.Grabbed
import com.flipcash.app.session.LocalSessionController
@@ -47,15 +45,17 @@ import com.flipcash.app.session.PutInWallet
import com.flipcash.app.updates.LocalAppUpdater
import com.getcode.ui.components.OnLifecycleEvent
import androidx.lifecycle.Lifecycle
+import com.flipcash.app.scanner.internal.bills.decor.ScannableDecoratorContext
+import com.flipcash.app.scanner.internal.bills.decor.ScannableDecorator
import com.flipcash.features.scanner.R
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
import com.getcode.theme.CodeTheme
import com.getcode.ui.biometrics.LocalBiometricsState
-import com.getcode.ui.core.measured
import com.getcode.ui.scanner.views.CameraDisabledView
import com.getcode.ui.scanner.views.CameraPermissionsMissingView
import com.getcode.ui.utils.AnimationUtils
+import com.getcode.ui.utils.ModalAnimationSpeed
import com.getcode.util.permissions.PermissionResult
import com.getcode.util.permissions.rememberCameraPermission
import kotlinx.coroutines.delay
@@ -101,6 +101,11 @@ internal fun ScannableContainer(
val state by session.state.collectAsStateWithLifecycle()
val billState by session.billState.collectAsStateWithLifecycle()
+ // Tip affordability (min-tip balance check) is owned by the tipping coordinator and
+ // surfaced through the shared selection state, so the scanner reads it without
+ // depending on the tipping module.
+ val tipSelection by LocalTipCoordinator.current.selection.collectAsStateWithLifecycle()
+
val autoStart = state.autoStartCamera == true
var cameraStarted by remember { mutableStateOf(autoStart) }
@@ -162,24 +167,39 @@ internal fun ScannableContainer(
val updatedState by rememberUpdatedState(state)
val updatedBillState by rememberUpdatedState(billState)
- var dismissed by remember(updatedBillState.bill) {
- mutableStateOf(false)
+ // Not keyed on the bill: it must stay true while the swiped-off card is being removed so
+ // the outgoing content stays hidden through its exit instead of snapping back to center.
+ // Reset explicitly when a fresh bill appears.
+ var dismissed by remember { mutableStateOf(false) }
+ LaunchedEffect(updatedBillState.bill) {
+ if (updatedBillState.bill != null) dismissed = false
}
// bill dismiss state, restarted for every bill
val billDismissState = remember(updatedBillState.bill) {
DismissState(
initialValue = DismissValue.Default,
+ // Only gate whether the swipe is allowed. Removing the bill here (mid-swipe) would
+ // recreate this DismissState and snap the card's offset back to center before the
+ // exit slide — the "reset then dismiss" stutter. Removal happens after the swipe
+ // settles the card off-screen (see below).
confirmStateChange = {
- val canDismiss =
- it == DismissValue.DismissedToEnd && updatedBillState.canSwipeToDismiss
- if (canDismiss) {
- session.dismissBill(PutInWallet)
+ it == DismissValue.DismissedToEnd && updatedBillState.canSwipeToDismiss
+ }
+ )
+ }
+
+ // Once the swipe has carried the card off-screen (currentValue leaves Default), hide it and
+ // remove the bill. The card is already out of view, so the AnimatedContent exit re-shows
+ // nothing and there's no offset reset.
+ LaunchedEffect(billDismissState) {
+ snapshotFlow { billDismissState.currentValue }
+ .collect { value ->
+ if (value != DismissValue.Default) {
dismissed = true
+ session.dismissBill(PutInWallet)
}
- canDismiss
}
- )
}
LaunchedEffect(dismissed) {
@@ -212,11 +232,36 @@ internal fun ScannableContainer(
val showManagementOptions by remember(updatedBillState) {
derivedStateOf {
+ // The tip card always shows, but its modal only slides up when the
+ // viewer can afford the minimum tip; otherwise the tip decorator prompts
+ // to add money.
billDismissState.targetValue == DismissValue.Default &&
- updatedBillState.valuation != null
+ (updatedBillState.valuation != null ||
+ (updatedBillState.bill is Scannable.TipCard && tipSelection.canTip))
}
}
+ // When the tip modal is up, pin the tip card just above it: reserve the modal's height as
+ // bottom inset AND bottom-align the card (bias 0 = centered, 1 = bottom-aligned). Otherwise
+ // the card centers in the region above the (tall) modal and floats high. Both are animated
+ // so the card slides from centered down to just above the modal as it enters, and back.
+ val tipModalUp = managementHeight > 0.dp && updatedBillState.bill is Scannable.TipCard
+ // Drive the card's move with the SAME timing as the tip modal's enter (see
+ // AnimationUtils.modalEnter → ModalAnimationSpeed.Normal): the card holds centered during the
+ // modal's start delay, then slides up in lockstep with the modal instead of lagging behind.
+ val modalSpeed = ModalAnimationSpeed.Normal(updatedBillState.confirmationDelayMillis)
+ val offset = if (updatedBillState.bill is Scannable.TipCard) CodeTheme.dimens.grid.x8 else CodeTheme.dimens.grid.x2
+ val billBottomInset by animateDpAsState(
+ targetValue = managementHeight + offset,
+ animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay),
+ label = "billBottomInset",
+ )
+ val billVerticalBias by animateFloatAsState(
+ targetValue = if (tipModalUp) 1f else 0f,
+ animationSpec = tween(durationMillis = modalSpeed.duration, delayMillis = modalSpeed.delay),
+ label = "billVerticalBias",
+ )
+
AnimatedScannable(
modifier = Modifier.fillMaxSize(),
dismissState = billDismissState,
@@ -225,8 +270,9 @@ internal fun ScannableContainer(
start = CodeTheme.dimens.inset,
end = CodeTheme.dimens.inset,
top = CodeTheme.dimens.grid.x2,
- bottom = managementHeight + CodeTheme.dimens.grid.x2
+ bottom = billBottomInset,
),
+ scannableAlignment = BiasAlignment(horizontalBias = 0f, verticalBias = billVerticalBias),
bill = updatedBillState.bill,
transitionSpec = {
when (updatedState.billResult) {
@@ -241,84 +287,24 @@ internal fun ScannableContainer(
}
)
- // Below-bill content, folded by scannable type. `displayedScannable` retains the
- // last shown scannable so an arm's modal can still animate OUT as `bill` returns to
- // null on dismiss (the arm stays mounted; only `visible` flips).
+ // Below-bill content, owned by the scannable type (see `overlays/ScannableOverlays`).
+ // `displayedScannable` retains the last shown scannable so an overlay can still animate
+ // OUT as `bill` returns to null on dismiss (the overlay stays mounted; only `visible` flips).
var displayedScannable by remember { mutableStateOf(null) }
LaunchedEffect(updatedBillState.bill) {
updatedBillState.bill?.let { displayedScannable = it }
}
- when (val shown = displayedScannable) {
- is Scannable.Payable -> {
- //Bill management options
- AnimatedVisibility(
- modifier = Modifier
- .align(BottomCenter)
- .measured { managementHeight = it.height },
- visible = updatedBillState.bill is Scannable.Payable && showManagementOptions,
- enter = fadeIn(),
- exit = fadeOut(tween(100)),
- ) {
- var canCancel by remember {
- mutableStateOf(false)
- }
- BillManagementOptions(
- modifier = Modifier
- .windowInsetsPadding(WindowInsets.navigationBars),
- primaryAction = updatedBillState.primaryAction,
- secondaryAction = updatedBillState.secondaryAction,
- isSending = updatedState.isRemoteSendLoading,
- isInteractable = canCancel,
- )
-
- LaunchedEffect(transition.isRunning, transition.targetState) {
- // wait for spring settle to enable cancel to not prematurely cancel
- // the enter. doing so causing the exit of the bill to not run, or run its own dismiss animation
- if (transition.targetState == EnterExitState.Visible && transition.currentState == transition.targetState) {
- delay(500)
- canCancel = true
- }
- }
-
- BackHandler(canCancel) {
- session.dismissBill(PutInWallet)
- }
- }
-
- //Bill Received Bottom Dialog
- AnimatedVisibility(
- modifier = Modifier.align(BottomCenter),
- visible = (updatedBillState.bill as? Scannable.Payable)?.didReceive == true,
- enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis),
- exit = AnimationUtils.modalExit,
- ) {
- Box(
- contentAlignment = BottomCenter
- ) {
- ReceivedFundsConfirmation(
- bill = shown,
- onClaim = { session.dismissBill(PutInWallet) }
- )
- }
- }
- }
-
- is Scannable.TipCard -> {
- // TODO(owner): the tip card's bottom modal (analogous to ReceivedFundsConfirmation).
- // Gate `visible` on the live bill so it animates out on dismiss, and use `shown`
- // (the retained Scannable.TipCard) for content so it persists through the exit:
- // AnimatedVisibility(
- // modifier = Modifier.align(BottomCenter),
- // visible = updatedBillState.bill is Scannable.TipCard,
- // enter = AnimationUtils.modalEnter(0),
- // exit = AnimationUtils.modalExit,
- // ) {
- // TipCardModal(tipCard = shown, onDone = { session.dismissBill(PutInWallet) })
- // }
- }
-
- null -> Unit
+ displayedScannable?.let { ScannableDecorator.forScannable(it) }?.let { overlays ->
+ val overlayContext = ScannableDecoratorContext(
+ liveBill = updatedBillState.bill,
+ billState = updatedBillState,
+ isRemoteSendLoading = updatedState.isRemoteSendLoading,
+ showManagementOptions = showManagementOptions,
+ onManagementHeightMeasured = { managementHeight = it },
+ onDismiss = { session.dismissBill(PutInWallet) },
+ )
+ with(overlays) { Content(overlayContext) }
}
}
}
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/PayableDecorator.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/PayableDecorator.kt
new file mode 100644
index 0000000000..a1c8d3264a
--- /dev/null
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/PayableDecorator.kt
@@ -0,0 +1,93 @@
+package com.flipcash.app.scanner.internal.bills.decor
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.EnterExitState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.Companion.BottomCenter
+import androidx.compose.ui.Modifier
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.scanner.internal.bills.BillManagementOptions
+import com.flipcash.app.scanner.internal.ui.modals.ReceivedFundsConfirmation
+import com.getcode.ui.core.measured
+import com.getcode.ui.utils.AnimationUtils
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Decorator owned by a [Scannable.Payable]: the bill management options row
+ * (send / send-as-link / share / cancel) and the "you received funds" confirmation modal.
+ *
+ * [bill] is the retained scannable so the confirmation modal keeps its content through the
+ * exit animation while [ScannableDecoratorContext.liveBill] flips to null on dismiss.
+ */
+internal data class PayableDecorator(private val bill: Scannable.Payable) : ScannableDecorator {
+ @Composable
+ override fun BoxScope.Content(context: ScannableDecoratorContext) {
+ val billState = context.billState
+
+ // Bill management options
+ AnimatedScannableDecorator(
+ visible = context.liveBill is Scannable.Payable && context.showManagementOptions,
+ enter = fadeIn(),
+ exit = fadeOut(tween(100)),
+ modifier = Modifier
+ .align(BottomCenter)
+ .measured { context.onManagementHeightMeasured(it.height) },
+ ) {
+ var canCancel by remember {
+ mutableStateOf(false)
+ }
+ BillManagementOptions(
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets.navigationBars),
+ primaryAction = billState.primaryAction,
+ secondaryAction = billState.secondaryAction,
+ isSending = context.isRemoteSendLoading,
+ isInteractable = canCancel,
+ )
+
+ LaunchedEffect(transition.isRunning, transition.targetState) {
+ // wait for spring settle to enable cancel to not prematurely cancel
+ // the enter. doing so causing the exit of the bill to not run, or run its own dismiss animation
+ if (transition.targetState == EnterExitState.Visible && transition.currentState == transition.targetState) {
+ delay(500.milliseconds)
+ canCancel = true
+ }
+ }
+
+ BackHandler(canCancel) {
+ context.onDismiss()
+ }
+ }
+
+ // Bill Received Bottom Dialog
+ AnimatedScannableDecorator(
+ visible = (context.liveBill as? Scannable.Payable)?.didReceive == true,
+ enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis),
+ exit = AnimationUtils.modalExit,
+ modifier = Modifier.align(BottomCenter),
+ ) {
+ Box(
+ contentAlignment = BottomCenter
+ ) {
+ ReceivedFundsConfirmation(
+ bill = bill,
+ onClaim = { context.onDismiss() }
+ )
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/ScannableDecorator.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/ScannableDecorator.kt
new file mode 100644
index 0000000000..ef6c1a6538
--- /dev/null
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/ScannableDecorator.kt
@@ -0,0 +1,86 @@
+package com.flipcash.app.scanner.internal.bills.decor
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import com.flipcash.app.core.bill.BillState
+import com.flipcash.app.core.bill.Scannable
+
+/**
+ * Below-bill decor content owned by a [Scannable] type rather than decided by
+ * `ScannableContainer` in a `when` branch. Each Scannable variant supplies its own decorators
+ * — management options, confirmation modals, and (future) the tip card modal — via [Content].
+ *
+ * The container still owns the card animation/gesture wiring (the `DismissState`, the
+ * `AnimatedContent` enter/exit, the retained-scannable latch). Decorators only decide what
+ * renders below the card. Each decorator captures the concrete retained scannable for its
+ * content (so it survives the exit animation) and gates its `visible` on
+ * [ScannableDecoratorContext.liveBill].
+ */
+internal sealed interface ScannableDecorator {
+ @Composable
+ fun BoxScope.Content(context: ScannableDecoratorContext)
+
+ /**
+ * An [AnimatedVisibility] that every decorator gets for free — it also runs its **enter**
+ * animation on first composition. A plain `visible = true` on an [AnimatedVisibility]'s first
+ * frame skips the enter transition, so the first scannable overlay of a session (management
+ * options, confirmation modals, tip modal, …) popped in instantly while later ones animated.
+ * Driving visibility through a [MutableTransitionState] seeded to `false` makes the very first
+ * appearance animate too. Use this instead of a bare [AnimatedVisibility] inside [Content].
+ */
+ @Composable
+ fun BoxScope.AnimatedScannableDecorator(
+ visible: Boolean,
+ enter: EnterTransition,
+ exit: ExitTransition,
+ modifier: Modifier = Modifier,
+ content: @Composable AnimatedVisibilityScope.() -> Unit,
+ ) {
+ val visibleState = remember { MutableTransitionState(false) }
+ visibleState.targetState = visible
+ AnimatedVisibility(
+ visibleState = visibleState,
+ modifier = modifier,
+ enter = enter,
+ exit = exit,
+ content = content,
+ )
+ }
+
+ companion object {
+ /** 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)
+ }
+ }
+}
+
+/**
+ * Container-owned state the decorator read. Rebuilt on each recomposition so decorators gate
+ * their visibility on the live bill without reaching into container internals.
+ *
+ * @param liveBill the current bill (null once dismissed); used to gate `visible`.
+ * @param billState the current bill state (actions, valuation, confirmation delay).
+ * @param isRemoteSendLoading whether a remote send is in flight.
+ * @param showManagementOptions whether the management options row should be shown.
+ * @param onManagementHeightMeasured reports the measured management-row height back to the
+ * container so the card floats above it.
+ * @param onDismiss dismisses the current bill (equivalent to `session.dismissBill(PutInWallet)`).
+ */
+internal data class ScannableDecoratorContext(
+ val liveBill: Scannable?,
+ val billState: BillState,
+ val isRemoteSendLoading: Boolean,
+ val showManagementOptions: Boolean,
+ val onManagementHeightMeasured: (Dp) -> Unit,
+ val onDismiss: () -> Unit,
+)
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/TipCardDecorator.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/TipCardDecorator.kt
new file mode 100644
index 0000000000..21c4e95a84
--- /dev/null
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/TipCardDecorator.kt
@@ -0,0 +1,118 @@
+package com.flipcash.app.scanner.internal.bills.decor
+
+import androidx.compose.animation.EnterExitState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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.Companion.BottomCenter
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.core.extensions.navigateAll
+import com.flipcash.app.core.extensions.openAsSheet
+import com.flipcash.app.core.tipping.LocalTipCoordinator
+import com.flipcash.app.core.tipping.TipEvent
+import com.flipcash.app.scanner.internal.ui.modals.TipUserModal
+import com.flipcash.app.session.LocalSessionController
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.ui.core.measured
+import com.getcode.ui.utils.AnimationUtils
+
+/**
+ * Decorator owned by a [Scannable.TipCard]. Placeholder home for the tip card's bottom modal
+ * (analogous to the payable "received funds" confirmation). Wire it here, gating `visible` on
+ * `context.liveBill is Scannable.TipCard` so it animates out on dismiss, and use the retained
+ * [tipCard] for content so it persists through the exit:
+ *
+ * ```
+ * AnimatedVisibility(
+ * modifier = Modifier.align(BottomCenter),
+ * visible = context.liveBill is Scannable.TipCard,
+ * enter = AnimationUtils.modalEnter(0),
+ * exit = AnimationUtils.modalExit,
+ * ) {
+ * TipCardModal(tipCard = tipCard, onDone = { context.onDismiss() })
+ * }
+ * ```
+ */
+internal data class TipCardDecorator(private val tipCard: Scannable.TipCard) : ScannableDecorator {
+ @Composable
+ override fun BoxScope.Content(context: ScannableDecoratorContext) {
+ val billState = context.billState
+ val session = LocalSessionController.current
+ val navigator = LocalCodeNavigator.current
+ val tipCoordinator = LocalTipCoordinator.current
+ val selection by tipCoordinator.selection.collectAsState()
+
+ val tipPresented = context.liveBill is Scannable.TipCard
+ // Can't afford the minimum tip → the modal stays hidden (gated by
+ // showManagementOptions); prompt to add money instead. The navigator lives here in
+ // the UI, so we route directly rather than plumbing a callback through the session.
+ LaunchedEffect(tipPresented, selection.canTip) {
+ if (tipPresented && !selection.canTip) {
+ session?.presentDepositOptions { route ->
+ navigator.openAsSheet(route)
+ // No giveable balance to tip with → steer to add-money / discover, and dismiss the
+ // tip card so we don't leave it stranded behind the prompt.
+ context.onDismiss()
+ }
+ }
+ }
+
+ // Handle the coordinator's one-shot UI events (e.g. open the add money flow after the
+ // insufficient-balance prompt's "Add Money"). Collected here, not in the modal, so it
+ // fires whether the tip modal is on screen or not.
+ LaunchedEffect(tipCoordinator) {
+ tipCoordinator.events.collect { event ->
+ when (event) {
+ is TipEvent.OpenRoute -> {
+ navigator.openAsSheet(event.route)
+ }
+ // Open the completed tip's chat with the tips list beneath it (navigateAll packs
+ // the chat into the tips sheet's back stack), so back returns to the list.
+ is TipEvent.LaunchChat -> {
+ navigator.navigateAll(
+ listOf(
+ AppRoute.Sheets.Tips(),
+ AppRoute.Messaging.Chat(event.identifier),
+ )
+ )
+ context.onDismiss()
+ }
+ }
+ }
+ }
+
+ AnimatedScannableDecorator(
+ visible = tipPresented && context.showManagementOptions,
+ enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis),
+ exit = AnimationUtils.modalExit,
+ modifier = Modifier.align(BottomCenter),
+ ) {
+ // Report the modal's height as soon as it starts entering (target Visible), not only
+ // once it settles — this lets the container move the tip card in sync with the modal's
+ // slide (both share the same enter timing). Reports 0 on exit so the card re-centers.
+ var modalHeight by remember { mutableStateOf(0.dp) }
+ val entering = transition.targetState == EnterExitState.Visible
+ LaunchedEffect(modalHeight, entering) {
+ context.onManagementHeightMeasured(if (entering) modalHeight else 0.dp)
+ }
+
+ Box(
+ modifier = Modifier.measured { modalHeight = it.height },
+ contentAlignment = BottomCenter,
+ ) {
+ TipUserModal(
+ card = tipCard,
+ )
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/modals/TipUserModal.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/modals/TipUserModal.kt
new file mode 100644
index 0000000000..ffb67c8576
--- /dev/null
+++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/modals/TipUserModal.kt
@@ -0,0 +1,216 @@
+package com.flipcash.app.scanner.internal.ui.modals
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+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.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.MoreHoriz
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.ColorFilter
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
+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.LocalTipCoordinator
+import com.flipcash.app.core.tipping.TipAmount
+import com.flipcash.app.core.tokens.TokenPurpose
+import com.flipcash.app.core.ui.TokenSelectionPill
+import com.flipcash.features.scanner.R
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Token
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White10
+import com.getcode.ui.components.Modal
+import com.getcode.ui.components.SlideToConfirm
+
+@Composable
+internal fun TipUserModal(
+ card: Scannable.TipCard,
+) {
+ val tip = LocalTipCoordinator.current
+ val navigator = LocalCodeNavigator.current
+
+ val selection by tip.selection.collectAsState()
+
+ // A newly presented tip card starts with a clean selection.
+ LaunchedEffect(card) { tip.selectAmount(null) }
+
+ Modal(
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset),
+ ) {
+ Text(
+ text = stringResource(id = R.string.title_sendTip),
+ style = CodeTheme.typography.displaySmall,
+ color = CodeTheme.colors.textMain,
+ )
+
+ PresetOptions(
+ // Presets follow the selected region reactively (see TippingCoordinator.selection).
+ presets = selection.presets,
+ selected = selection.amount?.value,
+ modifier = Modifier.fillMaxWidth(),
+ onPresetClicked = { tip.selectAmount(TipAmount.Preset(it)) },
+ onCustomClicked = { navigator.openAsSheet(AppRoute.Sheets.TipAmountEntry) },
+ )
+
+ TipTokenRow(
+ token = selection.token,
+ modifier = Modifier.fillMaxWidth(),
+ onSelectToken = {
+ val threshold = selection.amount?.value ?: selection.presets.firstOrNull()
+ navigator.openAsSheet(AppRoute.Sheets.TokenSelection(TokenPurpose.Tip(threshold)))
+ },
+ )
+
+ SlideToConfirm(
+ onConfirm = { tip.confirmTip() },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = selection.canConfirm,
+ isLoading = selection.sendState.loading,
+ isSuccess = selection.sendState.success,
+ label = stringResource(R.string.action_swipeToTip),
+ )
+ }
+}
+
+/** "of [token]" row — the token the tip will be sent in; tapping the pill opens the token picker. */
+@Composable
+private fun TipTokenRow(
+ token: Token?,
+ onSelectToken: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(
+ CodeTheme.dimens.grid.x2,
+ Alignment.CenterHorizontally
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.label_of),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textSecondary,
+ )
+ TokenSelectionPill(
+ token = token,
+ background = White10,
+ textStyle = CodeTheme.typography.textMedium,
+ imageSize = CodeTheme.dimens.staticGrid.x3,
+ contentPadding = PaddingValues(
+ horizontal = CodeTheme.dimens.grid.x1 + 1.dp,
+ vertical = CodeTheme.dimens.grid.x1 - 1.dp,
+ ),
+ onClick = onSelectToken,
+ )
+ }
+}
+
+@Composable
+private fun PresetOptions(
+ presets: List,
+ selected: Fiat?,
+ modifier: Modifier = Modifier,
+ onPresetClicked: (Fiat) -> Unit,
+ onCustomClicked: () -> Unit,
+) {
+ val isCustomSelected = selected != null && selected !in presets
+
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ presets.fastForEach { preset ->
+ TipAmount(
+ fiat = preset,
+ selected = preset == selected,
+ onClick = { onPresetClicked(preset) },
+ )
+ }
+
+ // Custom-amount slot: shows the entered value when a custom amount is set, otherwise a
+ // "more" affordance that opens the amount-entry sheet.
+ TipAmount(
+ fiat = selected.takeIf { isCustomSelected },
+ selected = isCustomSelected,
+ onClick = onCustomClicked,
+ )
+ }
+}
+
+@Composable
+private fun RowScope.TipAmount(
+ fiat: Fiat?,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val backgroundAlpha by animateFloatAsState(
+ if (selected) 1f else 0.10f
+ )
+
+ val foregroundColor by animateColorAsState(
+ if (selected) Color.Black else Color.White
+ )
+
+ val customAmountDescription = stringResource(R.string.content_description_customTipAmount)
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(CodeTheme.shapes.medium)
+ .background(
+ color = Color.White.copy(alpha = backgroundAlpha),
+ shape = CodeTheme.shapes.medium
+ )
+ .clickable(onClick = onClick)
+ .padding(vertical = CodeTheme.dimens.grid.x4)
+ .semantics {
+ contentDescription = fiat?.formatted(rule = Fiat.FormattingRule.Truncated)
+ ?: customAmountDescription
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (fiat != null) {
+ Text(
+ text = fiat.formatted(rule = Fiat.FormattingRule.Truncated),
+ style = CodeTheme.typography.textLarge,
+ color = foregroundColor,
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ Image(
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x6),
+ imageVector = Icons.Rounded.MoreHoriz,
+ colorFilter = ColorFilter.tint(foregroundColor),
+ contentDescription = null,
+ )
+ }
+ }
+}
diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts
index 9b6f5b0de6..55bf649140 100644
--- a/apps/flipcash/features/tipping/build.gradle.kts
+++ b/apps/flipcash/features/tipping/build.gradle.kts
@@ -8,8 +8,12 @@ android {
dependencies {
implementation(project(":services:flipcash"))
+ implementation(project(":services:opencode"))
+ implementation(project(":libs:messaging"))
+ implementation(project(":apps:flipcash:shared:amount-entry"))
implementation(project(":apps:flipcash:shared:bills"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
+ implementation(project(":apps:flipcash:shared:shareable"))
implementation(project(":apps:flipcash:shared:tipping"))
}
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt
new file mode 100644
index 0000000000..026e7b5629
--- /dev/null
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt
@@ -0,0 +1,45 @@
+package com.flipcash.app.tipping
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.tipping.internal.TipAmountEntryViewModel
+import com.flipcash.features.tipping.R
+import com.flipcash.shared.amountentry.AmountEntryScreen
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import kotlinx.coroutines.flow.filterIsInstance
+
+/**
+ * Custom tip-amount entry, presented as a bottom sheet over the still-visible tip card + modal.
+ * Confirming commits the amount to the shared tip selection (see [TipAmountEntryViewModel]) and
+ * dismisses the sheet; the underlying tip modal then reflects the chosen amount.
+ */
+@Composable
+fun TipAmountEntryScreen() {
+ val viewModel = hiltViewModel()
+ val navigator = LocalCodeNavigator.current
+
+ LaunchedEffect(viewModel) {
+ viewModel.eventFlow
+ .filterIsInstance()
+ .collect { navigator.pop() }
+ }
+
+ AmountEntryScreen(
+ controller = viewModel.amountDelegate,
+ onConfirm = { viewModel.dispatchEvent(TipAmountEntryViewModel.Event.ConfirmRequested) },
+ onChangeCurrency = { navigator.push(AppRoute.Main.RegionSelection) },
+ appBar = {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_amountToTip),
+ titleAlignment = Alignment.CenterHorizontally,
+ endContent = { AppBarDefaults.Close { navigator.hide() } },
+ )
+ },
+ )
+}
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt
new file mode 100644
index 0000000000..54a37d1969
--- /dev/null
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt
@@ -0,0 +1,131 @@
+package com.flipcash.app.tipping.internal
+
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.core.tipping.TipAmount
+import com.flipcash.app.core.ui.ConfirmationStyle
+import com.flipcash.shared.amountentry.AmountEntryDelegate
+import com.flipcash.shared.amountentry.AmountEntryLabel
+import com.flipcash.shared.amountentry.AmountEntryStyle
+import com.flipcash.shared.tipping.TippingCoordinator
+import com.flipcash.features.tipping.R
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.view.BaseViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+
+/**
+ * Backs the custom tip-amount entry sheet. Hosts an [AmountEntryDelegate] keypad (in the user's
+ * preferred currency, mirroring the tip presets) and commits the entered value to the shared tip
+ * selection on [TippingCoordinator], so the still-visible tip modal reflects it once the sheet is
+ * dismissed.
+ */
+@HiltViewModel
+internal class TipAmountEntryViewModel @Inject constructor(
+ exchange: Exchange,
+ resources: ResourceHelper,
+ private val tippingCoordinator: TippingCoordinator,
+) : BaseViewModel(
+ initialState = State(),
+ updateStateForEvent = updateStateForEvent,
+) {
+
+ data class State(
+ /** Currency the keypad is entering in — kept in sync with the preferred rate. */
+ val currency: CurrencyCode = CurrencyCode.USD,
+ )
+
+ sealed interface Event {
+ /** Preferred currency resolved/changed; keeps the entry currency in sync. */
+ data class CurrencyChanged(val currency: CurrencyCode) : Event
+
+ /** User asked to confirm the currently entered amount. */
+ data object ConfirmRequested : Event
+
+ /** The entered amount was committed to the tip selection — the screen should dismiss. */
+ data object Confirmed : Event
+ }
+
+ val amountDelegate = AmountEntryDelegate(
+ exchange = exchange,
+ scope = viewModelScope,
+ style = AmountEntryStyle(
+ actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_next)),
+ actionStyle = ConfirmationStyle.Button,
+ infoHint = { resources.getString(R.string.subtitle_sendHint, it) },
+ overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
+ belowMinHint = { resources.getString(R.string.subtitle_tipHintMinimum, it) },
+ ),
+ // Cap entry at the selected token's min(send limit, balance) — the "enter up to" hint —
+ // and floor it at the lowest preset so a custom amount can't undercut the presets.
+ maxAmount = tippingCoordinator.maxTipAmount,
+ minimumAmount = tippingCoordinator.minTipAmount,
+ )
+
+ init {
+ exchange.observePreferredRate()
+ .onEach { rate ->
+ exchange.getCurrency(rate.currency.name)?.let { amountDelegate.onCurrencyChanged(it) }
+ dispatchEvent(Event.CurrencyChanged(rate.currency))
+ }
+ .launchIn(viewModelScope)
+
+ // Prefill only when the current selection is itself a custom amount, so re-opening the sheet
+ // keeps a custom value — but opening it with a preset picked starts blank.
+ (tippingCoordinator.selection.value.amount as? TipAmount.Custom)?.let { custom ->
+ amountDelegate.prefill(custom.value.decimalValue)
+ }
+
+ // Commit the entered amount on confirm, then signal the screen to dismiss.
+ eventFlow
+ .filterIsInstance()
+ .onEach {
+ val entered = amountDelegate.state.value.enteredAmount
+ if (entered <= 0.0) return@onEach
+ val amount = Fiat(entered, stateFlow.value.currency)
+ // Floor at the lowest preset — a custom amount can't undercut the presets.
+ val min = tippingCoordinator.minTipAmount.value
+ if (min != null && amount.valueLessThan(min)) {
+ BottomBarManager.showAlert(
+ title = resources.getString(R.string.error_title_tipMinimum, min.formatted()),
+ message = resources.getString(R.string.error_description_tipMinimum),
+ )
+ return@onEach
+ }
+ // Over the send limit → block with the limit message.
+ if (tippingCoordinator.exceedsSendLimit(amount)) {
+ BottomBarManager.showAlert(
+ title = resources.getString(R.string.error_title_sendLimitReached),
+ message = resources.getString(R.string.error_description_sendLimitReached),
+ )
+ return@onEach
+ }
+ // Over the max (min(limit, balance)) but within the limit → over balance. Prompt to
+ // add money instead of committing, mirroring the send-time gate in confirmTip.
+ val max = tippingCoordinator.maxTipAmount.value
+ if (max != null && amount.valueGreaterThan(max)) {
+ tippingCoordinator.promptInsufficientBalance()
+ return@onEach
+ }
+ tippingCoordinator.selectAmount(TipAmount.Custom(amount))
+ dispatchEvent(Event.Confirmed)
+ }
+ .launchIn(viewModelScope)
+ }
+
+ companion object {
+ private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
+ when (event) {
+ is Event.CurrencyChanged -> { state -> state.copy(currency = event.currency) }
+ is Event.ConfirmRequested -> { state -> state }
+ is Event.Confirmed -> { state -> state }
+ }
+ }
+ }
+}
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 bc06fe4a24..5f7f9d8aee 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
@@ -5,8 +5,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import com.flipcash.app.core.bill.Scannable
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.services.models.chat.ChatType
import com.flipcash.services.user.UserManager
+import com.getcode.opencode.model.core.ID
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatSummary
import com.flipcash.shared.chat.ui.ConversationReference
@@ -14,6 +17,7 @@ import com.flipcash.shared.tipping.TippingCoordinator
import com.getcode.view.BaseViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
@@ -25,6 +29,7 @@ internal class TipFlowViewModel @Inject constructor(
chatCoordinator: ChatCoordinator,
userManager: UserManager,
tippingCoordinator: TippingCoordinator,
+ shareable: ShareSheetController,
) : BaseViewModel(
initialState = State(),
updateStateForEvent = updateStateForEvent,
@@ -47,6 +52,7 @@ internal class TipFlowViewModel @Inject constructor(
/** How the flow was entered — [resumed] is true for the post-setup handoff re-entry. */
data class OnResumed(val resumed: Boolean) : Event
data class OnTipCardPopulated(val card: Scannable.TipCard) : Event
+ data object ShareTipCard: Event
}
init {
@@ -79,13 +85,27 @@ internal class TipFlowViewModel @Inject constructor(
.launchIn(viewModelScope)
chatCoordinator.feed(ChatType.TIP_DM)
- .onEach { summaries -> dispatchEvent(Event.ChatsUpdated(summaries.map { it.toPreview() })) }
+ .onEach { summaries ->
+ val selfId = userManager.accountId
+ dispatchEvent(Event.ChatsUpdated(summaries.map { it.toPreview(selfId) }))
+ }
+ .launchIn(viewModelScope)
+
+ eventFlow
+ .filterIsInstance()
+ .mapNotNull { tippingCoordinator.currentUserId }
+ .map { shareable.present(Shareable.TipCard(it)) }
.launchIn(viewModelScope)
}
- private fun ChatSummary.toPreview(): ConversationReference {
+ private fun ChatSummary.toPreview(selfId: ID?): ConversationReference {
+ // A tip DM has no separate contact, so carry the counterparty's identity (name + avatar)
+ // straight from the chat member that isn't us.
+ val other = metadata.members.firstOrNull { it.userId != selfId }
return ConversationReference(
chatId = metadata.chatId,
+ displayName = other?.userProfile?.displayName,
+ image = other?.userProfile?.profilePicture,
lastMessagePreview = null, // TODO:
unreadCount = unreadCount,
)
@@ -99,6 +119,7 @@ internal class TipFlowViewModel @Inject constructor(
is Event.ChatsUpdated -> { state -> state.copy(tipChats = event.tips) }
is Event.OnResumed -> { state -> state.copy(resumed = event.resumed) }
is Event.OnTipCardPopulated -> { state -> state.copy(tipCard = event.card) }
+ is Event.ShareTipCard -> { state -> state }
}
}
}
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt
index 1ffffa5e2d..0713e4b181 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt
@@ -25,6 +25,7 @@ import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.tipping.TipResult
import com.flipcash.app.core.tipping.TipStep
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.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
@@ -63,11 +64,13 @@ internal fun TipCardScreen() {
CircularIconButton(
imageSize = CodeTheme.dimens.staticGrid.x6,
buttonSize = CodeTheme.dimens.grid.x12,
- onClick = {}
+ onClick = {
+ viewModel.dispatchEvent(Event.ShareTipCard)
+ }
) { size ->
Icon(
painter = painterResource(R.drawable.ic_remote_send),
- contentDescription = "",
+ contentDescription = null,
tint = Color.White,
modifier = Modifier.requiredSize(size),
)
@@ -89,7 +92,7 @@ internal fun TipCardScreen() {
) {
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10),
- text = "Share Your Tipcard to Get Tipped",
+ text = stringResource(R.string.subtitle_myTipCard),
style = CodeTheme.typography.textLarge,
color = CodeTheme.colors.textMain,
)
diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt
index e1d87545f7..ddd2224745 100644
--- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt
+++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt
@@ -5,20 +5,36 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.tipping.TipStep
import com.flipcash.app.tipping.internal.TipFlowViewModel
import com.flipcash.features.tipping.R
+import com.flipcash.services.models.chat.MediaItemRendition
+import com.flipcash.shared.chat.ui.ChatListRow
+import com.flipcash.shared.chat.ui.ChatRowSubtitle
+import com.flipcash.shared.chat.ui.ChatRowTrailing
+import com.flipcash.shared.chat.ui.ConversationReference
+import com.flipcash.shared.chat.ui.SubtitleText
+import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.theme.CodeTheme
+import com.getcode.theme.White10
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.theme.ButtonState
@@ -58,7 +74,7 @@ internal fun TipsScreen() {
CodeButton(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = CodeTheme.dimens.inset),
- text = "Show My Tipcard",
+ text = stringResource(R.string.action_showTipCard),
buttonState = ButtonState.Filled,
onClick = {
navigator.navigate(TipStep.TipCard)
@@ -67,9 +83,60 @@ internal fun TipsScreen() {
}
}
- items(state.tipChats) { chat ->
-
+ itemsIndexed(state.tipChats) { index, chat ->
+ TipChatRow(
+ chat = chat,
+ showDivider = index < state.tipChats.lastIndex,
+ ) {
+ navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)))
+ }
}
}
}
}
+
+@Composable
+private fun TipChatRow(
+ chat: ConversationReference,
+ modifier: Modifier = Modifier,
+ showDivider: Boolean = true,
+ onClick: () -> Unit,
+) {
+ ChatListRow(
+ modifier = modifier,
+ avatar = {
+ ContactAvatar(
+ photoUri = chat.image?.url(preferred = MediaItemRendition.Role.THUMBNAIL),
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x8)
+ .clip(CircleShape),
+ displayName = chat.displayName.orEmpty(),
+ )
+ },
+ title = {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = chat.displayName.orEmpty(),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+
+ ChatRowTrailing(
+ lastActivity = null,
+ unreadCount = chat.unreadCount,
+ canOpen = true,
+ )
+ },
+ subtitle = {
+ ChatRowSubtitle(
+ isTyping = chat.isTyping,
+ preview = chat.lastMessagePreview,
+ fallback = {
+ SubtitleText("")
+ }
+ )
+ },
+ showDivider = showDivider,
+ onClick = onClick,
+ )
+}
\ No newline at end of file
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt
index 95094f67d7..044f9ca7cb 100644
--- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenSelectScreen.kt
@@ -3,6 +3,7 @@ package com.flipcash.app.tokens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -15,6 +16,8 @@ import com.flipcash.app.tokens.internal.SelectTokenScreen
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.flipcash.features.tokens.R
import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.navigation.flow.FlowDismissStyle
+import com.getcode.navigation.flow.LocalFlowDismissStyle
import com.getcode.ui.components.AppBarWithTitle
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
@@ -30,24 +33,34 @@ fun TokenSelectScreen(
val navigator = LocalCodeNavigator.current
val viewModel = hiltViewModel()
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- if (showTopBar) {
- AppBarWithTitle(
- title = when (purpose) {
- is TokenPurpose.Swap -> stringResource(R.string.title_selectPaymentCurrency)
- is TokenPurpose.LaunchFunding -> stringResource(R.string.title_selectPaymentCurrency)
- else -> stringResource(R.string.title_selectCurrency)
- },
- backButton = true,
- onBackIconClicked = { navigator.pop() },
- titleAlignment = Alignment.CenterHorizontally,
- )
- }
+ // Standalone selection sheets (Select / Tip) dismiss with a close (X); when the screen is a
+ // step pushed onto another stack we keep the ambient style — a flow host's back arrow (Swap) or
+ // the default. AppBarWithTitle auto-swaps the icon off LocalFlowDismissStyle.
+ val dismissStyle = when (purpose) {
+ is TokenPurpose.Tip -> FlowDismissStyle.Close
+ else -> LocalFlowDismissStyle.current
+ }
- SelectTokenScreen(viewModel)
+ CompositionLocalProvider(LocalFlowDismissStyle provides dismissStyle) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (showTopBar) {
+ AppBarWithTitle(
+ title = when (purpose) {
+ is TokenPurpose.Swap -> stringResource(R.string.title_selectPaymentCurrency)
+ is TokenPurpose.LaunchFunding -> stringResource(R.string.title_selectPaymentCurrency)
+ else -> stringResource(R.string.title_selectCurrency)
+ },
+ backButton = true,
+ onBackIconClicked = { navigator.pop() },
+ titleAlignment = Alignment.CenterHorizontally,
+ )
+ }
+
+ SelectTokenScreen(viewModel)
+ }
}
LaunchedEffect(viewModel) {
@@ -80,6 +93,7 @@ fun TokenSelectScreen(
navigator.push(Deposit())
}
+ is TokenPurpose.Tip -> Unit
is TokenPurpose.LaunchFunding -> Unit
is TokenPurpose.Swap -> Unit
}
diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt
index 080c0993da..38a3031ff1 100644
--- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt
+++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/internal/TokenSelectScreen.kt
@@ -55,6 +55,7 @@ private fun SelectTokenScreenContent(
is TokenPurpose.Swap -> TokenSelectionStyle.Chevron
is TokenPurpose.LaunchFunding -> TokenSelectionStyle.Chevron
is TokenPurpose.Select -> TokenSelectionStyle.Checkbox
+ is TokenPurpose.Tip -> TokenSelectionStyle.Checkbox
TokenPurpose.Withdraw -> TokenSelectionStyle.Chevron
}
),
@@ -63,13 +64,18 @@ private fun SelectTokenScreenContent(
is TokenPurpose.Select -> false
is TokenPurpose.Swap -> false
is TokenPurpose.LaunchFunding -> false
+ is TokenPurpose.Tip -> false
else -> true
},
- enableGreaterThanAmount = { _, amount ->
+ enableGreaterThanAmount = atLeast@{ _, amount ->
when (val purpose = state.purpose) {
is TokenPurpose.LaunchFunding -> {
amount.nativeAmount.valueGreaterThanOrEqualTo(purpose.amount)
}
+ is TokenPurpose.Tip -> {
+ val target = purpose.amount ?: return@atLeast true
+ amount.nativeAmount.valueGreaterThanOrEqualTo(target)
+ }
else -> true
}
},
diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt
index 58ddcbd364..5f96e7ce67 100644
--- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt
+++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt
@@ -31,6 +31,7 @@ fun AnimatedScannable(
transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform,
contentKey: (Scannable?) -> Any? = { it },
bill: Scannable?,
+ scannableAlignment: Alignment = Alignment.Center,
) {
AnimatedContent(
modifier = modifier,
@@ -52,7 +53,8 @@ fun AnimatedScannable(
.fillMaxWidth()
.weight(1f)
.padding(contentPadding),
- scannable = b
+ scannable = b,
+ contentAlignment = scannableAlignment,
)
}
},
diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt
index 0c002a1eae..05052459b6 100644
--- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt
+++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt
@@ -2,6 +2,7 @@ package com.flipcash.app.bills
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
@@ -21,6 +22,9 @@ import com.getcode.theme.CodeTheme
fun ScannableRenderer(
modifier: Modifier = Modifier,
scannable: Scannable,
+ // Vertical placement of the card within its area — used to pin a tip card just above its
+ // bottom modal; ignored by bill types that always center.
+ contentAlignment: Alignment = Alignment.Center,
) {
when (scannable) {
is Scannable.CashBill -> CashBill(
@@ -37,7 +41,8 @@ fun ScannableRenderer(
is Scannable.TipCard -> TipCard(
modifier = modifier,
payloadData = scannable.data,
- user = scannable.user
+ user = scannable.user,
+ contentAlignment = contentAlignment,
)
}
}
diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt
index d21b1baa37..b11da54de6 100644
--- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt
+++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt
@@ -20,9 +20,12 @@ 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.compositeOver
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flipcash.app.bills.components.ScannableCode
import com.flipcash.services.models.UserProfile
+import com.flipcash.shared.bills.R
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme
import com.getcode.theme.xxl
@@ -33,18 +36,23 @@ internal fun TipCard(
payloadData: List,
user: UserProfile,
modifier: Modifier = Modifier,
+ contentAlignment: Alignment = Alignment.Center,
) {
BoxWithConstraints(
modifier = modifier
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.padding(horizontal = CodeTheme.dimens.inset),
- contentAlignment = Alignment.Center
+ contentAlignment = contentAlignment
) {
val mW = this.maxWidth
val codeSize = remember { mW * 0.65f }
Column(
- modifier = Modifier.background(CodeTheme.colors.tipCardColor, shape = CodeTheme.shapes.xxl)
+ modifier = Modifier
+ .background(
+ CodeTheme.colors.tipCardColor,
+ shape = CodeTheme.shapes.xxl,
+ )
.padding(vertical = CodeTheme.dimens.grid.x8, horizontal = CodeTheme.dimens.grid.x7)
.heightIn(0.dp, 800.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -62,7 +70,7 @@ internal fun TipCard(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = "Tip",
+ text = stringResource(R.string.label_tip),
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textMain,
)
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt
index e1ee984d79..99660b8ac1 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt
@@ -1,10 +1,15 @@
package com.flipcash.shared.chat.ui
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.MediaItem
/** Presentation state derived from an existing DM with a contact. */
data class ConversationReference(
val chatId: ChatId,
+ /** Counterparty display name — used when the row has no separate contact (e.g. tip DMs). */
+ val displayName: String? = null,
+ /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */
+ val image: MediaItem? = null,
val lastMessagePreview: String? = null,
val unreadCount: Int = 0,
val isTyping: Boolean = false,
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
index 808d04b3b0..f49c83c0b0 100644
--- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
@@ -86,6 +86,7 @@ fun ContactAvatar(
modifier = Modifier.matchParentSize(),
model = request,
contentDescription = null,
+ contentScale = ContentScale.FillBounds,
onError = { isError = true },
)
}
@@ -123,6 +124,10 @@ fun ContactAvatar(
AsyncImage(
modifier = Modifier.matchParentSize(),
model = request,
+ // Crop fills the avatar while preserving aspect ratio (Fit letterboxed and let
+ // the gradient show through; FillBounds fills but distorts the photo).
+ // AsyncImage infers the Coil request scale from this, so the request is fine.
+ contentScale = ContentScale.Crop,
contentDescription = null,
onError = { isError = true },
)
diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts
index c371850af3..3488b33609 100644
--- a/apps/flipcash/shared/notifications/build.gradle.kts
+++ b/apps/flipcash/shared/notifications/build.gradle.kts
@@ -20,6 +20,10 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
+ // Coil core (no Compose) to load remote profile-picture avatars for chat
+ // notifications, reusing the app's SingletonImageLoader + disk cache.
+ implementation(libs.coil3.core)
+
implementation(libs.androidx.datastore)
testImplementation(kotlin("test"))
diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
index 7c50f922f6..a4e2d42a97 100644
--- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
+++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt
@@ -17,6 +17,11 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
+import coil3.SingletonImageLoader
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.request.allowHardware
+import coil3.toBitmap
import com.flipcash.app.auth.AuthManager
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.contacts.ContactResolver
@@ -24,7 +29,10 @@ import com.flipcash.app.core.util.Linkify
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.PushController
+import com.flipcash.services.models.SocialAccount
+import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.MediaItemRendition
import com.flipcash.services.models.NavigationTrigger
import com.flipcash.services.models.NotificationCategory
import com.flipcash.services.models.NotificationPayload
@@ -39,8 +47,10 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import java.security.SecureRandom
import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class NotificationService : FirebaseMessagingService(),
@@ -50,6 +60,12 @@ class NotificationService : FirebaseMessagingService(),
private const val KEY_TITLE = "push_notification_title"
private const val KEY_BODY = "push_notification_body"
private const val KEY_PAYLOAD = "flipcash_payload"
+
+ // Upper bound on how long we'll wait for a remote avatar before posting
+ // without one. A memory/disk cache hit returns well under this; the
+ // bound only caps the cold-cache network fetch so the notification isn't
+ // held back indefinitely.
+ private const val AVATAR_FETCH_TIMEOUT_MS = 5_000L
}
@Inject
@@ -188,7 +204,7 @@ class NotificationService : FirebaseMessagingService(),
}
val notificationId = if (chatId != null) {
- builder.applyContactChatStyle(chatId, groupKey, body)
+ builder.applyChatStyle(chatId, groupKey, title, body)
} else {
builder.setContentTitle(title).setContentText(body)
SecureRandom().nextInt(Int.MAX_VALUE)
@@ -209,32 +225,49 @@ class NotificationService : FirebaseMessagingService(),
}
}
- private suspend fun NotificationCompat.Builder.applyContactChatStyle(
+ private suspend fun NotificationCompat.Builder.applyChatStyle(
chatId: ChatId,
groupKey: String?,
+ title: String?,
body: String?,
): Int {
val notificationId = chatId.hashCode()
- val lookupContact = contactCoordinator.lookupContactByDmChatId(chatId.toString())
- val e164 = lookupContact?.e164
- ?: chatCoordinator.getOtherMemberE164(chatId)
+
+ // Prefer the device-contact identity (CONTACT_DM, or a counterparty saved
+ // in the address book): the user's own name + photo for them. Only when
+ // that's absent do we fetch the chat member and fall back to their
+ // server-side profile — which is the only identity a TIP_DM has.
+ val contactE164 = contactCoordinator.lookupContactByDmChatId(chatId.toString())?.e164
+ val member = if (contactE164 == null) chatCoordinator.getOtherMember(chatId) else null
+ val e164 = contactE164 ?: member?.userProfile?.verifiedPhoneNumber
+
+ val senderName = e164?.let { contactResolver.resolveName(it) }
+ ?: member?.userProfile?.displayName?.takeIf { it.isNotBlank() }
+ ?: member?.userProfile?.socialHandle()
+ ?: title
+ ?: ""
+
+ // Device-contact photo (local, synchronous) first; otherwise the profile
+ // picture URL loaded through the app's shared Coil loader (cache-first,
+ // network-bounded). Works for CONTACT_DM and TIP_DM alike.
+ val avatar = e164?.let { resolveContactPhoto(it) }
+ ?: member?.userProfile?.profilePicture
+ ?.url(MediaItemRendition.Role.THUMBNAIL)
+ ?.let { loadRemoteAvatar(it) }
trace(
tag = "NotificationService",
- message = "applyContactChatStyle: chatId=$chatId, groupKey=$groupKey, lookupE164=${lookupContact?.e164}, e164=$e164, authenticated=${userManager.accountCluster != null}",
+ message = "applyChatStyle: chatId=$chatId, groupKey=$groupKey, e164=$e164, hasMember=${member != null}, hasAvatar=${avatar != null}, authenticated=${userManager.accountCluster != null}",
type = TraceType.Log,
)
- val contactPhoto = e164?.let { resolveContactPhoto(it) }
- val senderName = e164?.let { contactResolver.resolveName(it) } ?: ""
-
val selfPerson = buildSelfPerson(this@NotificationService, userManager.profile, contactResolver)
val senderPerson = Person.Builder()
.setName(senderName)
.setKey(groupKey ?: "unknown")
.apply {
- if (contactPhoto != null) setIcon(IconCompat.createWithBitmap(contactPhoto.toCircularBitmap()))
+ if (avatar != null) setIcon(IconCompat.createWithBitmap(avatar.toCircularBitmap()))
}
.build()
@@ -254,6 +287,33 @@ class NotificationService : FirebaseMessagingService(),
return notificationId
}
+ /** First social handle (e.g. an X username) to render as a display name, if any. */
+ private fun UserProfile.socialHandle(): String? =
+ socialAccounts.filterIsInstance()
+ .firstOrNull()
+ ?.username
+ ?.let { "@$it" }
+
+ /**
+ * Loads a remote avatar [url] into a software [Bitmap] via the app's shared
+ * Coil [SingletonImageLoader]. Serves from memory/disk cache without a
+ * network trip when possible; the network case is bounded by
+ * [AVATAR_FETCH_TIMEOUT_MS] so a slow fetch never holds back the
+ * notification. Returns `null` on timeout or failure (the notification then
+ * posts with the name monogram).
+ */
+ private suspend fun loadRemoteAvatar(url: String): Bitmap? =
+ withTimeoutOrNull(AVATAR_FETCH_TIMEOUT_MS.milliseconds) {
+ runCatching {
+ val request = ImageRequest.Builder(this@NotificationService)
+ .data(url)
+ .allowHardware(false) // notification icons require a software bitmap
+ .build()
+ val result = SingletonImageLoader.get(this@NotificationService).execute(request)
+ (result as? SuccessResult)?.image?.toBitmap()
+ }.getOrNull()
+ }
+
private suspend fun resolveContactPhoto(e164: String): Bitmap? {
val uriString = contactResolver.resolvePhotoUri(e164)
if (uriString != null) {
diff --git a/apps/flipcash/shared/router/build.gradle.kts b/apps/flipcash/shared/router/build.gradle.kts
index 7792411246..b41f1af072 100644
--- a/apps/flipcash/shared/router/build.gradle.kts
+++ b/apps/flipcash/shared/router/build.gradle.kts
@@ -10,6 +10,8 @@ dependencies {
api(project(":ui:navigation"))
api(libs.rinku.compose)
+ implementation(project(":libs:models"))
+
testImplementation(kotlin("test"))
testImplementation(libs.robolectric)
}
diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
index 433d551e1d..3c638b59d3 100644
--- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
+++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt
@@ -15,15 +15,18 @@ import com.flipcash.app.router.Router
import com.flipcash.app.router.internal.AppRouter.Companion.cashLink
import com.flipcash.app.router.internal.AppRouter.Companion.chat
import com.flipcash.app.router.internal.AppRouter.Companion.login
+import com.flipcash.app.router.internal.AppRouter.Companion.tip
import com.flipcash.app.router.internal.AppRouter.Companion.token
import com.flipcash.app.router.internal.AppRouter.Companion.verification
import com.flipcash.services.user.AuthState
+import com.getcode.opencode.model.core.bytes
import com.getcode.solana.keys.Mint
import com.getcode.utils.decodeBase64
import com.getcode.utils.decodeBase64UrlSafe
import com.getcode.utils.urlDecode
import dev.theolm.rinku.DeepLink
import org.json.JSONObject
+import java.util.UUID
internal class AppRouter(
private val authStateProvider: () -> AuthState,
@@ -34,6 +37,7 @@ internal class AppRouter(
val verification = listOf("verify")
val token = listOf("token")
val chat = listOf("chat")
+ val tip = listOf("tip")
}
override fun dispatch(deepLink: DeepLink): DeeplinkAction {
@@ -63,6 +67,8 @@ internal class AppRouter(
is DeeplinkType.Chat -> DeeplinkAction.Navigate(
listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(type.identifier))
)
+
+ is DeeplinkType.Tipcard -> DeeplinkAction.PresentTipCard(type.userId)
}
}
@@ -73,6 +79,7 @@ internal class AppRouter(
deepLink.isToken() -> deepLink.handleTokenLink()
deepLink.isEmailVerification() -> deepLink.handleEmailVerification()
deepLink.isChat() -> deepLink.handleChat()
+ deepLink.isTipCard() -> deepLink.handleTipCard()
else -> null
}
}
@@ -128,6 +135,8 @@ private fun DeepLink.isEmailVerification(): Boolean = verification.contains(path
private fun DeepLink.isChat(): Boolean = chat.contains(pathSegments.getOrNull(0))
+private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0))
+
private fun DeepLink.handleLoginLink(): DeeplinkType.Login? {
val uri = data.toUri()
var entropy = uri.fragments[Key.entropy]
@@ -170,6 +179,15 @@ private fun DeepLink.handleChat(): DeeplinkType.Chat? {
return DeeplinkType.Chat(identifier)
}
+private fun DeepLink.handleTipCard(): DeeplinkType.Tipcard? {
+ val uri = data.toUri()
+ val userId = uri.pathSegments.getOrNull(1)
+ ?.let { runCatching { UUID.fromString(it).bytes }.getOrNull() }
+ ?: return null
+
+ return DeeplinkType.Tipcard(userId)
+}
+
// https://app.flipcash.com/verify?email={email}&code={code}&client_data={data}
private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? {
val uri = data.toUri()
diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts
index 58cd9629d2..49d318eacd 100644
--- a/apps/flipcash/shared/session/build.gradle.kts
+++ b/apps/flipcash/shared/session/build.gradle.kts
@@ -22,6 +22,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:payments"))
implementation(project(":apps:flipcash:shared:shareable"))
+ implementation(project(":apps:flipcash:shared:tipping"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":apps:flipcash:shared:workers"))
implementation(project(":services:flipcash"))
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 04ab50fa0f..89760ac8db 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
@@ -6,6 +6,7 @@ import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.session.BillDeterminationResult.ActedUpon
import com.getcode.opencode.model.financial.Token
import com.flipcash.app.core.AppRoute
+import com.getcode.opencode.model.core.ID
import com.getcode.ui.core.RestrictionType
import com.kik.kikx.models.ScannableKikCode
import kotlinx.coroutines.flow.StateFlow
@@ -21,13 +22,6 @@ data object PutInWallet : BillDeterminationResult, ActedUpon
interface BillOperations {
val billState: StateFlow
fun showBill(bill: Scannable.Payable)
-
- /**
- * Presents a non-payment [Scannable.TipCard] in the bill container. Unlike [showBill],
- * this runs no grab/await transaction and sets no valuation — the card is simply shown
- * until dismissed.
- */
- fun showTipCard(tipCard: Scannable.TipCard)
fun dismissBill(action: BillDeterminationResult)
}
@@ -40,6 +34,10 @@ interface CashLinkOperations {
fun openCashLink(cashLink: String?)
}
+interface TipCardOperations {
+ fun resolveTipCard(user: ID)
+}
+
interface DepositOperations {
/**
* Presents the appropriate "you can't give yet" prompt based on the user's balance:
@@ -49,7 +47,7 @@ interface DepositOperations {
fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null)
}
-interface SessionController : BillOperations, CodeScanOperations, CashLinkOperations, DepositOperations {
+interface SessionController : BillOperations, CodeScanOperations, CashLinkOperations, DepositOperations, TipCardOperations {
val state: StateFlow
fun onAppInForeground()
fun onAppInBackground()
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 d8fbdaa6c5..5b100350d0 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
@@ -21,11 +21,13 @@ import com.flipcash.app.session.DepositOperations
import com.flipcash.app.session.PutInWallet
import com.flipcash.app.session.SessionController
import com.flipcash.app.session.SessionState
+import com.flipcash.app.session.TipCardOperations
import com.flipcash.app.session.internal.delegates.BillPresentationDelegate
import com.flipcash.app.session.internal.delegates.CashLinkDelegate
import com.flipcash.app.session.internal.delegates.CodeScanDelegate
import com.flipcash.app.session.internal.delegates.DepositDelegate
import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate
+import com.flipcash.app.session.internal.delegates.TipCardDelegate
import com.flipcash.app.session.internal.toast.SessionToastController
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.tokens.TokenCoordinator
@@ -55,15 +57,16 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
- * Thin orchestration shell that implements [SessionController] by composing five
+ * Thin orchestration shell that implements [SessionController] by composing six
* focused delegates via Kotlin `by` interface delegation:
*
* | Delegate | Interface | Responsibility |
* |----------|-----------|----------------|
- * | [com.flipcash.app.session.internal.delegates.BillPresentationDelegate] | [BillOperations] | Creating, presenting, and dismissing cash bills |
+ * | [com.flipcash.app.session.internal.delegates.BillPresentationDelegate] | [BillOperations] | Creating, presenting, and dismissing cash bills (and presenting resolved tip cards) |
* | [com.flipcash.app.session.internal.delegates.CodeScanDelegate] | [CodeScanOperations] | QR/Kik-code scanning and grab attempts |
* | [com.flipcash.app.session.internal.delegates.CashLinkDelegate] | [CashLinkOperations] | Cash-link claiming |
* | [com.flipcash.app.session.internal.delegates.DepositDelegate] | [DepositOperations] | Deposit options and USDC sweep |
+ * | [com.flipcash.app.session.internal.delegates.TipCardDelegate] | [TipCardOperations] | Resolving another user's tip card for presentation |
* | [com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate] | *(internal)* | "Send as Link" gift-card funding + share |
*
* **What lives here (and why):**
@@ -87,6 +90,7 @@ class RealSessionController @Inject constructor(
private val cashLinkDelegate: CashLinkDelegate,
private val depositDelegate: DepositDelegate,
private val giftCardDelegate: GiftCardSharingDelegate,
+ private val tippingDelegate: TipCardDelegate,
private val stateHolder: SessionStateHolder,
private val billController: BillController,
private val userManager: UserManager,
@@ -110,7 +114,8 @@ class RealSessionController @Inject constructor(
) : SessionController, BillOperations by billDelegate,
CodeScanOperations by scanDelegate,
CashLinkOperations by cashLinkDelegate,
- DepositOperations by depositDelegate {
+ DepositOperations by depositDelegate,
+ TipCardOperations by tippingDelegate {
private val scope = CoroutineScope(dispatchers.IO + SupervisorJob())
@@ -135,6 +140,17 @@ class RealSessionController @Inject constructor(
is CodeScanDelegate.Event.BillReady -> showBill(event.bill)
is CodeScanDelegate.Event.RefreshFeed -> bringActivityFeedCurrent()
is CodeScanDelegate.Event.CheckPendingFeed -> checkPendingItemsInFeed()
+ is CodeScanDelegate.Event.TipCardScanned -> resolveTipCard(event.userId)
+ }
+ }.launchIn(scope)
+
+ // Tip card resolved (via scan or deeplink) → hand to the bill delegate to present.
+ // Presentation is already balance-gated in TipCardDelegate, so an unaffordable
+ // tip never reaches here (no card to dismiss).
+ tippingDelegate.events
+ .onEach { event ->
+ when (event) {
+ is TipCardDelegate.Event.Present -> billDelegate.presentTipCard(event.card)
}
}.launchIn(scope)
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 c6bfd7686e..4fb65d8866 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
@@ -112,12 +112,19 @@ class BillPresentationDelegate @Inject constructor(
}
}
- override fun showTipCard(tipCard: Scannable.TipCard) {
+ /**
+ * Presents an already-resolved, non-payment [Scannable.TipCard] in the bill container.
+ * Unlike [showBill] there's no grab/await transaction and no valuation — the card is just
+ * placed until dismissed. Not part of the public [BillOperations] surface: the single public
+ * entry point for tip cards is [com.flipcash.app.session.TipCardOperations.resolveTipCard],
+ * which resolves the card and routes here through the [com.flipcash.app.session.internal.RealSessionController] shell.
+ */
+ internal fun presentTipCard(tipCard: Scannable.TipCard) {
// Single bill slot — don't clobber a bill that's already presented.
if (billController.state.value.bill != null) return
// No grab/await, no valuation: just place the card in the container.
billController.update { it.copy(bill = tipCard, valuation = null) }
- stateHolder.update { it.copy(billResult = PutInWallet) }
+ stateHolder.update { it.copy(billResult = Grabbed) }
}
override fun dismissBill(action: BillDeterminationResult) {
diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt
index 670f48b46a..20915ad21d 100644
--- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt
+++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt
@@ -10,6 +10,7 @@ import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.user.UserManager
import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.core.OpenCodePayload
import com.getcode.opencode.model.core.PayloadKind
import com.getcode.util.vibration.Vibrator
@@ -54,6 +55,9 @@ class CodeScanDelegate @Inject constructor(
data class BillReady(val bill: Scannable.Payable) : Event
data object RefreshFeed : Event
data object CheckPendingFeed : Event
+
+ /** A tip [OpenCodePayload] was scanned; the shell resolves & presents the card for [userId]. */
+ data class TipCardScanned(val userId: ID) : Event
}
private val _events = Channel(Channel.UNLIMITED)
@@ -92,9 +96,7 @@ class CodeScanDelegate @Inject constructor(
when (codePayload.kind) {
PayloadKind.Cash -> onCashScanned(codePayload)
PayloadKind.MultiMintCash -> onCashScanned(codePayload)
- // TODO(tipping): route tip scans to the tip flow. Deliberately not handled as a cash
- // grab — onCashScanned force-unwraps payload.fiat, which is null for a Tip payload.
- PayloadKind.Tip -> Unit
+ PayloadKind.Tip -> onTipCardScanned(codePayload)
PayloadKind.Unknown -> Unit
}
}
@@ -143,4 +145,12 @@ class CodeScanDelegate @Inject constructor(
}
)
}
+
+ private fun onTipCardScanned(payload: OpenCodePayload) {
+ // Tip payloads carry the recipient's user id (see OpenCodePayload layout 2), not a
+ // rendezvous grab. Hand the id to the shell, which routes to TipCardOperations to
+ // resolve and present the card.
+ val userId = payload.userId ?: return
+ _events.trySend(Event.TipCardScanned(userId))
+ }
}
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
new file mode 100644
index 0000000000..7e3f6e794e
--- /dev/null
+++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt
@@ -0,0 +1,89 @@
+package com.flipcash.app.session.internal.delegates
+
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.session.TipCardOperations
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.flipcash.shared.tipping.TippingCoordinator
+import com.getcode.opencode.model.core.ID
+import com.getcode.utils.trace
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.consumeAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Implements [TipCardOperations] — the single public entry point for presenting another
+ * user's tip card, whether it arrives via a deeplink (`/tip/{userId}`), a scanned QR link,
+ * or a scanned OpenCode tip payload (see [CodeScanDelegate.onTipCardScanned]).
+ *
+ * 1. Resolves [ID] to a [Scannable.TipCard] via [TippingCoordinator.resolveTipCard]
+ * (a server-backed profile fetch).
+ * 2. Coalesces concurrent resolves for the same user via [inFlight] so repeated camera
+ * frames don't fan out into redundant fetches.
+ * 3. On success emits [Event.Present]; the [com.flipcash.app.session.internal.RealSessionController]
+ * shell collects it and hands the resolved card to [BillPresentationDelegate.presentTipCard]
+ * (which owns writes to the bill container). Presentation itself is intentionally not done
+ * here — this delegate resolves, the bill delegate shows.
+ *
+ * @see com.flipcash.app.session.internal.RealSessionController
+ */
+@Singleton
+class TipCardDelegate @Inject constructor(
+ private val tippingCoordinator: TippingCoordinator,
+ dispatchers: DispatcherProvider,
+) : TipCardOperations {
+
+ sealed interface Event {
+ data class Present(val card: Scannable.TipCard) : Event
+ }
+
+ private val scope = CoroutineScope(dispatchers.IO + SupervisorJob())
+
+ private val _events = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.consumeAsFlow()
+
+ // Users with an in-flight resolve — coalesces duplicate requests (e.g. repeated scan frames).
+ private val inFlight = MutableStateFlow>(emptySet())
+
+ override fun resolveTipCard(user: ID) {
+ if (!inFlight.add(user)) return
+
+ scope.launch {
+ tippingCoordinator.resolveTipCard(user)
+ .onSuccess { card ->
+ // Always present the card. Whether the tip modal slides up (or an
+ // add-money prompt shows instead) is decided in the UI from the
+ // coordinator's affordability state — see TipCardDecorator.
+ _events.trySend(Event.Present(card))
+ }
+ .onFailure {
+ trace(
+ tag = "Session",
+ message = "Failed to resolve tip card for user",
+ error = it,
+ )
+ }
+
+ inFlight.remove(user)
+ }
+ }
+
+ /** Atomically adds [user]; returns true only if it wasn't already in flight. */
+ private fun MutableStateFlow>.add(user: ID): Boolean {
+ var added = false
+ update { current ->
+ added = user !in current
+ // plusElement (not `+`): ID is List, so `+` would pick the Iterable overload.
+ if (added) current.plusElement(user) else current
+ }
+ return added
+ }
+
+ private fun MutableStateFlow>.remove(user: ID) = update { it.minusElement(user) }
+}
diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt
index a373191e85..6892b4ef57 100644
--- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt
+++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt
@@ -4,6 +4,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
import com.getcode.ed25519.Ed25519
import com.getcode.opencode.model.accounts.GiftCardAccount
+import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
import kotlin.time.Duration
@@ -40,6 +41,12 @@ sealed interface Shareable {
data object Invite : Shareable {
override val pendingData: ShareablePendingData? = null
}
+
+ data class TipCard(
+ val userId: ID
+ ): Shareable {
+ override val pendingData: ShareablePendingData? = null
+ }
}
sealed interface ShareablePendingData {
diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt
index 01dbeefeec..896893b07c 100644
--- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt
+++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt
@@ -28,6 +28,7 @@ internal class InternalShareConfirmationController(
is Shareable.DownloadLink -> ShareConfirmationResult.Confirmed(shareResult)
is Shareable.TokenInfo -> ShareConfirmationResult.Confirmed(shareResult)
is Shareable.Invite -> ShareConfirmationResult.Confirmed(shareResult)
+ is Shareable.TipCard -> ShareConfirmationResult.Confirmed(shareResult)
}
}
diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt
index fbd6048cfa..2585a12746 100644
--- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt
+++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt
@@ -22,6 +22,7 @@ import com.flipcash.app.shareable.ShareablePendingData.CashLink
import com.flipcash.shared.shareable.R
import com.getcode.opencode.model.accounts.GiftCardAccount
import com.getcode.opencode.model.accounts.entropy
+import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
@@ -32,6 +33,7 @@ import java.security.SecureRandom
import java.util.Timer
import java.util.TimerTask
import kotlin.concurrent.schedule
+import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -100,6 +102,7 @@ internal class InternalShareSheetController(
Shareable.DownloadLink -> Unit
is Shareable.TokenInfo -> Unit
is Shareable.Invite -> Unit
+ is Shareable.TipCard -> Unit
}
}
}
@@ -125,7 +128,7 @@ internal class InternalShareSheetController(
pendingShareable = shareable.copy(pendingData = pendingData)
shareCashLink(shareable.giftCardAccount, shareable.amount)
- delay(300)
+ delay(300.milliseconds)
isChecking = true
LocalBroadcastManager.getInstance(context).registerReceiver(
shareResultReceiver,
@@ -144,6 +147,8 @@ internal class InternalShareSheetController(
is Shareable.Invite -> {
shareInviteLink()
}
+
+ is Shareable.TipCard -> shareTipCard(shareable.userId)
}
}
@@ -283,6 +288,21 @@ internal class InternalShareSheetController(
context.startActivity(share)
}
+ private fun shareTipCard(userId: ID) {
+ val url = Linkify.tipcard(userId)
+ val intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_TEXT, url)
+ type = "text/plain"
+ }
+
+ val share = Intent.createChooser(intent, null).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ context.startActivity(share)
+ }
+
override fun reset(setChecked: Boolean) {
pendingShareable = null
sharedWithApp = null
diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
index 30da635d44..44de2f2d74 100644
--- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
+++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
@@ -118,7 +118,7 @@ private val colors = with(Flipcash2ColorSpec) {
toggleUncheckedTrackColor = Color(0xFF666666),
cashBill = cashBill,
cashBillDecorColor = Color.White.copy(0.60f),
- tipCard = Color.Black.copy(alpha = 0.36f),
+ tipCard = Color(0xFF101011),
betaIndicator = BetaIndicator,
bannerThemed = bannerThemed,
bannerError = Error,
diff --git a/apps/flipcash/shared/tipping/build.gradle.kts b/apps/flipcash/shared/tipping/build.gradle.kts
index 03ab2e5131..a8f7e2925e 100644
--- a/apps/flipcash/shared/tipping/build.gradle.kts
+++ b/apps/flipcash/shared/tipping/build.gradle.kts
@@ -11,6 +11,13 @@ dependencies {
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)
+ implementation(project(":apps:flipcash:shared:analytics"))
+ implementation(project(":apps:flipcash:shared:chat"))
+ implementation(project(":apps:flipcash:shared:payments"))
+ implementation(project(":apps:flipcash:shared:region-selection:core"))
+ implementation(project(":apps:flipcash:shared:tokens"))
+ implementation(project(":apps:flipcash:shared:userflags"))
+ implementation(project(":libs:messaging"))
implementation(project(":services:flipcash"))
implementation(project(":services:opencode"))
}
diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
index 550ad0e72d..f4159074a0 100644
--- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
+++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt
@@ -1,16 +1,59 @@
package com.flipcash.shared.tipping
import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.core.chat.ChatIdentifier
+import com.flipcash.app.core.tipping.TipAmount
+import com.flipcash.app.core.tipping.TipEvent
+import com.flipcash.app.core.tipping.TipSelectionHolder
+import com.flipcash.app.core.tipping.TipSelectionState
+import com.flipcash.app.currency.PreferredCurrencyController
+import com.flipcash.app.payments.PurchaseMethodController
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.controllers.ResolverController
import com.flipcash.services.models.UserProfile
+import com.flipcash.services.models.buildDmPaymentMetadata
+import com.flipcash.services.models.buildTipDmPaymentMetadata
import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.ChatCoordinator
+import com.getcode.manager.BottomBarAction
+import com.getcode.manager.BottomBarManager
+import com.getcode.opencode.controllers.TransactionController
import com.getcode.opencode.model.core.ID
import com.getcode.opencode.model.core.OpenCodePayload
import com.getcode.opencode.model.core.PayloadKind
import com.getcode.opencode.model.core.UserId
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.exchange.VerifiedFiatCalculator
+import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.SendLimit
+import com.getcode.opencode.model.financial.Token
+import com.getcode.util.resources.ResourceHelper
+import com.getcode.view.LoadingSuccessState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.math.min
+import kotlin.time.Duration.Companion.milliseconds
/**
* Orchestrates tipping flows.
@@ -21,14 +64,273 @@ import javax.inject.Singleton
*/
@Singleton
class TippingCoordinator @Inject constructor(
+ userFlags: UserFlagsCoordinator,
private val profileController: ProfileController,
private val userManager: UserManager,
private val resolverController: ResolverController,
-) {
+ private val exchange: Exchange,
+ private val tokenCoordinator: TokenCoordinator,
+ private val transactionController: TransactionController,
+ private val verifiedFiatCalculator: VerifiedFiatCalculator,
+ private val resources: ResourceHelper,
+ private val chatCoordinator: ChatCoordinator,
+ private val purchaseMethodController: PurchaseMethodController,
+
+) : TipSelectionHolder {
/** The signed-in user's id ([UserManager.accountId]), or null if unavailable. */
val currentUserId: ID?
get() = userManager.accountId
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ private val _amount = MutableStateFlow(null)
+ private val _sendState = MutableStateFlow(LoadingSuccessState())
+
+ private val _userId = MutableStateFlow(null)
+
+ // Whether the viewer can afford at least the minimum tip, evaluated when a tip
+ // card is resolved. Surfaced through [selection] so the scanner can hide the tip
+ // modal and prompt to add money without re-checking balance itself.
+ private val _canTip = MutableStateFlow(false)
+
+ private val _events = MutableSharedFlow()
+ override val events: Flow = _events
+
+ /**
+ * The token to tip with — the app-global selected token from [TokenCoordinator], resolved from
+ * the persisted selected mint. Emits null until the token metadata is known.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val selectedToken: Flow = tokenCoordinator.observeSelectedTokenMint()
+ .flatMapLatest { mint ->
+ tokenCoordinator.tokens.map { tokens -> tokens.find { it.address == mint } }
+ }
+
+ /**
+ * The user's suggested tip amounts, derived from the server-provided per-region
+ * [com.flipcash.services.models.TipPresets] (via [UserFlagsCoordinator]) and the user's current
+ * preferred currency. Selects the presets whose region matches that currency (region is an ISO
+ * 4217 currency code), falls back to the USD presets, and expresses each tier — low / medium /
+ * high — as a [Fiat] in the matched region's currency. When the server provides no presets at
+ * all, falls back to the built-in [DEFAULT_USD_PRESETS], localized to the preferred currency.
+ */
+ private val tipPresets: Flow> = combine(
+ userFlags.resolvedFlags,
+ // Drive off the preferred rate (a StateFlow that always emits its current value) rather than
+ // observePreferredCurrency() — which can be an empty flow, and combine()'d with the hot
+ // resolvedFlags that never completes would hang firstOrNull() forever (blocking card resolve).
+ // Re-derives when the region changes so the tip modal's presets follow it.
+ exchange.observePreferredRate(),
+ ) { flags, rate ->
+ val presets = flags.tipPresets.effectiveValue
+ val preferred = rate.currency
+ val (currency, matched) =
+ presets.firstOrNull { it.region.equals(preferred.name, ignoreCase = true) }
+ ?.let { preferred to it }
+ ?: presets.firstOrNull {
+ it.region.equals(
+ CurrencyCode.USD.name,
+ ignoreCase = true
+ )
+ }
+ ?.let { CurrencyCode.USD to it }
+ ?: return@combine defaultPresets(preferred)
+
+ listOf(matched.low, matched.medium, matched.high)
+ .map { amount -> Fiat(fiat = amount, currencyCode = currency) }
+ }
+
+ /** The combined tip selection (amount chosen in the modal + app-global token + send state). */
+ override val selection: StateFlow =
+ combine(
+ combine(_amount, selectedToken, _sendState, _userId, _canTip) { amount, token, sendState, userId, canTip ->
+ TipSelectionState(
+ amount = amount,
+ token = token,
+ sendState = sendState,
+ userId = userId,
+ canTip = canTip,
+ )
+ },
+ tipPresets,
+ ) { state, presets -> state.copy(presets = presets) }
+ .stateIn(scope, SharingStarted.WhileSubscribed(5_000), TipSelectionState())
+
+ /**
+ * The largest tippable amount, in the user's preferred currency: the smaller of the
+ * per-transaction send limit and the selected token's balance — mirroring the give/cash/send
+ * amount entries. Null until limits/balance/rate are known. The amount-entry sheet surfaces it
+ * as an "enter up to" hint and blocks amounts above it.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val maxTipAmount: StateFlow =
+ combine(
+ transactionController.limits,
+ tokenCoordinator.observeSelectedTokenMint()
+ .flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
+ exchange.observePreferredRate(),
+ ) { limits, balance, rate ->
+ val balanceInLocal = balance.convertingTo(rate)
+ val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
+ Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
+ }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /**
+ * The smallest tippable amount — the lowest preset tier in the user's preferred currency. The
+ * amount entry surfaces it as a "minimum tip" hint and blocks custom amounts below it. Null until
+ * presets resolve.
+ */
+ val minTipAmount: StateFlow =
+ tipPresets.map { presets -> presets.minOrNull() }
+ .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /**
+ * Whether [amount] exceeds the per-transaction send limit for its currency — the tip amount
+ * entry gates on this before committing. Balance is enforced separately at send time
+ * ([confirmTip]); false when no limit is known for the currency.
+ */
+ fun exceedsSendLimit(amount: Fiat): Boolean {
+ val limit = transactionController.limits.value?.sendLimitFor(amount.currencyCode) ?: return false
+ return amount.toDouble() > limit.nextTransaction
+ }
+
+ override fun selectAmount(amount: TipAmount?) {
+ _amount.value = amount
+ }
+
+ override fun confirmTip() {
+ val amount = _amount.value ?: return
+ val userForTip = _userId.value ?: return
+ if (!_sendState.value.isIdle) return
+ val owner = userManager.accountCluster ?: return
+ val rate = exchange.preferredRate
+
+ scope.launch {
+ setSendState(LoadingSuccessState(loading = true))
+ val token = selectedToken.firstOrNull() ?: return@launch
+
+ // Fast-fail before the loading state if the tip exceeds the token balance:
+ // prompt to add money (or enter a smaller amount) instead of attempting a send.
+ val balanceInLocal = tokenCoordinator.balanceForToken(token).convertingTo(rate)
+ if (amount.value.valueGreaterThan(balanceInLocal)) {
+ setSendState(LoadingSuccessState())
+ promptInsufficientBalance()
+ return@launch
+ }
+
+
+ val source = owner.withTimelockForToken(token)
+
+ val balance = tokenCoordinator.balanceForToken(token)
+ val verifiedFiat = verifiedFiatCalculator.compute(
+ amount = amount.value,
+ token = token,
+ balance = balance,
+ rate = rate,
+ ).getOrElse { error ->
+ setSendState(LoadingSuccessState())
+ val (title, message) = when (error) {
+ is ComputeVerifiedFiatError.AmountBelowMinimum -> {
+ R.string.error_title_amountTooSmall to R.string.error_description_amountTooSmall
+ }
+
+ else -> {
+ R.string.error_title_staleRates to R.string.error_description_staleRates
+ }
+ }
+ BottomBarManager.showAlert(
+ title = resources.getString(title),
+ message = resources.getString(message),
+ )
+ return@launch
+ }
+
+ val canonicalChatId = chatCoordinator.generateChatId(userId = userForTip).getOrNull()
+
+ val appMetadataBytes = buildTipDmPaymentMetadata(
+ chatId = canonicalChatId,
+ )
+
+ resolverController.resolve(userId = userForTip)
+ .fold(
+ onSuccess = { destination ->
+ transactionController.directTransfer(
+ amount = verifiedFiat,
+ token = token,
+ source = source,
+ destinationOwner = destination,
+ appMetadata = appMetadataBytes,
+ )
+ },
+ onFailure = {
+ Result.failure(it)
+ }
+ ).fold(
+ onSuccess = {
+ tokenCoordinator.subtract(token, verifiedFiat.localFiat)
+ Result.success(verifiedFiat)
+ },
+ onFailure = { Result.failure(it) }
+ ).onSuccess { amount ->
+ setSendState(LoadingSuccessState(success = true))
+ if (canonicalChatId != null) {
+ chatCoordinator.loadMessages(canonicalChatId)
+ } else {
+ // New conversation — server just created the DM chat.
+ // Sync the feed so it appears in the contact list.
+ chatCoordinator.refreshFeed()
+ }
+ delay(400.milliseconds)
+ setSendState(LoadingSuccessState())
+// analytics.transfer(
+// event = Analytics.Transfer.SentCash,
+// amount = verifiedFiat.localFiat,
+// successful = true,
+// )
+
+ // Hand off to the tipped user's chat via the tips flow, so backing out of the
+ // chat lands on the tips list.
+ canonicalChatId?.let {
+ _events.emit(TipEvent.LaunchChat(ChatIdentifier.ByChatId(it)))
+ }
+
+ }.onFailure { cause ->
+ setSendState(LoadingSuccessState())
+// analytics.transfer(
+// event = Analytics.Transfer.SentCash,
+// amount = verifiedFiat.localFiat,
+// error = cause,
+// )
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_cashFailedToSend),
+ message = resources.getString(R.string.error_description_cashFailedToSend),
+ )
+ }
+ }
+ }
+
+ /**
+ * Shows the insufficient-balance prompt with an "Add Money" action, mirroring the
+ * give/currency-creator flows. Choosing "Add Money" resolves a deposit route via
+ * [PurchaseMethodController] and emits it as a [TipEvent.OpenRoute] for the tip UI to open.
+ * Shared by the send path ([confirmTip]) and the amount-entry over-balance gate.
+ */
+ fun promptInsufficientBalance() {
+ BottomBarManager.showInfo(
+ title = resources.getString(R.string.title_insufficientBalance),
+ message = resources.getString(R.string.description_insufficientBalanceToUse),
+ actions = listOf(
+ BottomBarAction(text = resources.getString(R.string.action_addMoney)) {
+ scope.launch {
+ purchaseMethodController.presentDepositOptions(popToRoot = true)
+ ?.let { _events.tryEmit(TipEvent.OpenRoute(it)) }
+ }
+ },
+ ),
+ showCancel = true,
+ )
+ }
+
/**
* Resolves the [UserProfile] for [userId] — e.g. a tip counterparty identified by a
* scanned code — so a tip card can be rendered for them. Delegates to the server-backed
@@ -61,14 +363,50 @@ class TippingCoordinator @Inject constructor(
* for another user (e.g. a scanned counterparty).
*/
suspend fun resolveTipCard(userId: ID): Result =
- resolveProfile(userId).map { tipCard(userId, it) }
+ resolveProfile(userId)
+ .onSuccess {
+ // Dual gating, like the send / currency-creator flows: the presentation gate only
+ // asks "is there any giveable balance?" (no amount threshold, so it stays currency-
+ // agnostic). The minimum-tip and per-amount affordability are enforced downstream —
+ // the amount entry's below-min / over-balance gates and confirmTip.
+ _canTip.value = tokenCoordinator.hasGiveableBalance()
+ _userId.value = userId
+ }
+ .map { tipCard(userId, it) }
+
+ /** Updates the tip submission's processing state; used by the send path (see [confirmTip]). */
+ private fun setSendState(state: LoadingSuccessState) {
+ _sendState.value = state
+ }
+
+ /**
+ * The built-in fallback tip presets ([DEFAULT_USD_PRESETS], in USD), localized to [preferred]
+ * via the current exchange rate when the user isn't on USD. Leaves the amounts in USD if no
+ * rate is available for the preferred currency.
+ */
+ private fun defaultPresets(preferred: CurrencyCode): List {
+ val usd = DEFAULT_USD_PRESETS.map { Fiat(fiat = it, currencyCode = CurrencyCode.USD) }
+ if (preferred == CurrencyCode.USD) return usd
+
+ val rate = exchange.rateFor(preferred) ?: return usd
+ return usd.map { it.convertingTo(rate).rounded() }
+ }
/**
* Assembles the scannable [Scannable.TipCard]: the tip [OpenCodePayload] encoding [userId]
- * as the scannable code data, plus [profile] for rendering.
+ * as the scannable code data, plus [profile] for rendering. Tip presets are surfaced reactively
+ * through [selection] (so they follow the region), not baked into the card.
*/
private fun tipCard(userId: ID, profile: UserProfile): Scannable.TipCard {
val payload = OpenCodePayload(kind = PayloadKind.Tip, value = UserId(userId))
- return Scannable.TipCard(data = payload.codeData.toList(), user = profile)
+ return Scannable.TipCard(
+ data = payload.codeData.toList(),
+ user = profile,
+ )
+ }
+
+ companion object {
+ /** Fallback tip amounts (USD) used when the server provides no presets. */
+ private val DEFAULT_USD_PRESETS = listOf(5.0, 10.0, 20.0)
}
}
diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt
index 6d3dce70d3..119178d5cf 100644
--- a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt
+++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt
@@ -1,23 +1,74 @@
package com.flipcash.shared.tipping
+import com.flipcash.app.currency.PreferredCurrencyController
+import com.flipcash.app.payments.PurchaseMethodController
+import com.flipcash.app.tokens.TokenCoordinator
+import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.controllers.ProfileController
import com.flipcash.services.controllers.ResolverController
import com.flipcash.services.models.UserProfile
import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.ChatCoordinator
+import com.getcode.opencode.controllers.TransactionController
+import com.getcode.opencode.exchange.Exchange
+import com.getcode.opencode.exchange.VerifiedFiatCalculator
+import com.getcode.opencode.model.financial.CurrencyCode
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.opencode.model.financial.Limits
+import com.getcode.opencode.model.financial.Rate
+import com.getcode.opencode.model.financial.SendLimit
+import com.getcode.solana.keys.Mint
+import com.getcode.util.resources.ResourceHelper
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertSame
+import kotlin.test.assertTrue
class TippingCoordinatorTest {
private val profileController = mockk()
private val userManager = mockk()
private val resolverController = mockk()
- private val coordinator = TippingCoordinator(profileController, userManager, resolverController)
+ private val userFlags = mockk(relaxed = true)
+ private val preferredCurrency = mockk(relaxed = true)
+ private val exchange = mockk(relaxed = true)
+ private val tokenCoordinator = mockk(relaxed = true)
+ private val transactionController = mockk(relaxed = true)
+ private val verifiedFiatCalculator = mockk(relaxed = true)
+ private val resources = mockk(relaxed = true)
+ private val chatCoordinator = mockk(relaxed = true)
+ private val purchaseMethodController = mockk(relaxed = true)
+
+ // Per-transaction send limit of $100 for USD; no limit for any other currency.
+ private val limits = mockk {
+ every { sendLimitFor(any()) } returns null
+ every { sendLimitFor(CurrencyCode.USD) } returns
+ SendLimit(nextTransaction = 100.0, maxPerTransaction = 500.0, maxPerDay = 1000.0)
+ }
+
+ private fun buildCoordinator() = TippingCoordinator(
+ userFlags,
+ profileController,
+ userManager,
+ resolverController,
+ exchange,
+ tokenCoordinator,
+ transactionController,
+ verifiedFiatCalculator,
+ resources,
+ chatCoordinator,
+ purchaseMethodController,
+ )
+
+ private val coordinator = buildCoordinator()
private fun profile(name: String) = UserProfile(
displayName = name,
@@ -65,4 +116,39 @@ class TippingCoordinatorTest {
assertEquals(id, coordinator.currentUserId)
}
+
+ @Test
+ fun `exceedsSendLimit is true when the amount is over the per-transaction limit`() {
+ every { transactionController.limits } returns MutableStateFlow(limits)
+
+ assertTrue(coordinator.exceedsSendLimit(Fiat(150.0, CurrencyCode.USD)))
+ }
+
+ @Test
+ fun `exceedsSendLimit is false when the amount is within the limit`() {
+ every { transactionController.limits } returns MutableStateFlow(limits)
+
+ assertFalse(coordinator.exceedsSendLimit(Fiat(100.0, CurrencyCode.USD)))
+ }
+
+ @Test
+ fun `exceedsSendLimit is false when no limit is known for the currency`() {
+ every { transactionController.limits } returns MutableStateFlow(limits)
+
+ // No send limit configured for CAD — nothing to enforce, so it's allowed.
+ assertFalse(coordinator.exceedsSendLimit(Fiat(999.0, CurrencyCode.CAD)))
+ }
+
+ @Test
+ fun `maxTipAmount is the smaller of the send limit and the selected token balance`() = runTest {
+ every { transactionController.limits } returns MutableStateFlow(limits)
+ every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(Mint(listOf(1)))
+ every { tokenCoordinator.balanceForToken(any()) } returns flowOf(Fiat(40.0, CurrencyCode.USD))
+ every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne)
+
+ val max = buildCoordinator().maxTipAmount.first { it != null }
+
+ // Send limit is $100, balance is $40 — the smaller wins.
+ assertEquals(40.0, max!!.toDouble())
+ }
}
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 f9457b27d1..b833f9a526 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
@@ -194,16 +194,20 @@ class TokenCoordinator @Inject constructor(
// region Public API — Balances
/** Can I hand money to a person right now? */
- suspend fun hasGiveableBalance(): Boolean {
+ 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)
val state = _state.value
return state.balances.filterKeys { canGiveUsdf || it != Mint.usdf }
.values
- .any { it.hasDisplayableValue }
+ .any { balance ->
+ if (atLeast > Fiat.Zero) balance.valueGreaterThanOrEqualTo(atLeast)
+ else balance.hasDisplayableValue
+ }
}
+
/** Do I have any balance at all, including reserves? */
suspend fun hasBalance(): Boolean =
_state.value.balances.values.any { it.hasDisplayableValue }
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 5ec509805c..a42c6d5fbc 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
@@ -137,6 +137,7 @@ class SelectTokenViewModel @Inject constructor(
is TokenPurpose.Swap,
is TokenPurpose.LaunchFunding,
+ is TokenPurpose.Tip,
TokenPurpose.Deposit,
TokenPurpose.Withdraw -> {
if (it.token.address == Mint.usdf) {
@@ -173,6 +174,10 @@ class SelectTokenViewModel @Inject constructor(
hasBalance
}
+ is TokenPurpose.Tip -> {
+ hasBalance
+ }
+
is TokenPurpose.Swap -> {
if (it.token.address != purpose.desiredToken) {
hasBalance
@@ -195,7 +200,7 @@ class SelectTokenViewModel @Inject constructor(
eventFlow
.filterIsInstance()
- .filter { stateFlow.value.purpose is TokenPurpose.Select }
+ .filter { stateFlow.value.purpose is TokenPurpose.TriggersChange }
.filter { it.fromUser }
.map { it.mint }
.onEach { tokenCoordinator.selectToken(it) }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index df7253caaf..412eea0efd 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -104,6 +104,7 @@ secrets-gradle-plugin = "2.0.1"
firebase-perf-plugin = "2.0.2"
uiToolingPreview = "1.11.4"
uiTooling = "1.11.4"
+foundationLayout = "1.11.4"
[libraries]
# Desugaring
@@ -150,6 +151,7 @@ ksp-symbol-processing = { module = "com.google.devtools.ksp:symbol-processing-ap
# Coil
coil3 = { module = "io.coil-kt.coil3:coil-compose", version.ref = "compose-coil" }
+coil3-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "compose-coil" }
coil3-network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "compose-coil" }
# Hilt
@@ -303,6 +305,7 @@ kotlin-serialization-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-ser
kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
+androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
[bundles]
compose = ["compose-ui", "compose-foundation", "compose-material", "compose-material-icons-extended", "compose-animation", "compose-activities"]
diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt
index 8c4c0d1efb..af9a2dd491 100644
--- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt
@@ -16,7 +16,7 @@ data class MediaItem(
* This never upgrades above [preferred] — e.g. `rendition(Role.THUMBNAIL)` will not
* return the ORIGINAL.
*/
- fun rendition(preferred: MediaItemRendition.Role): MediaItemRendition? {
+ fun rendition(preferred: MediaItemRendition.Role = MediaItemRendition.Role.ORIGINAL): MediaItemRendition? {
val start = QUALITY_LADDER.indexOf(preferred)
if (start < 0) return null // UNKNOWN / non-ladder roles have no fallback chain
for (i in start until QUALITY_LADDER.size) {
@@ -27,7 +27,7 @@ data class MediaItem(
}
/** Download URL of the rendition resolved for [preferred], or null if none is available. */
- fun url(preferred: MediaItemRendition.Role): String? =
+ fun url(preferred: MediaItemRendition.Role = MediaItemRendition.Role.ORIGINAL): String? =
rendition(preferred)?.blob?.downloadUrl
companion object {
diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt
index 04022dfcda..69e81ed0cb 100644
--- a/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt
+++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt
@@ -22,6 +22,7 @@ import com.getcode.theme.CodeTheme
fun Modal(
modifier: Modifier = Modifier,
backgroundColor: Color = CodeTheme.colors.brandContainer,
+ verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
content: @Composable ColumnScope.() -> Unit
) {
Surface(
@@ -39,7 +40,7 @@ fun Modal(
.padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2)
.windowInsetsPadding(WindowInsets.navigationBars),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)
+ verticalArrangement = verticalArrangement,
) {
content()
}