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), 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")))