From e82d1cab0e821edcd3472f59d44d3fc6511d7ed9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 17:10:50 -0400 Subject: [PATCH 1/2] feat(chat): show avatars for chat and push notifications Surface counterparty avatars in chat notifications and rows: - NotificationService loads the sender's remote avatar via Coil and falls back to the profile display name / social handle for TIP_DM senders (no device contact exists for them). - Add displayName + image to ConversationReference so chat rows can render name and avatar. - ContactAvatar crops (ContentScale.Crop) to avoid letterboxing. - MediaItem.rendition()/url() default the preferred rendition to ORIGINAL, making the argument optional. --- .../shared/chat/ui/ConversationReference.kt | 5 ++ .../shared/common/ui/ContactAvatar.kt | 5 ++ .../shared/notifications/build.gradle.kts | 4 + .../app/notifications/NotificationService.kt | 80 ++++++++++++++++--- gradle/libs.versions.toml | 1 + .../services/models/chat/MediaItem.kt | 4 +- 6 files changed, 87 insertions(+), 12 deletions(-) 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 e1ee984d7..99660b8ac 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 808d04b3b..f49c83c0b 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 c371850af..3488b3360 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 7c50f922f..a4e2d42a9 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index df7253caa..7b5f15e7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,6 +150,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 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 8c4c0d1ef..af9a2dd49 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 { From ba79e92a6a3e5622fe3218319dbe6c392eeb6719 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 17:13:15 -0400 Subject: [PATCH 2/2] feat(tipping): send a tip to a scanned user's tip card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the end-to-end tipping flow: scan (or deeplink) another user's tip card, choose an amount, and send them a tip that opens the resulting DM chat. - TippingCoordinator (shared/tipping): resolves the recipient's tip card, holds the tip selection (amount + token + send state), derives region-aware presets and min/max (send limit ∩ balance) amounts, enforces the send limit, and runs the transfer via TransactionController. - Scanner: ScannableDecorator dispatch (Payable/TipCard), TipUserModal (presets, token, slide-to-confirm), affordability gating, and a post-tip handoff into the tipped user's chat with the tips list beneath. - Custom tip-amount entry (features/tipping) with region/currency change and below-min / over-balance gating (add-money prompt on insufficient). - Session/nav wiring: TipCardDelegate, /tip/{userId} deeplink, routes, and MainActivity LocalTipCoordinator provision. - Tip-card sharing via the share sheet; token-selection wired for Tip. --- apps/flipcash/app/build.gradle.kts | 1 + .../flipcash/app/src/main/AndroidManifest.xml | 12 + .../kotlin/com/flipcash/app/MainActivity.kt | 6 + .../com/flipcash/app/internal/ui/App.kt | 2 +- .../ui/navigation/AppScreenContent.kt | 2 + .../app/internal/ui/navigation/MainRoot.kt | 1 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 7 + .../com/flipcash/app/core/bill/Scannable.kt | 2 +- .../app/core/internal/bill/BillController.kt | 7 +- .../app/core/navigation/DeeplinkAction.kt | 3 + .../app/core/navigation/DeeplinkType.kt | 3 + .../flipcash/app/core/tipping/TipSelection.kt | 102 ++++++ .../flipcash/app/core/tokens/TokenPurpose.kt | 9 +- .../app/core/ui/TokenSelectionPill.kt | 73 ++-- .../com/flipcash/app/core/util/Linkify.kt | 4 + .../core/src/main/res/values/strings.xml | 12 + .../com/flipcash/app/cash/CashScreen.kt | 9 +- .../app/messenger/ChatAmountEntryScreen.kt | 10 +- .../features/scanner/build.gradle.kts | 1 + .../flipcash/app/scanner/internal/Scanner.kt | 9 +- .../internal/bills/ScannableContainer.kt | 170 ++++----- .../internal/bills/decor/PayableDecorator.kt | 93 +++++ .../bills/decor/ScannableDecorator.kt | 86 +++++ .../internal/bills/decor/TipCardDecorator.kt | 118 ++++++ .../internal/ui/modals/TipUserModal.kt | 216 +++++++++++ .../features/tipping/build.gradle.kts | 4 + .../app/tipping/TipAmountEntryScreen.kt | 45 +++ .../internal/TipAmountEntryViewModel.kt | 131 +++++++ .../app/tipping/internal/TipFlowViewModel.kt | 25 +- .../tipping/internal/screens/TipCardScreen.kt | 9 +- .../tipping/internal/screens/TipsScreen.kt | 73 +++- .../flipcash/app/tokens/TokenSelectScreen.kt | 48 ++- .../app/tokens/internal/TokenSelectScreen.kt | 8 +- .../flipcash/app/bills/AnimatedScannable.kt | 4 +- .../flipcash/app/bills/ScannableRenderer.kt | 7 +- .../app/bills/components/cards/TipCard.kt | 14 +- apps/flipcash/shared/router/build.gradle.kts | 2 + .../flipcash/app/router/internal/AppRouter.kt | 18 + apps/flipcash/shared/session/build.gradle.kts | 1 + .../flipcash/app/session/SessionController.kt | 14 +- .../session/internal/RealSessionController.kt | 22 +- .../delegates/BillPresentationDelegate.kt | 11 +- .../internal/delegates/CodeScanDelegate.kt | 16 +- .../internal/delegates/TipCardDelegate.kt | 89 +++++ .../app/shareable/ShareSheetController.kt | 7 + .../InternalShareConfirmationController.kt | 1 + .../internal/InternalShareSheetController.kt | 22 +- .../theme/internal/FlipcashDesignSystem.kt | 2 +- apps/flipcash/shared/tipping/build.gradle.kts | 7 + .../shared/tipping/TippingCoordinator.kt | 346 +++++++++++++++++- .../shared/tipping/TippingCoordinatorTest.kt | 88 ++++- .../flipcash/app/tokens/TokenCoordinator.kt | 8 +- .../app/tokens/ui/SelectTokenViewModel.kt | 7 +- gradle/libs.versions.toml | 2 + .../kotlin/com/getcode/ui/components/Modal.kt | 3 +- 55 files changed, 1802 insertions(+), 190 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipSelection.kt create mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/PayableDecorator.kt create mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/ScannableDecorator.kt create mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/decor/TipCardDecorator.kt create mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/modals/TipUserModal.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TipAmountEntryScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipAmountEntryViewModel.kt create mode 100644 apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 6a3b76287..fed6dd7cd 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 e351cf0b2..df1327d63 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 61bf63f79..03501dfef 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 054a6af91..b6ff8427e 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 e8aca9016..066ac4732 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 193150767..f3b6fea9c 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 f04985db3..a6592fdd6 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 1fae03500..bb34c0302 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 a0b3a9fba..2a8f4ce93 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 8bc6901ef..5d90d335d 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 a0bdb4a8e..e90780bce 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 000000000..baf33284b --- /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 2ace76ca5..1fb093d57 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 10feb1bfa..5726bdc8d 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 7928e1fc9..26ed1d31c 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 d5a30c0b4..7b8e66d90 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 3fa78b741..e45f910b0 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 f021ac341..c5348ab3a 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 49f466134..6bf13d2c9 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 dc1bac0cb..c652482b0 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 66c881802..540f127ac 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 000000000..a1c8d3264 --- /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 000000000..ef6c1a653 --- /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 000000000..21c4e95a8 --- /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 000000000..ffb67c857 --- /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 9b6f5b0de..55bf64914 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 000000000..026e7b562 --- /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 000000000..54a37d196 --- /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 bc06fe4a2..5f7f9d8ae 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 1ffffa5e2..0713e4b18 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 e1d87545f..ddd222474 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 95094f67d..044f9ca7c 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 080c0993d..38a3031ff 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 58ddcbd36..5f96e7ce6 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 0c002a1ea..05052459b 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 d21b1baa3..b11da54de 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/router/build.gradle.kts b/apps/flipcash/shared/router/build.gradle.kts index 779241124..b41f1af07 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 433d551e1..3c638b59d 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 58cd9629d..49d318eac 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 04ab50fa0..89760ac8d 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 d8fbdaa6c..5b100350d 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 c6bfd7686..4fb65d886 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 670f48b46..20915ad21 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 000000000..7e3f6e794 --- /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 a373191e8..6892b4ef5 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 01dbeefee..896893b07 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 fbd6048cf..2585a1274 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 30da635d4..44de2f2d7 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 03ab2e513..a8f7e2925 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 550ad0e72..f4159074a 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 6d3dce70d..119178d5c 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 f9457b27d..b833f9a52 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 5ec509805..a42c6d5fb 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 7b5f15e7f..412eea0ef 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 @@ -304,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/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Modal.kt index 04022dfcd..69e81ed0c 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() }