From 29052823991fa2cc57c68285f2df17ece2e9a9ef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 16:40:41 -0400 Subject: [PATCH 1/2] fix(payments): raise the insufficient-balance prompt above the bill overlay The prompt was drawn by NavMessagingEntryDecorator, which sat inside NavBillOverlayEntryDecorator; the outermost decorator paints its overlay last, so the tip card always covered the prompt it had just raised. Moving the messaging decorator outermost puts a prompt that has to be answered above everything the entry draws. The in-chat send raised a red alert for the same condition. It is the same situation the tip card handles as info with an Add Money action, so it now does too. --- .../app/internal/ui/navigation/AppContent.kt | 12 ++++++--- .../app/messenger/internal/ChatViewModel.kt | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 114de5baf..fbc204bb8 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -102,14 +102,18 @@ internal fun AppContent( navigator = codeNavigator, resultStateRegistry = resultStateRegistry, decorators = listOf( - // First = outermost: bill draws above screen content but is skipped for sheet - // entries, so NavDisplay paints the sheet scene above the bill-bearing base - // entry — sheets open OVER the bill. See NavBillOverlayEntryDecorator. - rememberNavBillOverlayEntryDecorator(), + // First = outermost, and outermost draws last: a bottom bar message is a prompt + // that has to be answered, so it sits above everything the entry draws — the + // bill overlay and its scrim included (the tip card's own modal would otherwise + // cover the insufficient-balance prompt raised from it). rememberNavMessagingEntryDecorator( codeNavigator.backStack, barManager ), + // The bill draws above screen content but is skipped for sheet entries, so + // NavDisplay paints the sheet scene above the bill-bearing base entry — sheets + // open OVER the bill. See NavBillOverlayEntryDecorator. + rememberNavBillOverlayEntryDecorator(), rememberNavBlockingOverlayEntryDecorator(), // Inset-only: reserves LocalTabBarPadding per tab-home entry for the hoisted bar, // so pushed/detail screens are not inset and the inset never collapses mid-push. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index cd2a95a7b..181a570b1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -833,10 +833,7 @@ internal class ChatViewModel @Inject constructor( val balanceInLocal = balance.convertingTo(rate) val isOverBalance = entered.valueGreaterThan(balanceInLocal) if (isOverBalance) { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_insufficientFunds), - resources.getString(R.string.error_description_insufficientFunds), - ) + presentInsufficientBalance() } return isOverBalance } @@ -874,6 +871,26 @@ internal class ChatViewModel @Inject constructor( } } + /** + * Over balance, with something in the account: the same prompt the tip card raises, offering + * the way out of it. [presentAddMoney] covers the empty account, which has nothing to enter a + * smaller amount than. + */ + private fun presentInsufficientBalance() { + 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) + ) { + dispatchEvent(Event.PresentDepositOptions) + }, + ), + showCancel = true, + ) + } + private fun presentAddMoney() { BottomBarManager.showInfo( title = resources.getString(R.string.title_noBalanceYet), From 3cfe8c90cb4dd918b9b2fa303a6f47f9b9d7e732 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 17:01:24 -0400 Subject: [PATCH 2/2] fix(deeplinks): route a jump link that carries its own path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The website's tip interstitial sends "Open in Flipcash" to jump.flipcash.com/tip/{id}, with no `#source=` fragment. classifyOrThrow read the jump host as a wrapper and nothing else, so unwrapJumpTarget returned null and the link fell through to unrouted() — which hands only flipcash.com back to the browser and returns None for every other host. The tap brought the app to the front and nothing happened. Classify a jump link by its own path when there is no fragment to unwrap. The redirector is claimed whole by the manifest and mirrors the app's path space, so /tip/{id} resolves the same as it does on app.flipcash.com. A `#source=` fragment still takes precedence, and isProfileLink stays host-gated to flipcash.com, so the redirector does not start claiming handles. --- .../flipcash/app/router/internal/AppRouter.kt | 30 ++++++++++--- .../app/router/internal/AppRouterTest.kt | 43 ++++++++++++++++++- 2 files changed, 66 insertions(+), 7 deletions(-) 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 63dd5caf3..3ee56ad02 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 @@ -171,13 +171,31 @@ internal class AppRouter( } private fun classifyOrThrow(deepLink: DeepLink): DeeplinkType? { - return when { - // A jump link is a wrapper, never a destination. Unwrap once and classify the inner - // URL; a jump pointing at another jump is malformed and drops to null rather than - // recursing. Mirrors iOS DeepLinkController. - deepLink.isJump() -> deepLink.unwrapJumpTarget() + // A jump link may carry the real URL percent-encoded in `#source=`. Unwrap once and + // classify that; a jump pointing at another jump is malformed and falls through rather + // than recursing. Mirrors iOS DeepLinkController. + if (deepLink.isJump()) { + deepLink.unwrapJumpTarget() ?.takeUnless { it.isJump() } - ?.let { classifyOrThrow(it) } + ?.let { return classifyOrThrow(it) } + } + return deepLink.classifyByPath() + } + + /** + * Classify a link by its path. Every route below is matched on path alone except + * [DeepLink.isProfileLink], which is host-gated to [PROFILE_HOST] because only the bare host + * gives a person's handle a whole path segment. + * + * That is why an unwrappable jump link lands here too: [JUMP_HOST] is a redirector the app + * claims whole, and it mirrors the app's path space rather than owning one of its own. The + * website's tip interstitial sends `jump.flipcash.com/tip/{id}` with no fragment at all, and + * a fragment-only reading would drop it — the link opens the app and nothing happens, since + * [unrouted] hands only [PROFILE_HOST] back to the browser. + */ + private fun DeepLink.classifyByPath(): DeeplinkType? { + val deepLink = this + return when { deepLink.isLogin() -> deepLink.handleLoginLink() deepLink.isCashLink() -> deepLink.handleCashLink() deepLink.isToken() -> deepLink.handleTokenLink() diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 6cce8b0bc..a46f6a11c 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -866,12 +866,53 @@ class AppRouterTest { } @Test - fun `jump link with no source fragment classifies to null`() { + fun `jump link with no source fragment and no path classifies to null`() { assertNull(router.classify(DeepLink("https://jump.flipcash.com/"))) assertNull(router.classify(DeepLink("https://jump.flipcash.com/#other=1"))) assertNull(router.classify(DeepLink("https://jump.flipcash.com/#source="))) } + @Test + fun `jump link carrying a route on its own path is classified by that path`() { + // What the website's tip interstitial navigates to from "Open in Flipcash" — a bare + // redirector path, no `#source=` fragment. + val userId = "11111111-1111-1111-1111-111111111111" + val type = router.classify(DeepLink("https://jump.flipcash.com/tip/$userId")) + + assertIs(type) + assertEquals(UUID.fromString(userId).bytes, type.userId) + } + + @Test + fun `jump link path routes dispatch like the same path on an app host`() { + loggedIn() + currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes + + val userId = "11111111-1111-1111-1111-111111111111" + val action = router.dispatch(DeepLink("https://jump.flipcash.com/tip/$userId")) + + assertIs(action) + assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner) + } + + @Test + fun `a source fragment still wins over the jump link's own path`() { + val type = router.classify( + DeepLink( + "https://jump.flipcash.com/tip/11111111-1111-1111-1111-111111111111#source=" + + URLEncoder.encode("https://app.flipcash.com/token/$MINT", "UTF-8") + ) + ) + assertIs(type) + } + + @Test + fun `jump link does not claim a handle the way the bare host does`() { + // isProfileLink is host-gated: only flipcash.com gives a person a whole path segment, + // so `jump.flipcash.com/sally` is not a tip card by handle. + assertNull(router.classify(DeepLink("https://jump.flipcash.com/sally_streamer"))) + } + @Test fun `jump link wrapping an unroutable url classifies to null`() { assertNull(router.classify(jump("https://app.flipcash.com/chat/abc")))