From b9f03a45e9bfa322fa893a72f7c56e6d29b5911d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:05:31 -0400 Subject: [PATCH 1/4] feat(username): claim a handle, and reach a tip card by it Usernames get an entry screen, a balance-gated nudge on the You tab, and a place in every surface that until now showed only an opaque user id. - `Username` joins the update-profile stack as a conditional step alongside display name (node 9491:6296), reachable from the You tab's progress card (node 9491:6295) and a new My Account row (node 9491:6297). - `usernameGate` reads the `usernameMinBalance` flag locally, so the card shows how far off the balance is instead of letting someone type a handle into the server's rejection. Rejections show as info rather than error: each describes what was typed and is fixed by typing something else. Length is checked here so "too short" and "too long" name the problem rather than arriving as a generic INVALID_USERNAME. - `TipCardOwner` replaces the bare `ID` at the deeplink, session, and Linkify seams, so a card can be addressed by id or by handle. A vanity link renders whole; only an opaque id is abbreviated (node 9442:3673). - `flipcash.com/{username}` is claimed as a verified App Link, narrowed to the handle charset by `pathAdvancedPattern`. Android verifies per host and not per path, so the website's own paths (/download, /privacy, /terms) are listed in `AppRouter` and handed back to a browser through `DeeplinkAction.OpenExternally` rather than dead-ending on the home screen. - Following your own handle answers with the You tab. Both handle-based self-checks are blind until this account's profile has loaded, which is exactly the window a cold-started link lands in, so `TippingCoordinator` compares ids after the fetch and raises `OwnTipCard` before the resolve arms the tip modal. The new host needs `assetlinks.json` served from `https://flipcash.com/.well-known/` before Android will verify it. --- .../flipcash/app/src/main/AndroidManifest.xml | 44 ++++ .../com/flipcash/app/internal/ui/App.kt | 55 ++++- .../app/internal/ui/navigation/MainRoot.kt | 1 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 11 +- .../flipcash/app/core/chat/ChatParticipant.kt | 12 + .../app/core/navigation/DeeplinkAction.kt | 20 +- .../app/core/navigation/DeeplinkType.kt | 7 + .../flipcash/app/core/tipping/OwnTipCard.kt | 16 ++ .../flipcash/app/core/tipping/TipCardOwner.kt | 49 ++++ .../app/core/userprofile/UpdateProfileStep.kt | 11 +- .../com/flipcash/app/core/util/Linkify.kt | 20 +- .../core/src/main/res/values/strings.xml | 35 +++ apps/flipcash/features/menu/build.gradle.kts | 3 + .../app/menu/internal/AbbreviatedLink.kt | 26 +++ .../app/menu/internal/MenuScreenContent.kt | 146 +++++++----- .../app/menu/internal/MenuScreenViewModel.kt | 143 +++++++++++- .../app/menu/internal/UsernameGate.kt | 57 +++++ .../components/UsernameProgressCard.kt | 190 ++++++++++++++++ .../app/menu/internal/AbbreviatedLinkTest.kt | 63 ++++++ .../app/menu/internal/UsernameGateTest.kt | 78 +++++++ .../components/ContactInfoContainer.kt | 18 +- .../flipcash/app/myaccount/MyAccountScreen.kt | 16 ++ .../internal/myaccount/MyAccountMenuItems.kt | 18 ++ .../myaccount/MyAccountScreenViewModel.kt | 11 + .../userprofile/UserProfileScreenContent.kt | 3 +- .../flipcash/app/scanner/internal/Scanner.kt | 8 +- .../app/tipping/internal/TipFlowViewModel.kt | 9 +- .../userprofile/UserProfileSetupFlowScreen.kt | 4 + .../internal/username/LengthComplaint.kt | 24 ++ .../internal/username/UsernameEntryScreen.kt | 165 ++++++++++++++ .../username/UsernameEntryViewModel.kt | 209 ++++++++++++++++++ .../internal/username/LengthComplaintTest.kt | 43 ++++ .../com/flipcash/app/analytics/Analytics.kt | 2 +- .../com/flipcash/app/analytics/Events.kt | 1 + .../flipcash/app/bills/ScannableRenderer.kt | 14 ++ .../app/bills/components/cards/TipCard.kt | 29 ++- .../app/notifications/NotificationService.kt | 4 +- .../shared/profile/ProfileCoordinator.kt | 3 + .../app/router/inject/RouterModule.kt | 7 +- .../flipcash/app/router/internal/AppRouter.kt | 98 +++++++- .../app/router/internal/AppRouterTest.kt | 116 +++++++++- .../flipcash/app/session/SessionController.kt | 9 +- .../session/internal/RealSessionController.kt | 4 +- .../internal/delegates/TipCardDelegate.kt | 85 ++++++- .../internal/delegates/TipCardDelegateTest.kt | 8 +- .../app/shareable/ShareSheetController.kt | 4 + .../internal/InternalShareSheetController.kt | 7 +- .../shared/tipping/TippingCoordinator.kt | 51 ++++- .../app/tokens/core/TotalBalanceProvider.kt | 16 ++ .../flipcash/app/tokens/TokenCoordinator.kt | 18 +- .../flipcash/app/tokens/inject/TokenModule.kt | 6 + .../com/flipcash/services/models/Handle.kt | 50 +++++ .../flipcash/services/models/HandleTest.kt | 93 ++++++++ 53 files changed, 2022 insertions(+), 118 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/OwnTipCard.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipCardOwner.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt create mode 100644 apps/flipcash/shared/tokens/core/src/main/kotlin/com/flipcash/app/tokens/core/TotalBalanceProvider.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index b604152ba6..3c1805ad02 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -27,6 +27,17 @@ + + + + + + + @@ -197,6 +208,39 @@ android:scheme="https" /> + + + + + + + + + + + + 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 7ce3abc9fd..00c7bfb527 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 @@ -1,5 +1,9 @@ package com.flipcash.app.internal.ui +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionLayout @@ -49,6 +53,8 @@ import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.featureflags.model.BackgroundResetTimeout +import androidx.core.net.toUri +import com.flipcash.app.MainActivity import com.flipcash.app.internal.ui.navigation.AppContent import com.flipcash.app.internal.ui.navigation.NewAppContent import com.flipcash.app.internal.ui.navigation.appEntryProvider @@ -61,6 +67,7 @@ import com.flipcash.app.theme.FlipcashTheme import com.flipcash.features.shareapp.R import com.flipcash.services.user.AuthState import com.getcode.animation.LocalSharedTransitionScope +import com.getcode.utils.trace import com.getcode.libs.biometrics.BiometricsError import com.getcode.libs.qr.rememberQrBitmapPainter import com.getcode.navigation.AppNavHost @@ -200,7 +207,9 @@ internal fun App( is DeeplinkAction.OpenCashLink -> session.openCashLink(action.entropy) is DeeplinkAction.PresentTipCard -> - session.resolveTipCard(action.userId) + session.resolveTipCard(action.owner) + is DeeplinkAction.OpenExternally -> + context.openInBrowser(action.url) is DeeplinkAction.Login -> viewModel.handleLoginEntropy( action.entropy, @@ -231,7 +240,9 @@ internal fun App( is DeeplinkAction.OpenCashLink -> session.openCashLink(action.entropy) is DeeplinkAction.PresentTipCard -> - session.resolveTipCard(action.userId) + session.resolveTipCard(action.owner) + is DeeplinkAction.OpenExternally -> + context.openInBrowser(action.url) is DeeplinkAction.Login -> viewModel.handleLoginEntropy( action.entropy, @@ -338,7 +349,10 @@ internal fun App( onDismissed = { } ) - is DeeplinkAction.PresentTipCard -> session.resolveTipCard(action.userId) + is DeeplinkAction.PresentTipCard -> + session.resolveTipCard(action.owner) + is DeeplinkAction.OpenExternally -> + context.openInBrowser(action.url) is DeeplinkAction.OpenCashLink -> session.openCashLink( action.entropy ) @@ -451,3 +465,38 @@ private fun BackgroundResetEffect( } } +/** + * Hand a link back to the web — the tail of [DeeplinkAction.OpenExternally]. + * + * Not `ChromeTabsUtils.launchUrl`, and not `LocalUriHandler`: both send a package-less `ACTION_VIEW`, + * and this URL is on a host we are a verified handler for, so it would resolve straight back to us + * and the tap would loop. The browser has to be named. Resolving the default one keeps the hop + * invisible, which is what a tap on `flipcash.com/download` should feel like; when there isn't one to + * name — no default set, or the resolver activity answered — the chooser does it instead, with our + * own activity excluded so it can't be picked. + */ +private fun Context.openInBrowser(url: String) { + val uri = url.toUri() + val view = Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE) + + // Probed against a host we make no claim on, so the answer is a browser rather than ourselves. + val probe = Intent(Intent.ACTION_VIEW, "https://example.com".toUri()) + .addCategory(Intent.CATEGORY_BROWSABLE) + val browser = packageManager + .resolveActivity(probe, PackageManager.MATCH_DEFAULT_ONLY) + ?.activityInfo + ?.packageName + ?.takeIf { it != packageName && it != "android" } + + val intent = if (browser != null) { + view.setPackage(browser) + } else { + Intent.createChooser(view, null).putExtra( + Intent.EXTRA_EXCLUDE_COMPONENTS, + arrayOf(ComponentName(this, MainActivity::class.java)), + ) + }.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + runCatching { startActivity(intent) } + .onFailure { trace(tag = "Deeplink", message = "No browser to open $url", error = it) } +} 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 df719ecd6f..307b6d5c79 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 @@ -251,6 +251,7 @@ internal fun buildNavGraphForLaunch( is DeeplinkAction.OpenCashLink, is DeeplinkAction.PresentTipCard, + is DeeplinkAction.OpenExternally, is DeeplinkAction.Login -> LaunchNavGraph( baseRoutes = listOf(home), 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 11f78dda53..63ed8c59ea 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 @@ -149,13 +149,16 @@ sealed interface AppRoute : NavKey, Parcelable { val nameSource: DisplayNameSource, val includeName: Boolean = true, val includePhoto: Boolean = true, + // Off by default: the username step is gated on a minimum balance and is never part of + // onboarding, so only the surfaces that qualify the account ask for it. + val includeUsername: Boolean = false, val target: AppRoute? = null, // When false, the first step has no back affordance and system back is swallowed — // used in onboarding where display-name entry is a mandatory, non-dismissable step. val allowBack: Boolean = true, ): AppRoute, FlowRouteWithResult { override val initialStack: List - get() = buildUpdateUserProfileStack(includeName, includePhoto) + get() = buildUpdateUserProfileStack(includeName, includeUsername, includePhoto) } @Serializable @@ -360,13 +363,15 @@ private fun buildVerificationInitialStack( return emptyList() } -// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name first, then -// photo. In edit mode only the requested step(s) are included. +// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name, then +// username, then photo. In edit mode only the requested step(s) are included. private fun buildUpdateUserProfileStack( includeName: Boolean, + includeUsername: Boolean, includePhoto: Boolean, ): List = buildList { if (includeName) add(UpdateProfileStep.Name) + if (includeUsername) add(UpdateProfileStep.Username) if (includePhoto) add(UpdateProfileStep.Photo) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt index 8597f582f0..1574943a9f 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatParticipant.kt @@ -3,6 +3,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.handle import com.getcode.opencode.model.core.ID import kotlinx.parcelize.Parcelize @@ -21,11 +22,22 @@ import kotlinx.parcelize.Parcelize sealed interface ChatParticipant: Parcelable { val displayName: String + /** + * The counterparty's public `@handle`, or null when there isn't one to show. + * + * Always null for a [Contact]: a `CONTACT_DM` is addressed by phone number, and the device + * contact carries no Flipcash identity to read a username off. A [TipUser] has one whenever they + * have claimed it. + */ + val handle: String? + data class Contact(val contact: DeviceContact) : ChatParticipant { override val displayName: String get() = contact.displayName + override val handle: String? get() = null } data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant { override val displayName: String get() = profile.displayName + override val handle: String? get() = profile.handle } } \ No newline at end of file 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 4f5a318676..826de2160a 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,13 +1,19 @@ package com.flipcash.app.core.navigation -import com.getcode.opencode.model.core.ID +import com.flipcash.app.core.tipping.TipCardOwner import com.getcode.solana.keys.Mint 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 + + /** + * Present someone's tip card. [owner] carries how the link named them — a `/tip/{id}` link by + * id, a vanity `flipcash.com/{username}` link by handle — because resolving the handle is a + * server round trip, and that belongs to the session rather than to the router. + */ + data class PresentTipCard(val owner: TipCardOwner): DeeplinkAction /** * A `/token/{mint}` link. @@ -25,5 +31,15 @@ sealed interface DeeplinkAction { val routes: List, ) : DeeplinkAction + /** + * A link the app captured but doesn't route — hand it back to the web. + * + * Only the bare `flipcash.com` host produces one. Its path space is shared with the website + * (/download, /privacy, /terms), and the App Link filter can only narrow it to the handle + * charset — which those words also satisfy, and which older platforms ignore entirely. Rather + * than dead-end the tap on the home screen, the URL goes to a browser. + */ + data class OpenExternally(val url: String) : 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 296768c332..7fed5caf64 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 @@ -22,6 +22,13 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class Tipcard(val userId: ID): DeeplinkType + /** + * A vanity `flipcash.com/{username}` link — the same destination as [Tipcard], addressed by the + * owner's public handle. The id it resolves to is the server's to supply, so it stays a + * username all the way to the session. + */ + @Serializable data class TipcardByUsername(val username: String): DeeplinkType + @Serializable data class EmailVerification( val email: String, diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/OwnTipCard.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/OwnTipCard.kt new file mode 100644 index 0000000000..4f2b2a45dd --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/OwnTipCard.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.core.tipping + +/** + * A tip card link turned out to address the account that followed it. + * + * Raised only by the resolve-by-handle path, and only after the round trip. Both earlier + * self-checks — `AppRouter`'s, and [com.flipcash.app.core.tipping.TipCardOwner.isSelf] in the + * session's tip card delegate — compare handles, which they can only do once this account's own + * profile has loaded. A link followed before that gets past both; the id the profile fetch answers + * with settles it. + * + * A failure rather than a card so the resolve stops short of its side effects — arming the tip + * modal, buzzing the phone — for a card that will never be tipped. + */ +class OwnTipCard(val username: String) : + IllegalStateException("@$username is the signed-in account's own handle") diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipCardOwner.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipCardOwner.kt new file mode 100644 index 0000000000..1bd267fe82 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipCardOwner.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.core.tipping + +import com.getcode.opencode.model.core.ID + +/** + * Who a tip card belongs to, in whichever of the two ways the holder of this value can name them. + * + * The fork exists because turning a handle into an id is a server round trip. Nothing between a + * `flipcash.com/{username}` link and the session can make that call, so the username travels + * un-resolved the whole way — through [com.flipcash.app.core.navigation.DeeplinkAction] and back + * out through [com.flipcash.app.core.util.Linkify]. Naming it once keeps every stop on that path + * from carrying its own parallel pair of "by id" / "by handle" entry points. + */ +sealed interface TipCardOwner { + /** By account id — how the app addresses a card everywhere except a vanity link. */ + data class ById(val userId: ID) : TipCardOwner + + /** By claimed public handle, unresolved. */ + data class ByUsername(val username: String) : TipCardOwner + + /** + * Whether this addresses the account signed in right now, which has no card to present to + * itself — tipping yourself is a payment no-op. + * + * Takes both identities rather than a profile, because they do not become available together: + * [accountId] is set the moment the account authenticates, while the handle arrives with the + * profile fetch. A single nullable profile would make a self-link by id stop matching in the + * window before its own profile loads. + * + * Handles are lowercase on the wire, but a link can be typed or pasted in any case. + */ + fun isSelf(accountId: ID?, username: String?): Boolean = when (this) { + is ById -> accountId != null && userId == accountId + is ByUsername -> this.username.equals(username, ignoreCase = true) + } + + companion object { + /** + * A card's preferred public address: the handle when the account has claimed one, the id + * when it hasn't. + * + * The precedence is as much a display decision as a routing one — the You tab shows + * `flipcash.com/` under the code (node 9442:3673), so sharing or copying anything + * else would hand out a second, unrecognisable address for a card that names itself once. + */ + fun preferringUsername(username: String?, userId: ID): TipCardOwner = + username?.takeIf { it.isNotBlank() }?.let(::ByUsername) ?: ById(userId) + } +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt index 08661aba6c..a00dc960bd 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/userprofile/UpdateProfileStep.kt @@ -11,9 +11,18 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable { @Serializable object Name : UpdateProfileStep + /** + * Claiming the public `@handle`. Optional and off by default: unlike the display name it is + * never part of onboarding — the server gates it behind a minimum balance, so it is reached + * from My Account or the "You" tab once the account qualifies. + */ + @Parcelize + @Serializable + object Username : UpdateProfileStep + @Parcelize @Serializable object Photo : UpdateProfileStep -} \ No newline at end of file +} 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 5a86d4c6c5..56f64012ab 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,7 +1,7 @@ package com.flipcash.app.core.util +import com.flipcash.app.core.tipping.TipCardOwner 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 @@ -12,7 +12,23 @@ 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}" + + /** + * A tip card's URL, in the form its [owner] is named by. + * + * The handle form — `flipcash.com/sally_streamer` (node 9442:3673) — is what an account shows + * and shares once it has claimed a username, because it reads as a person rather than as a + * UUID; the id form stays the address for an account without one. [TipCardOwner.preferringUsername] + * is that precedence, for callers that hold both. + * + * Note the handle form's bare host, no `app.` subdomain: the manifest claims `flipcash.com` for + * username-shaped paths only, so this is the exact shape that has to resolve back into the app. + */ + fun tipcard(owner: TipCardOwner): String = when (owner) { + is TipCardOwner.ById -> "https://app.flipcash.com/tip/${owner.userId.uuid}" + is TipCardOwner.ByUsername -> "https://flipcash.com/${owner.username}" + } + 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 fb0429defd..dc6db3b998 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -929,6 +929,41 @@ AI flagged this name for impersonation. Please try a different name. If you think the name was rejected in error please DM @flipcash on X AI flagged this name as misleading. Please try a different name. If you think the name was rejected in error please DM @flipcash on X AI flagged this name as spam. Please try a different name. If you think the name was rejected in error please DM @flipcash on X + + Enter username + This is how you\'ll be uniquely identified on the platform + Username + Change Username + + + Get a custom @username + Get your balance to %1$s or more to unlock + Tap to select your username now + %1$s to go + Username Taken + Please try a different username + + Inappropriate Username + Trademarks Not Allowed + Please pick a different name that is not trademarked. If you own the trademark please contact support@flipcash.com + Invalid Characters + Only letters, numbers, and underscores are allowed + Too Long + Usernames must be a maximum of %1$d characters + Too Short + Usernames must be a minimum of %1$d characters + %1$s Minimum Balance Required + In order to stop username squatting getting a username requires a total Flipcash balance of at least %1$s + Something Went Wrong + Please try again + + + No Such Account + Nobody has claimed \@%1$s + Couldn\'t Open Tip Card + Please check your connection and try again + This Photo is Not Allowed AI flagged this photo. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X AI flagged this photo as sexually explicit. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index b995b55ad1..68b80a11db 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) + // Balance for the username gate only — :shared:tokens:core is the narrow half, so the + // whole token stack does not come with it. + implementation(project(":apps:flipcash:shared:tokens:core")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt new file mode 100644 index 0000000000..851709b4c6 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.services.models.isUsernameShaped + +/** How much of an opaque id survives abbreviation. Matches iOS's `identifierStubLength`. */ +private const val ABBREVIATED_ID_LENGTH = 5 + +/** + * `https://app.flipcash.com/tip/` -> `app.flipcash.com/tip/b0ced…` (node 9276:4753). The + * user never types this — it's a recognisable stand-in for the link the copy button puts on the + * clipboard, so it's cut short rather than ellipsized at whatever width the device happens to give. + * + * A vanity link is left whole: `flipcash.com/sally_streamer` (node 9442:3673) is the entire point + * of claiming a handle, it fits, and abbreviating it would hide the part that identifies the + * person. The test is the handle's own shape, so only an opaque id is ever cut. + * + * Mirrors iOS `TipCardLinkRow.displayText(for:)`. + */ +internal fun String.abbreviatedLink(): String { + val withoutScheme = substringAfter("://") + val lastSegment = withoutScheme.substringAfterLast('/') + if (lastSegment.isUsernameShaped()) return withoutScheme + if (lastSegment.length <= ABBREVIATED_ID_LENGTH) return withoutScheme + val prefix = withoutScheme.removeSuffix(lastSegment) + return "$prefix${lastSegment.take(ABBREVIATED_ID_LENGTH)}…" +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index c207c9e579..c3387af397 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -81,6 +81,8 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.menu.MenuList import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState +import com.flipcash.app.menu.internal.components.UsernameProgress +import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.services.models.UserProfile @@ -93,6 +95,7 @@ import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.White08 import com.getcode.theme.White50 +import com.getcode.theme.extraSmall import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.core.noRippleClickable @@ -183,7 +186,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // margins — same rule iOS applies. val expandedCardWidth = minOf( FullScreenCardWidth, - maxWidth - PageHorizontalInset * 2, + maxWidth - CodeTheme.dimens.inset * 2, ) // How far into the expansion we are. One progress drives all of it — the card's // size and position, the page fading out beneath it, the Close row — so a swipe that @@ -207,8 +210,13 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // // The card is drawn at [FullScreenCardWidth], the widest it ever gets, so the scale // only ever samples the drawing down. - val cardScale = remember(expandedCardWidth) { - { lerp(YouCardWidth, expandedCardWidth, progress()) / FullScreenCardWidth } + // + // Both widths are read here rather than inside the lambda: they are theme-backed now, + // and the lambda the frame calls is not a composable scope. + val restingCardWidth = YouCardWidth + val drawnCardWidth = FullScreenCardWidth + val cardScale = remember(expandedCardWidth, restingCardWidth, drawnCardWidth) { + { lerp(restingCardWidth, expandedCardWidth, progress()) / drawnCardWidth } } // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as // bottom content padding — the list then scrolls clear of the bar instead of running @@ -222,13 +230,14 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // Everything but the card fades out on the expansion and slides down out of the way, // rather than being removed. Keeping the rows in the layout means nothing reflows on // the way back (iOS does the same with opacity + offset). - val slideAway = remember(progress) { + val slideDistance = ContentSlideDistance + val slideAway = remember(progress, slideDistance) { Modifier.graphicsLayer { val fraction = progress() // A settled flick overshoots its end a little, so keep the fade in a legal // alpha range rather than assuming the progress is one. alpha = (1f - fraction).coerceIn(0f, 1f) - translationY = ContentSlideDistance.toPx() * fraction + translationY = slideDistance.toPx() * fraction } } @@ -307,6 +316,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) }, + usernameProgress = state.usernameProgress, + usernameMinimumBalance = state.usernameMinimumBalance, + onClaimUsername = { viewModel.dispatchEvent(Event.ClaimUsername) }, ) } else { MoneyTiles(viewModel, navigator) @@ -367,30 +379,40 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } } -/** Distance from the status bar to the top of the tip card (node 9278:7301). */ -private val CardTopSpacing = 74.dp +/** Distance from the status bar to the top of the tip card (node 9278:7301: 74). */ +private val CardTopSpacing: Dp + @Composable get() = CodeTheme.dimens.grid.x15 -/** The at-rest card width on the You tab (node 9278:7301: 241.636). */ -private val YouCardWidth = 242.dp +/** + * The card at rest (node 9278:7301: 241.636) and expanded (node 9277:121410: 302.21), both measured + * on a 402-wide frame and kept here as the fraction of the display they were drawn at rather than + * the dp they happened to measure on it. iOS pins 302 outright; as a fraction the card holds its + * proportion of a narrower or wider display instead of crowding one and stranding the other. + */ +private const val YouCardWidthFraction = 0.60f +private const val FullScreenCardWidthFraction = 0.75f -/** The expanded card's width (node 9277:121410: 302.21 on a 402 frame); iOS pins the same 302. */ -private val FullScreenCardWidth = 302.dp +private val YouCardWidth: Dp + @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction -/** The page's horizontal inset, and so the expanded card's minimum margin. */ -private val PageHorizontalInset = 20.dp +private val FullScreenCardWidth: Dp + @Composable get() = CodeTheme.dimens.screenWidth * FullScreenCardWidthFraction /** Gap between the Close row and the system nav bar (node 9277:121410). */ -private val CloseBottomSpacing = 8.dp +private val CloseBottomSpacing: Dp + @Composable get() = CodeTheme.dimens.grid.x2 /** * Clearance between the last settings row's divider and the version footer. iOS spends 32 above the * footer plus 12 of the footer's own vertical padding on top of the row's 25 inset; the Android row * already pays that same 25, so the difference lands here. */ -private val VersionFooterTopSpacing = 44.dp +private val VersionFooterTopSpacing: Dp + @Composable get() = CodeTheme.dimens.grid.x9 /** How far the page's content slides down as it fades out under the expanding card. */ -private val ContentSlideDistance = 60.dp +private val ContentSlideDistance: Dp + @Composable get() = CodeTheme.dimens.grid.x12 /** * How much of the expansion the "Full Screen" caption has to be gone within — a fraction of the @@ -426,6 +448,9 @@ private fun YouHeader( onShare: () -> Unit, onDownload: () -> Unit, onClaim: () -> Unit, + usernameProgress: UsernameProgress?, + usernameMinimumBalance: String, + onClaimUsername: () -> Unit, ) { when (tipCardState) { TipCardState.Unknown -> Unit @@ -449,6 +474,9 @@ private fun YouHeader( onCopyLink = onCopyLink, onShare = onShare, onDownload = onDownload, + usernameProgress = usernameProgress, + usernameMinimumBalance = usernameMinimumBalance, + onClaimUsername = onClaimUsername, ) } } @@ -479,6 +507,9 @@ private fun ClaimedTipCard( onCopyLink: () -> Unit, onShare: () -> Unit, onDownload: () -> Unit, + usernameProgress: UsernameProgress?, + usernameMinimumBalance: String, + onClaimUsername: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -545,13 +576,13 @@ private fun ClaimedTipCard( modifier = slideAway.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(64.dp)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x13)) Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.grid.x5), - verticalArrangement = Arrangement.spacedBy(11.dp), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { if (link != null) { TipLinkRow(link = link, enabled = enabled, onCopy = onCopyLink) @@ -560,8 +591,8 @@ private fun ClaimedTipCard( Row( modifier = Modifier .fillMaxWidth() - .height(88.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + .height(CodeTheme.dimens.grid.x18), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { ShareTile( modifier = Modifier.weight(1f), @@ -578,9 +609,20 @@ private fun ClaimedTipCard( onClick = onDownload, ) } + + // Only under a claimed card: an account with no display name is already being asked + // for one, and a second nudge under the blurred stand-in isn't in the design. Gone + // entirely once a handle exists — the caller nulls it out. + if (usernameProgress != null) { + UsernameProgressCard( + progress = usernameProgress, + minimumBalance = usernameMinimumBalance, + onClick = onClaimUsername, + ) + } } - Spacer(Modifier.height(19.dp)) + Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) } } } @@ -649,9 +691,15 @@ private fun UnclaimedTipCardPrompt( if (placeholder != null) { // A nameless account renders its name line as a bare "Tip ", which frosts to a much // narrower smudge than a real card's. Stand a name in so the blur has the weight the - // claimed card's would (iOS `YouScreen.placeholderName`). + // claimed card's would (iOS `YouScreen.placeholderName`). The handle goes with it — + // this card is a stand-in, and a real `@handle` under a stand-in name is neither. val stoodIn = remember(placeholder, placeholderName) { - placeholder.copy(user = placeholder.user.copy(displayName = placeholderName)) + placeholder.copy( + user = placeholder.user.copy( + displayName = placeholderName, + username = null, + ) + ) } CompositionLocalProvider( @@ -686,7 +734,7 @@ private fun UnclaimedTipCardPrompt( textAlign = TextAlign.Center, ) Text( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), text = stringResource(CoreR.string.subtitle_tipIntro), style = CodeTheme.typography.textSmall, // Full strength, not secondary: it sits over the blurred code's glow. @@ -695,11 +743,14 @@ private fun UnclaimedTipCardPrompt( ) Text( modifier = Modifier - .padding(top = 20.dp) + .padding(top = CodeTheme.dimens.grid.x4) .clip(CircleShape) .background(CodeTheme.colors.textMain) .clickable(enabled = enabled, onClick = onClaim) - .padding(horizontal = 25.dp, vertical = 10.dp), + .padding( + horizontal = CodeTheme.dimens.grid.x5, + vertical = CodeTheme.dimens.grid.x2, + ), text = stringResource(CoreR.string.action_startReceivingTips), style = CodeTheme.typography.textMedium, color = CodeTheme.colors.background, @@ -719,17 +770,20 @@ private const val TipCardCornerFraction = 0.08f private val PlaceholderBlurRadius = 12.dp /** The hairline that keeps the blurred stand-in readable as a card rather than a smudge. */ -private val PlaceholderBorderWidth = 1.dp +private val PlaceholderBorderWidth: Dp + @Composable get() = CodeTheme.dimens.border /** Margin between the claim prompt and the stand-in card's edges (iOS: 16 across the pair). */ -private val PromptInset = 8.dp +private val PromptInset: Dp + @Composable get() = CodeTheme.dimens.grid.x2 /** * Gap between the unclaimed stand-in and the first settings row. Wider than the claimed card's 19, * because the claimed card pays part of its clearance in the Share / Download tiles that the * unclaimed state doesn't draw (iOS `YouScreen`: `.padding(.top, displayName == nil ? 48 : 19)`). */ -private val UnclaimedRowsGap = 48.dp +private val UnclaimedRowsGap: Dp + @Composable get() = CodeTheme.dimens.grid.x10 /** * The label + chevron that toggles the card's full-screen state — "Full Screen" pointing down under @@ -745,7 +799,7 @@ private fun FullScreenToggle( Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { Text( text = label, @@ -780,8 +834,8 @@ private fun TipLinkRow(link: String, enabled: Boolean, onCopy: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .height(40.dp) - .clip(TileShape) + .height(CodeTheme.dimens.grid.x8) + .clip(CodeTheme.shapes.extraSmall) .background(White05) .clickable(enabled = enabled) { onCopy() @@ -794,7 +848,7 @@ private fun TipLinkRow(link: String, enabled: Boolean, onCopy: () -> Unit) { Row( modifier = Modifier.weight(1f, fill = false), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { Icon( modifier = Modifier.size(20.dp), @@ -851,10 +905,13 @@ private fun ShareTile( Column( modifier = modifier .fillMaxSize() - .clip(TileShape) + .clip(CodeTheme.shapes.extraSmall) .background(White05) .clickable(enabled = enabled) { onClick() }, - verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.grid.x1, + Alignment.CenterVertically, + ), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( @@ -871,23 +928,6 @@ private fun ShareTile( } } -private val TileShape = RoundedCornerShape(6.dp) - -/** - * `https://app.flipcash.com/tip/` -> `app.flipcash.com/tip/b0ced...` (node 9276:4753). The - * user never types this — it's a recognisable stand-in for the link the copy button puts on the - * clipboard, so it's cut short rather than ellipsized at whatever width the device happens to give. - */ -private fun String.abbreviatedLink(): String { - val withoutScheme = substringAfter("://") - val lastSegment = withoutScheme.substringAfterLast('/') - if (lastSegment.length <= ABBREVIATED_ID_LENGTH) return withoutScheme - val prefix = withoutScheme.removeSuffix(lastSegment) - return "$prefix${lastSegment.take(ABBREVIATED_ID_LENGTH)}..." -} - -private const val ABBREVIATED_ID_LENGTH = 5 - /** v1 Settings-sheet header: the Add Money / Withdraw tiles (removed from the v2 You tab). */ @Composable private fun MoneyTiles( @@ -906,7 +946,7 @@ private fun MoneyTiles( text = stringResource(R.string.action_addMoney), icon = painterResource(R.drawable.ic_menu_deposit) ) { - viewModel.dispatchEvent(Event.PresentDepositOptions) + viewModel.dispatchEvent(Event.PresentDepositOptions()) } TileButton( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 0a3bc9c37d..c06d4902f0 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -15,25 +15,33 @@ import com.flipcash.app.core.share.TipCodeExporter import com.flipcash.app.core.util.Linkify import com.flipcash.app.featureflags.BetaFeature import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.app.updates.ReleaseStage import com.flipcash.app.updates.ReleaseStageProvider +import com.flipcash.app.tokens.core.TotalBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.menu.BuildConfig import com.flipcash.features.menu.R +import com.flipcash.services.models.UserProfile import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.ID +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.FlowPreview +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -46,6 +54,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +/** + * The minimum-balance gate as the copy quotes it — `$100 USD` rather than `$100.00`. Shared by the + * card and the sheet so a round threshold never renders two different ways. + */ +private fun Fiat.formattedGate(): String = + formatted(rule = Fiat.FormattingRule.Truncated, suffix = currencyCode.name) + private val FullMenuList = buildList { add(MyAccount) add(AdvancedFeatures) @@ -61,6 +76,7 @@ internal class MenuScreenViewModel @Inject constructor( dispatchers: DispatcherProvider, releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, + totalBalance: TotalBalanceProvider, analytics: FlipcashAnalyticsService, private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, @@ -84,6 +100,12 @@ internal class MenuScreenViewModel @Inject constructor( val releaseTrack: String = "", // The viewer's own tip card, shown at the top of the v2 "You" tab. val tipCardState: TipCardState = TipCardState.Unknown, + // The nudge toward claiming a `@handle`, or null when there is nothing to nudge about — a + // handle already exists, or the account state hasn't resolved yet. + val usernameProgress: UsernameProgress? = null, + // The gate, formatted (e.g. `$100 USD`). Carried next to [usernameProgress] because both the + // card's locked subtitle and the sheet behind its tap quote it. + val usernameMinimumBalance: String = "", ) { /** The card to share, export or expand — only a claimed one qualifies. */ val tipCard: Scannable.TipCard? @@ -123,9 +145,23 @@ internal class MenuScreenViewModel @Inject constructor( data class OnAppVersionUpdated(val versionInfo: VersionInfo) : Event data class OnReleaseTrackDetermined(val stage: String): Event data class OnStaffUserDetermined(val staff: Boolean) : Event - data object PresentDepositOptions: Event + /** + * Add money, tagged with what prompted it. The default covers the menu's own row; the + * username gate passes its own source so a shortfall-driven deposit isn't reported as a + * deliberate visit to Add Money. + */ + data class PresentDepositOptions( + val source: Analytics.AddMoneySource = Analytics.AddMoneySource.Menu, + ) : Event data class OpenScreen(val screen: AppRoute) : Event data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event + data class OnUsernameProgressChanged( + val progress: UsernameProgress?, + val minimumBalance: String, + ) : Event + + /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ + data object ClaimUsername : Event /** The claim prompt's CTA — collect a display name so the account gets a real card. */ data object ClaimTipCard : Event data object ShareTipCard : Event @@ -197,8 +233,8 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() - .mapNotNull { - analytics.addMoneyOpened(Analytics.AddMoneySource.Menu) + .mapNotNull { event -> + analytics.addMoneyOpened(event.source) purchaseMethodController.presentDepositOptions(popToRoot = true) }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) @@ -227,7 +263,7 @@ internal class MenuScreenViewModel @Inject constructor( val userId = tippingCoordinator.currentUserId dispatchEvent( Event.OnTipCardStateChanged( - TipCardState.Claimed(card, userId?.let { Linkify.tipcard(it) }) + TipCardState.Claimed(card, tipCardLink(card.user, userId)) ) ) userId?.let { tipCodePreviewCache.prepare(it, card) } @@ -236,6 +272,82 @@ internal class MenuScreenViewModel @Inject constructor( } .launchIn(viewModelScope) + // The username nudge. Gated on Ready for the same reason as the tip card: a named account + // restores its cached profile before auth completes, so the card would otherwise flash for + // someone who already holds a handle. + combine( + userManager.state + .filter { it.authState is AuthState.Ready } + .map { it.userProfile?.username }, + userFlags.resolvedFlags.map { it.usernameMinBalance.effectiveValue }, + totalBalance.observeTotalBalance(), + ) { username, minimum, balance -> + val progress = when (val gate = usernameGate(username, minimum, balance)) { + UsernameGate.Claimed -> null + UsernameGate.Unlocked -> UsernameProgress.Unlocked + is UsernameGate.Locked -> UsernameProgress.Locked( + fraction = gate.fraction, + remaining = gate.shortfall.formattedGate(), + ) + } + Event.OnUsernameProgressChanged(progress, minimum.formattedGate()) + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + when (stateFlow.value.usernameProgress) { + UsernameProgress.Unlocked -> dispatchEvent( + Event.OpenScreen( + AppRoute.UpdateUserProfile( + origin = AppRoute.Sheets.Menu, + // Inert: the name step is skipped, but the route asks for a source. + nameSource = DisplayNameSource.TipCardSetup, + includeName = false, + includePhoto = false, + includeUsername = true, + ) + ) + ) + + // Below the minimum the tap states the rule instead of walking into a rejection + // on submit. Same strings as the server's refusal, and the same informational + // style the entry screen gives it, so the two can't disagree. + is UsernameProgress.Locked -> BottomBarManager.showInfo( + title = resources.getString( + R.string.error_title_usernameMinimumBalance, + stateFlow.value.usernameMinimumBalance, + ), + message = resources.getString( + R.string.error_description_usernameMinimumBalance, + stateFlow.value.usernameMinimumBalance, + ), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_addMoney), + onClick = { + dispatchEvent( + Event.PresentDepositOptions( + Analytics.AddMoneySource.UsernameShortfall + ) + ) + }, + ), + BottomBarAction( + text = resources.getString(R.string.action_dismiss), + style = BottomBarManager.BottomBarButtonStyle.Text, + ), + ), + ) + + null -> Unit + } + } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -311,11 +423,22 @@ internal class MenuScreenViewModel @Inject constructor( val title = stateFlow.value.tipCard?.user?.displayName ?.let { resources.getString(R.string.label_tipUser, it) } // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. - shareable.present(Shareable.TipCard(userId, tipCodePreviewCache.get(userId), title)) + shareable.present( + Shareable.TipCard( + userId = userId, + preview = tipCodePreviewCache.get(userId), + title = title, + username = stateFlow.value.tipCard?.user?.username, + ) + ) } .launchIn(viewModelScope) } + /** The link the card shares itself with. Null only when there is no signed-in user to address. */ + private fun tipCardLink(profile: UserProfile, userId: ID?): String? = + userId?.let { Linkify.tipcard(TipCardOwner.preferringUsername(profile.username, it)) } + internal companion object { private const val TAP_THRESHOLD = 6 private const val COUNTDOWN_START = 3 @@ -390,9 +513,17 @@ internal class MenuScreenViewModel @Inject constructor( state.copy(tipCardState = event.tipCardState) } - Event.PresentDepositOptions, + is Event.OnUsernameProgressChanged -> { state -> + state.copy( + usernameProgress = event.progress, + usernameMinimumBalance = event.minimumBalance, + ) + } + + is Event.PresentDepositOptions, Event.CheckForUpdate, Event.ClaimTipCard, + Event.ClaimUsername, Event.ShareTipCard, Event.CopyTipLink, Event.DownloadTipCard, diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt new file mode 100644 index 0000000000..48ce924e9e --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.menu.internal + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.minus + +/** + * Whether the signed-in account may claim a handle, and how far off it is if not. + * + * The server enforces the same minimum on submit (`SetUsernameError.InsufficientBalance`); this is + * the local reading of it, so the You tab can show the distance rather than let the user type a + * handle into a rejection. + * + * Kept as arithmetic over [Fiat] rather than the card's presentation type: the caller owns + * formatting and string resources, this owns the rule. + * + * Mirrors iOS `usernameGate(session:minimum:)` in `UsernameGate.swift`. + */ +internal sealed interface UsernameGate { + /** A handle is already claimed — the nudge is spent, and changing it lives in My Account. */ + data object Claimed : UsernameGate + + /** Nothing in the way: either the balance clears the minimum, or there is no minimum. */ + data object Unlocked : UsernameGate + + /** + * Short of the minimum by [shortfall], which is [fraction] of the way there. + * + * [fraction] is in `0f..1f` by construction — this arm is only reached below [minimum]. + */ + data class Locked( + val minimum: Fiat, + val shortfall: Fiat, + val fraction: Float, + ) : UsernameGate +} + +/** + * @param username the account's claimed handle, null or blank when it hasn't claimed one. + * @param minimum the balance the account must hold to claim, from the `usernameMinBalance` flag. + * @param balance the account's total balance, in the same currency as [minimum]. + */ +internal fun usernameGate( + username: String?, + minimum: Fiat, + balance: Fiat, +): UsernameGate = when { + !username.isNullOrBlank() -> UsernameGate.Claimed + // A zero minimum is no gate at all — which is also what an unresolved flag looks like. Either + // reading leaves nothing holding the account back, so both fail open. + !minimum.isPositive -> UsernameGate.Unlocked + balance.valueGreaterThanOrEqualTo(minimum) -> UsernameGate.Unlocked + else -> UsernameGate.Locked( + minimum = minimum, + shortfall = minimum - balance, + fraction = (balance.toDouble() / minimum.toDouble()).toFloat().coerceIn(0f, 1f), + ) +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt new file mode 100644 index 0000000000..8fd9e69558 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt @@ -0,0 +1,190 @@ +package com.flipcash.app.menu.internal.components + +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.SystemGreen +import com.getcode.theme.White +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.theme.White50 +import com.getcode.theme.extraSmall + +/** + * How close the account is to being allowed a `@handle`, and — once it is — the way in. + * + * Locked and [Unlocked] are the design's two variants (nodes 9536:4336 and 9537:1845) rather than a + * bag of nullable fields, so a full bar can never render next to an amount still to go. + */ +internal sealed interface UsernameProgress { + /** + * @param fraction how much of the minimum the balance covers, `0f..1f`. + * @param remaining the shortfall, already formatted for display (e.g. `$12.50 USD`). + */ + data class Locked(val fraction: Float, val remaining: String) : UsernameProgress + + data object Unlocked : UsernameProgress +} + +/** + * Nodes 9536:4336 / 9537:1845 — the "You" tab's nudge toward claiming a `@handle`, sitting under the + * Share / Download tiles in the same 88dp skin. + * + * Tappable in both states: below the minimum the tap is what surfaces the "Minimum Balance Required" + * sheet, which is the only place the rule is spelled out. The caller decides that, and also decides + * whether the card renders at all — it is gone once a handle exists. + * + * [minimumBalance] is the formatted threshold (e.g. `$100 USD`), interpolated into the locked + * subtitle; it comes from the same `usernameMinBalance` flag the entry screen's rejection dialog + * reads, so the two never quote different numbers. + */ +@Composable +internal fun UsernameProgressCard( + progress: UsernameProgress, + minimumBalance: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x18) + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .clickable { onClick() } + .padding(horizontal = CodeTheme.dimens.grid.x3), + ) { + Column(modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.title_usernameUpsell), + style = CodeTheme.typography.textSmall, + color = White, + ) + when (progress) { + is UsernameProgress.Locked -> Text( + text = stringResource(R.string.label_usernameAmountToGo, progress.remaining), + style = CodeTheme.typography.textSmall, + color = White50, + ) + + // The affordance only appears once tapping it leads somewhere other than a + // rejection. + UsernameProgress.Unlocked -> Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = White50, + ) + } + } + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = when (progress) { + is UsernameProgress.Locked -> stringResource( + R.string.subtitle_usernameUpsellLocked, + minimumBalance, + ) + + UsernameProgress.Unlocked -> + stringResource(R.string.subtitle_usernameUpsellUnlocked) + }, + style = CodeTheme.typography.caption, + color = White50, + ) + } + + UsernameProgressBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = CodeTheme.dimens.grid.x3), + fraction = when (progress) { + is UsernameProgress.Locked -> progress.fraction + UsernameProgress.Unlocked -> 1f + }, + // Green only on the full bar: it reads as "done", which a partial bar isn't. + color = when (progress) { + is UsernameProgress.Locked -> White + UsernameProgress.Unlocked -> SystemGreen + }, + ) + } +} + +@Composable +private fun UsernameProgressBar( + fraction: Float, + color: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(CodeTheme.dimens.grid.x1) + .clip(CircleShape) + .background(White10), + ) { + Box( + modifier = Modifier + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(CircleShape) + .background(color), + ) + } +} + +@Preview(name = "Below the minimum") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UsernameProgressCard_Locked() { + Box(modifier = Modifier.fillMaxSize().padding(CodeTheme.dimens.inset)) { + UsernameProgressCard( + progress = UsernameProgress.Locked(fraction = 0.84f, remaining = "$12.50 USD"), + minimumBalance = "$100 USD", + onClick = {}, + ) + } +} + +@Preview(name = "Minimum met") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UsernameProgressCard_Unlocked() { + Box(modifier = Modifier.fillMaxSize().padding(CodeTheme.dimens.inset)) { + UsernameProgressCard( + progress = UsernameProgress.Unlocked, + minimumBalance = "$100 USD", + onClick = {}, + ) + } +} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt new file mode 100644 index 0000000000..0944ec6aeb --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.menu.internal + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AbbreviatedLinkTest { + + @Test + fun `an id link keeps the first five characters of the id`() { + assertEquals( + "app.flipcash.com/tip/b0ced…", + "https://app.flipcash.com/tip/b0ced1f2a3b4c5d6".abbreviatedLink(), + ) + } + + @Test + fun `a vanity link is left whole`() { + assertEquals( + "flipcash.com/sally_streamer", + "https://flipcash.com/sally_streamer".abbreviatedLink(), + ) + } + + @Test + fun `the longest allowed handle still isn't abbreviated`() { + assertEquals( + "flipcash.com/abcdefghijklmno", + "https://flipcash.com/abcdefghijklmno".abbreviatedLink(), + ) + } + + @Test + fun `a segment too long to be a handle is abbreviated`() { + assertEquals( + "flipcash.com/abcde…", + "https://flipcash.com/abcdefghijklmnop".abbreviatedLink(), + ) + } + + @Test + fun `an id no longer than the stub is left alone rather than gaining an ellipsis`() { + assertEquals( + "app.flipcash.com/tip/AB-CD", + "https://app.flipcash.com/tip/AB-CD".abbreviatedLink(), + ) + } + + @Test + fun `the scheme is always dropped`() { + assertEquals( + "flipcash.com/mcansh", + "http://flipcash.com/mcansh".abbreviatedLink(), + ) + } + + @Test + fun `a link with no scheme survives unchanged`() { + assertEquals( + "flipcash.com/mcansh", + "flipcash.com/mcansh".abbreviatedLink(), + ) + } +} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt new file mode 100644 index 0000000000..30628e48ad --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.menu.internal + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class UsernameGateTest { + + private fun usd(amount: Double) = Fiat(fiat = amount, currencyCode = CurrencyCode.USD) + + @Test + fun `a claimed handle spends the nudge, whatever the balance`() { + assertEquals( + UsernameGate.Claimed, + usernameGate(username = "mcansh", minimum = usd(25.0), balance = Fiat.Zero), + ) + } + + @Test + fun `a blank handle counts as unclaimed`() { + assertIs( + usernameGate(username = " ", minimum = usd(25.0), balance = Fiat.Zero), + ) + } + + @Test + fun `a zero minimum fails open`() { + assertEquals( + UsernameGate.Unlocked, + usernameGate(username = null, minimum = Fiat.Zero, balance = Fiat.Zero), + ) + } + + @Test + fun `exactly the minimum unlocks`() { + assertEquals( + UsernameGate.Unlocked, + usernameGate(username = null, minimum = usd(25.0), balance = usd(25.0)), + ) + } + + @Test + fun `above the minimum unlocks`() { + assertEquals( + UsernameGate.Unlocked, + usernameGate(username = null, minimum = usd(25.0), balance = usd(25.01)), + ) + } + + @Test + fun `below the minimum reports the shortfall and how far along it is`() { + val gate = usernameGate(username = null, minimum = usd(25.0), balance = usd(20.0)) + + assertIs(gate) + assertEquals(usd(25.0), gate.minimum) + assertEquals(usd(5.0), gate.shortfall) + assertEquals(0.8f, gate.fraction) + } + + @Test + fun `an empty balance is zero progress, not a divide by zero`() { + val gate = usernameGate(username = null, minimum = usd(25.0), balance = Fiat.Zero) + + assertIs(gate) + assertEquals(usd(25.0), gate.shortfall) + assertEquals(0f, gate.fraction) + } + + @Test + fun `a negative balance clamps to zero progress rather than a backwards bar`() { + val gate = usernameGate(username = null, minimum = usd(25.0), balance = usd(-5.0)) + + assertIs(gate) + assertEquals(0f, gate.fraction) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 3f3e2a0e9e..977b76e14d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -101,7 +101,18 @@ internal fun ContactInfoContainer( } } - if (contact != null && !contact.isUnknown) { + // The line under the name says how this person is addressed: a tip DM by their public + // handle (node 9443:8928), a contact DM by the number the chat is keyed on. Never both — + // only one of the two identity sources backs any given conversation. + val handle = participant?.handle + if (handle != null) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = handle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } else if (contact != null && !contact.isUnknown) { Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), text = contact.displayNumber, @@ -260,7 +271,10 @@ private fun Preview_AllStates() { // A tip DM's counterparty: identity from a server profile — no phone number, no pill. val tipUser = ChatParticipant.TipUser( userId = listOf(1.toByte()), - profile = UserProfile.Empty.copy(displayName = "Grace Hopper"), + profile = UserProfile.Empty.copy( + displayName = "Grace Hopper", + username = "grace_hopper", + ), ) // Fixed width so every state renders at the same size regardless of name/number length. diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index a679c28bc7..87b2f4fe2b 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -58,6 +58,22 @@ fun MyAccountScreen() { }.launchIn(this) } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { + navigator.push( + AppRoute.UpdateUserProfile( + origin = AppRoute.Menu.MyAccount, + nameSource = DisplayNameSource.MyAccount, + includeName = false, + includePhoto = false, + includeUsername = true, + ) + ) + }.launchIn(this) + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index 682bdead5a..1142cbf066 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.myaccount.internal.myaccount import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContactMail +import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.Badge import androidx.compose.material.icons.outlined.Block import androidx.compose.runtime.Composable @@ -32,6 +33,23 @@ internal data object ChangeDisplayName : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.AlternateEmail) + override val name: String + @Composable get() = stringResource(CoreR.string.title_changeUsername) + override val action: MyAccountScreenViewModel.Event = + MyAccountScreenViewModel.Event.OnChangeUsernameClicked +} + /** * A toggle, not a destination — the screen renders a switch in its trailing slot and routes the tap * through a biometric prompt. Its [action] is what a row tap dispatches, same as the switch. diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index 712ec602ae..b96bb44114 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -20,6 +20,7 @@ import javax.inject.Inject private val FullMenuList = buildList { add(ChangeDisplayName) + add(ChangeUsername) add(RequireBiometrics) add(Blocklist) add(UserProfile) @@ -61,6 +62,8 @@ internal class MyAccountScreenViewModel @Inject constructor( data object OnBiometricsToggled : Event data object OnChangeDisplayNameClicked : Event data object OnEditDisplayName : Event + data object OnChangeUsernameClicked : Event + data object OnEditUsername : Event data object OnBlocklistClicked: Event data object OnViewBlocklist: Event data object OnContactMethodsClicked : Event @@ -104,6 +107,12 @@ internal class MyAccountScreenViewModel @Inject constructor( dispatchEvent(Event.OnEditDisplayName) }.launchIn(viewModelScope) + eventFlow + .filterIsInstance() + .onEach { + dispatchEvent(Event.OnEditUsername) + }.launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { @@ -131,6 +140,8 @@ internal class MyAccountScreenViewModel @Inject constructor( Event.OnBiometricsToggled, Event.OnChangeDisplayNameClicked, Event.OnEditDisplayName, + Event.OnChangeUsernameClicked, + Event.OnEditUsername, Event.OnContactMethodsClicked, Event.OnViewUserProfile, Event.OnBlocklistClicked, diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt index 830314dffe..356f50ea01 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/userprofile/UserProfileScreenContent.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.flipcash.core.R import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.handle import com.flipcash.services.models.chat.MediaItem import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @@ -437,7 +438,7 @@ private fun SocialAccountRow( verticalArrangement = Arrangement.Center, ) { Text( - text = "@${account.username}", + text = account.handle, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) 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 a4230de256..f3a59b9534 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 @@ -19,6 +19,7 @@ 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 +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter @@ -164,7 +165,12 @@ internal fun Scanner() { } is DeeplinkType.Login -> Unit is DeeplinkType.Tipcard -> { - session.resolveTipCard(deeplink.userId) + session.resolveTipCard(TipCardOwner.ById(deeplink.userId)) + } + // A printed or on-screen `flipcash.com/{username}` is the + // same card as a scanned /tip/{id}, addressed by handle. + is DeeplinkType.TipcardByUsername -> { + session.resolveTipCard(TipCardOwner.ByUsername(deeplink.username)) } } } 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 10c5dad2b4..36e0019671 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 @@ -112,7 +112,14 @@ internal class TipFlowViewModel @Inject constructor( val title = stateFlow.value.tipCard?.user?.displayName ?.let { resources.getString(R.string.label_tipUser, it) } // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. - shareable.present(Shareable.TipCard(userId, tipCodePreviewCache.get(userId), title)) + shareable.present( + Shareable.TipCard( + userId = userId, + preview = tipCodePreviewCache.get(userId), + title = title, + username = stateFlow.value.tipCard?.user?.username, + ) + ) } .launchIn(viewModelScope) } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index b09d749964..32ead4fa32 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -9,6 +9,7 @@ import com.flipcash.app.core.userprofile.UpdateProfileResult import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.userprofile.internal.name.NameEntryScreen import com.flipcash.app.userprofile.internal.photo.PhotoSelectionScreen +import com.flipcash.app.userprofile.internal.username.UsernameEntryScreen import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowExitReason @@ -60,6 +61,9 @@ private fun profileUpdateProvider( annotatedEntry { NameEntryScreen(source = route.nameSource, allowBack = route.allowBack) } + annotatedEntry { + UsernameEntryScreen() + } annotatedEntry { PhotoSelectionScreen() } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt new file mode 100644 index 0000000000..baf0b919e0 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.userprofile.internal.username + +import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MinUsernameLength + +/** + * A handle that is the wrong length, raised locally and never off the wire — the server folds both + * ends into `INVALID_USERNAME`, and the dialog could no longer say which one was wrong. + * + * The third of iOS `UsernameValidator.Failure`'s cases, `invalidCharacters`, has no counterpart + * here: `UsernameInputTransformation` filters the charset as the user types, so the only way to + * reach that dialog on Android is a server rejection. + */ +internal sealed class LengthComplaint(message: String) : IllegalArgumentException(message) { + class TooShort : LengthComplaint("Username shorter than $MinUsernameLength characters") + class TooLong : LengthComplaint("Username longer than $MaxUsernameLength characters") +} + +/** The complaint [username] would earn on submit, or null when its length is acceptable. */ +internal fun lengthComplaint(username: String): LengthComplaint? = when { + username.length < MinUsernameLength -> LengthComplaint.TooShort() + username.length > MaxUsernameLength -> LengthComplaint.TooLong() + else -> null +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt new file mode 100644 index 0000000000..9d2f567b9a --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryScreen.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.userprofile.internal.username + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.core.userprofile.UpdateProfileResult +import com.flipcash.app.core.userprofile.UpdateProfileStep +import com.flipcash.core.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Node 9491:6297 — the public `@handle`, a step in the profile update flow. Serves both entry + * points and both cases: the "You" tab's progress card and My Account's Change Username row land + * here, and a first claim differs from a change only in whether the field arrives prefilled. + * + * Mirrors [com.flipcash.app.userprofile.internal.name.NameEntryScreen]; the difference is the + * input, which is held to the server's charset as it is typed. + */ +@Composable +internal fun UsernameEntryScreen() { + val flowNavigator = rememberFlowNavigator() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val keyboard = rememberKeyboardController() + + Column { + // Always backable, unlike the name step: this is a pushed route for both the first claim + // and a later change, never a mandatory step someone has to complete to get past it. + AppBarWithTitle( + onBackIconClicked = { + keyboard.hideIfVisible { + flowNavigator.back() + } + }, + ) + UsernameEntryScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.proceed() } + .launchIn(this) + } +} + +@Composable +private fun UsernameEntryScreenContent( + state: UsernameEntryViewModel.State, + dispatchEvent: (UsernameEntryViewModel.Event) -> Unit, +) { + val keyboard = rememberKeyboardController() + CodeScaffold( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset + ), + text = stringResource(R.string.title_usernameSelection), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3 + ).imePadding(), + text = stringResource(R.string.action_next), + enabled = state.hasUsername && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + } + }, + ) + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + // Padding on the wrapper rather than the field — see NameEntryScreen: on the field it + // inflates the box and drops the sublabel away from the entered text. + Column(modifier = Modifier.padding(padding)) { + DisplayTextInput( + state = state.usernameFieldState, + placeholder = stringResource(R.string.hint_username), + sublabel = stringResource(R.string.subtitle_usernameSelection), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + // A handle is lowercase and unspaced; autocapitalizing it would only produce + // input the transformation below has to undo. + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + } + }, + inputTransformation = UsernameInputTransformation, + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +/** + * Holds the field to the server's `^[a-z0-9_]+$` charset: uppercase is folded down and anything + * else is dropped, so a paste out of another app lands as a usable handle instead of a rejection. + * + * Length is deliberately not clamped here — the design has "Too Short" and "Too Long" dialogs, so + * over-typing has to be possible for the user to be told about it. + */ +private val UsernameInputTransformation = InputTransformation { + val current = asCharSequence().toString() + val sanitized = current.lowercase().filter { it in 'a'..'z' || it in '0'..'9' || it == '_' } + if (sanitized != current) { + replace(0, length, sanitized) + } +} diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt new file mode 100644 index 0000000000..c3bb936322 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt @@ -0,0 +1,209 @@ +package com.flipcash.app.userprofile.internal.username + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.features.userprofile.R +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MinUsernameLength +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.SetUsernameError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.errors.ValidationException +import com.getcode.opencode.model.financial.Fiat +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * Claiming the public `@handle`. Shaped like `NameEntryViewModel`, but the failure surface is much + * wider: the server can reject a username for six distinct reasons, each with its own dialog. + * + * Every rejection is informational rather than an error. They all describe something the user typed + * — taken, too short, wrong characters — and are fixed by typing something else; the destructive + * style would read as a fault in the app instead of a prompt to try another handle. + * + * Length is checked here rather than server-side so "Too Short" / "Too Long" name the actual + * problem instead of arriving as a generic `INVALID_USERNAME`. The charset is enforced as the user + * types (see `UsernameInputTransformation`), so `InvalidUsername` off the wire only ever means the + * server disagrees with us about the charset — it still gets the "Invalid Characters" dialog. + */ +@HiltViewModel +class UsernameEntryViewModel @Inject constructor( + private val userManager: UserManager, + private val profileController: ProfileController, + private val userFlags: UserFlagsCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent +) { + data class State( + val usernameFieldState: TextFieldState = TextFieldState(), + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) { + val hasUsername: Boolean + get() = usernameFieldState.text.isNotBlank() + } + + sealed interface Event { + data object CheckUsername : Event + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false + ) : Event + + data object OnUsernameApproved : Event + } + + init { + userManager.state + .mapNotNull { it.userProfile } + .map { profile -> profile.username } + .onEach { username -> + val inputState = stateFlow.value.usernameFieldState + inputState.setTextAndPlaceCursorAtEnd(username.orEmpty()) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } + .map { stateFlow.value.usernameFieldState.text.toString() } + .map { username -> + lengthComplaint(username) + ?.let { Result.failure(it) } + ?: profileController.setUsername(username) + } + .onResult( + onSuccess = { + viewModelScope.launch { + dispatchEvent(Event.UpdateProcessingState(success = true)) + delay(500.milliseconds) + dispatchEvent(Event.OnUsernameApproved) + dispatchEvent(Event.UpdateProcessingState()) + } + }, + onError = { cause -> + dispatchEvent(Event.UpdateProcessingState()) + handleUsernameSetFailure(cause) + } + ).launchIn(viewModelScope) + } + + private fun handleUsernameSetFailure(cause: Throwable) { + when (cause) { + is LengthComplaint.TooShort -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameTooShort), + message = resources.getString( + R.string.error_description_usernameTooShort, + MinUsernameLength, + ), + ) + + is LengthComplaint.TooLong -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameTooLong), + message = resources.getString( + R.string.error_description_usernameTooLong, + MaxUsernameLength, + ), + ) + + is SetUsernameError.AlreadyTaken -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameTaken), + message = resources.getString(R.string.error_description_usernameTaken), + ) + + // The moderator speaks about names, not usernames, so the descriptions are the + // display-name copy verbatim — only the title changes. + is SetUsernameError.FailedModerated -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameNotAllowed), + message = resources.getString(moderationDescription(cause.category)), + ) + + is SetUsernameError.ReservedWord -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameTrademarked), + message = resources.getString(R.string.error_description_usernameTrademarked), + ) + + is SetUsernameError.InsufficientBalance -> { + val minimum = userFlags.resolvedFlags.value.usernameMinBalance.effectiveValue + val formatted = minimum.formatted( + rule = Fiat.FormattingRule.Truncated, + suffix = minimum.currencyCode.name, + ) + BottomBarManager.showInfo( + title = resources.getString( + R.string.error_title_usernameMinimumBalance, + formatted, + ), + message = resources.getString( + R.string.error_description_usernameMinimumBalance, + formatted, + ), + ) + } + + is SetUsernameError.InvalidUsername, + is ValidationException -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameInvalidCharacters), + message = resources.getString(R.string.error_description_usernameInvalidCharacters), + ) + + // The only branch that isn't the user's doing — a failed check is ours to own, so it + // stays an error while every rejection above is informational. + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_usernameCheckFailed), + message = resources.getString(R.string.error_description_usernameCheckFailed), + ) + } + } + + private fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = + when (category) { + ModerationResult.FlaggedCategory.NONE -> + R.string.error_description_profileNameNotAllowed + ModerationResult.FlaggedCategory.OTHER -> + R.string.error_description_profileNameNotAllowedFlaggedOther + ModerationResult.FlaggedCategory.NSFW -> + R.string.error_description_profileNameNotAllowedFlaggedNsfw + ModerationResult.FlaggedCategory.IMPERSONATION -> + R.string.error_description_profileNameNotAllowedFlaggedImpersonation + ModerationResult.FlaggedCategory.MISLEADING -> + R.string.error_description_profileNameNotAllowedFlaggedMisleading + ModerationResult.FlaggedCategory.SPAM -> + R.string.error_description_profileNameNotAllowedFlaggedSpam + } + + companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + Event.CheckUsername -> { state -> state } + is Event.UpdateProcessingState -> { state -> + val current = state.processingState + state.copy( + processingState = current.copy( + loading = event.loading, + success = event.success + ) + ) + } + + Event.OnUsernameApproved -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt new file mode 100644 index 0000000000..b23e6c9fd2 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.userprofile.internal.username + +import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MinUsernameLength +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNull + +class LengthComplaintTest { + + @Test + fun `an empty field is too short`() { + assertIs(lengthComplaint("")) + } + + @Test + fun `one character short of the minimum is too short`() { + assertIs(lengthComplaint("a".repeat(MinUsernameLength - 1))) + } + + @Test + fun `exactly the minimum passes`() { + assertNull(lengthComplaint("a".repeat(MinUsernameLength))) + } + + @Test + fun `exactly the maximum passes`() { + assertNull(lengthComplaint("a".repeat(MaxUsernameLength))) + } + + @Test + fun `one character past the maximum is too long`() { + assertIs(lengthComplaint("a".repeat(MaxUsernameLength + 1))) + } + + // The charset is the input transformation's job, not this one's — a handle of the right length + // is accepted here even when the server would reject it, and gets the "Invalid Characters" + // dialog off the wire instead. + @Test + fun `characters are not this check's concern`() { + assertNull(lengthComplaint("Not A Handle!")) + } +} diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index aa875c92e4..757c380d74 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -90,7 +90,7 @@ object Analytics { data class SentTip(val origin: TipOrigin) : Transfer } enum class OnrampSource { Settings, Balance, Give } - enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, Chat, Scanner, Balance } + enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, UsernameShortfall, Chat, Scanner, Balance } enum class AddMoneyMethod { Coinbase, Phantom, OtherWallet, Reserves } enum class OnrampVerificationStep { ShowInfo, EnterPhone, ConfirmPhone, EnterEmail, ConfirmEmail } enum class OnrampPurchaseStep { PresetSelected, EnterCustomAmount, InvokePayment, InvokePaymentCustom, Completed } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt index e6f96b6cce..0a083bf21a 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt @@ -400,6 +400,7 @@ internal val Analytics.AddMoneySource.propertyValue: String Analytics.AddMoneySource.Menu -> "Menu" Analytics.AddMoneySource.GiveShortfall -> "Give Shortfall" Analytics.AddMoneySource.BuyShortfall -> "Buy Shortfall" + Analytics.AddMoneySource.UsernameShortfall -> "Username Shortfall" Analytics.AddMoneySource.Chat -> "Chat" Analytics.AddMoneySource.Scanner -> "Scanner" Analytics.AddMoneySource.Balance -> "Balance" 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 98029fd8cd..49b8e1fa16 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 @@ -84,6 +84,20 @@ fun Preview_CashBill() { @PreviewWrapper(FlipcashThemeWrapper::class) @Composable fun Preview_TipCard() { + TipCard( + payloadData = PREVIEW_CODE_DATA, + user = UserProfile.Empty.copy( + displayName = "Flipcash User", + username = "flipcash_user", + ) + ) +} + +/** The same card for an account that hasn't claimed a handle — the second line is absent, not blank. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +fun Preview_TipCard_NoUsername() { TipCard( payloadData = PREVIEW_CODE_DATA, user = UserProfile.Empty.copy( 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 faf539a9af..8cdff7ee09 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 @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -30,6 +29,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.bills.components.ScannableCode import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.handle import com.flipcash.shared.bills.R import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @@ -85,6 +85,12 @@ private const val TipCardNameFraction = 17f / 269f // Line height as a multiple of the font size, carried over from `textMedium` (20 on 16) so a name // that wraps to a second line keeps the same rhythm it had at the fixed size. private const val TipCardNameLineHeightRatio = 1.25f +// The claimed handle sits directly under the name and at the same size — node 9443:7991 draws both +// at 15 on the 241.6-wide card — so it scales with the rest of the figure. Medium rather than Demi, +// at half opacity, is what separates it from the name; a second type size would not survive the +// card being drawn at three different widths. +private const val TipCardHandleGapFraction = 4f / 241.636f +private const val TipCardHandleAlpha = 0.5f @OptIn(ExperimentalLayoutApi::class) @Composable @@ -138,10 +144,10 @@ internal fun TipCard( ) } - Row( + Column( modifier = Modifier.padding(top = height * TipCardNameTopFraction), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(width * TipCardHandleGapFraction), ) { Text( text = stringResource(R.string.label_tipUser, user.displayName), @@ -153,6 +159,21 @@ internal fun TipCard( maxLines = 2, overflow = TextOverflow.Ellipsis, ) + + // Only for an account that has claimed one — the line is absent rather than + // blank, so a card without a handle is the figure it always was. + user.handle?.let { handle -> + Text( + text = handle, + style = CodeTheme.typography.caption.copy( + fontSize = nameFontSize, + lineHeight = nameFontSize * TipCardNameLineHeightRatio, + ), + color = CodeTheme.colors.textMain.copy(alpha = TipCardHandleAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 8c9c01e68f..17a3adcd32 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 @@ -33,6 +33,7 @@ import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController import com.getcode.opencode.model.core.ID import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.handle import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.NavigationTrigger @@ -306,8 +307,7 @@ class NotificationService : FirebaseMessagingService(), private fun UserProfile.socialHandle(): String? = socialAccounts.filterIsInstance() .firstOrNull() - ?.username - ?.let { "@$it" } + ?.handle /** * Loads a remote avatar [url] into a software [Bitmap] via the app's shared diff --git a/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt index b94c2fb451..ea93d45fe1 100644 --- a/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt +++ b/apps/flipcash/shared/profile/src/main/kotlin/com/flipcash/shared/profile/ProfileCoordinator.kt @@ -77,12 +77,14 @@ class ProfileCoordinator @Inject constructor( @Serializable private data class CachedProfile( val displayName: String? = null, + val username: String? = null, val socialAccounts: List = emptyList(), val phoneNumber: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, ) { fun toDomain(): UserProfile = UserProfile( displayName = displayName.orEmpty(), + username = username, socialAccounts = socialAccounts.mapNotNull { it.toDomain() }, phoneNumber = phoneNumber, email = email, @@ -91,6 +93,7 @@ private data class CachedProfile( companion object { fun fromDomain(profile: UserProfile): CachedProfile = CachedProfile( displayName = profile.displayName, + username = profile.username, socialAccounts = profile.socialAccounts.map { CachedSocialAccount.fromDomain(it) }, phoneNumber = profile.phoneNumber, email = profile.email, diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt index ee6dad79d6..d8f8d3f600 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt @@ -19,6 +19,11 @@ object RouterModule { userManager: UserManager, ): Router = AppRouter( authStateProvider = { userManager.authState }, - currentUserIdProvider = { userManager.accountId }, + currentUserProvider = { + AppRouter.CurrentUser( + id = userManager.accountId, + username = userManager.profile?.username, + ) + }, ) } 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 57fc2951e8..c091d4df70 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 @@ -7,10 +7,12 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.isUsernameShaped import com.flipcash.app.core.navigation.Key import com.flipcash.app.core.navigation.fragments import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.router.Router import com.flipcash.app.router.internal.AppRouter.Companion.cashLink import com.flipcash.app.router.internal.AppRouter.Companion.chat @@ -32,8 +34,16 @@ import java.util.UUID internal class AppRouter( private val authStateProvider: () -> AuthState, - private val currentUserIdProvider: () -> ID?, + private val currentUserProvider: () -> CurrentUser, ) : Router { + + /** + * How the signed-in account can be addressed, for matching a tip card link against itself. + * Both nullable and read together: see [TipCardOwner.isSelf] for why the id and the handle + * cannot be sourced from one profile. + */ + internal data class CurrentUser(val id: ID?, val username: String?) + companion object { val login = listOf("login") val cashLink = listOf("c", "cash") @@ -47,10 +57,27 @@ internal class AppRouter( * the fragment as `#source=`. See [DeepLink.unwrapJumpTarget]. */ const val JUMP_HOST = "jump.flipcash.com" + + /** + * The bare host, which serves the website *and* every user's vanity profile link + * (`flipcash.com/sally_streamer`). Distinct from the `app.` / `send.` hosts, whose whole + * path space belongs to the app. + */ + const val VANITY_HOST = "flipcash.com" + + /** + * Paths on [VANITY_HOST] that belong to the website rather than to a person. Every one of + * them is charset-valid as a username, and the server reserves them — so a link to one + * could only ever fail to resolve, but it would fail *inside* the app, having taken the tap + * away from the browser. Ruling them out here keeps `flipcash.com/download` a web link. + */ + val reservedVanityPaths: Set = + setOf("download", "privacy", "terms", "support", "help", "about", "blog", "legal") + + login + cashLink + verification + token + chat + tip } override fun dispatch(deepLink: DeepLink): DeeplinkAction { - val type = classify(deepLink) ?: return DeeplinkAction.None + val type = classify(deepLink) ?: return deepLink.unrouted() // Not logged in — redirect to login (or login deeplink itself) if (authStateProvider() !is AuthState.Ready) { @@ -85,14 +112,27 @@ internal class AppRouter( listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) ) - // Your own tip card link: tipping yourself is a payment no-op, so instead of - // presenting a card that can't be acted on, land on the You tab — the surface that - // owns your tip card (see NavBarRoutes: NavBarButton.TipCard -> Sheets.Menu). - is DeeplinkType.Tipcard -> if (type.userId == currentUserIdProvider()) { - DeeplinkAction.Navigate(listOf(AppRoute.Sheets.Menu)) - } else { - DeeplinkAction.PresentTipCard(type.userId) - } + is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) + + is DeeplinkType.TipcardByUsername -> tipCard(TipCardOwner.ByUsername(type.username)) + } + } + + /** + * Where a tip card link goes. Your own leads nowhere payable, so instead of presenting a card + * that can't be acted on it lands on the You tab — the surface that owns your tip card (see + * NavBarRoutes: NavBarButton.TipCard -> Sheets.Menu). + * + * The self-check belongs here and not after resolution: the session announces a self-tip + * through a replay-less event that only the scanner collects, so a link opened onto any other + * tab — a cold start lands on the wallet — would drop it and do nothing at all. + */ + private fun tipCard(owner: TipCardOwner): DeeplinkAction { + val self = currentUserProvider() + return if (owner.isSelf(self.id, self.username)) { + DeeplinkAction.Navigate(listOf(AppRoute.Sheets.Menu)) + } else { + DeeplinkAction.PresentTipCard(owner) } } @@ -127,6 +167,7 @@ internal class AppRouter( deepLink.isEmailVerification() -> deepLink.handleEmailVerification() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() + deepLink.isVanityProfile() -> deepLink.handleVanityProfile() // `/chat/{id}` links are intentionally NOT handled: the Send tab / direct-send // flow they opened was removed. The manifest no longer claims that path either, so // such a link opens in the browser rather than dead-ending here. Re-add routing and @@ -136,6 +177,22 @@ internal class AppRouter( } } + /** + * What to do with a link the app was handed but has no route for. + * + * For every host but the bare one that is "nothing": those hosts belong to the app, and a path + * it doesn't know is a link it was never meant to receive. `flipcash.com` is different — its + * path space is shared with the website, and the App Link filter can only narrow it to the + * handle charset, which `/download` and `/privacy` also satisfy (and which the platform ignores + * below API 31). Send those back to a browser instead of dead-ending on the home screen. + */ + private fun DeepLink.unrouted(): DeeplinkAction = + if (host.removePrefix("www.").equals(VANITY_HOST, ignoreCase = true)) { + DeeplinkAction.OpenExternally(data) + } else { + DeeplinkAction.None + } + private fun resolveEmailVerification(type: DeeplinkType.EmailVerification): DeeplinkAction { val origin = EmailDeeplinkOrigin.deserialize(type.origin.orEmpty()) val routes: List = when (origin) { @@ -215,6 +272,27 @@ private fun DeepLink.isTipChat(): Boolean = private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0)) +/** + * `flipcash.com/{username}` — a single path segment on the bare host, shaped like a handle and not + * one of the website's own pages. + * + * All three conditions matter. The manifest claims this host for username-shaped paths only, but a + * `pathAdvancedPattern` is ignored below API 31, so on those versions the whole host arrives here + * and this is the only place the distinction is made. + */ +private fun DeepLink.isVanityProfile(): Boolean { + if (!host.removePrefix("www.").equals(AppRouter.VANITY_HOST, ignoreCase = true)) return false + val segment = pathSegments.singleOrNull()?.lowercase() ?: return false + return segment.isUsernameShaped() && segment !in AppRouter.reservedVanityPaths +} + +private fun DeepLink.handleVanityProfile(): DeeplinkType.TipcardByUsername? { + // Lowercased, not just matched case-insensitively: handles are lowercase on the wire, and this + // string is what the profile lookup is keyed by. + val username = pathSegments.singleOrNull()?.lowercase() ?: return null + return DeeplinkType.TipcardByUsername(username) +} + private fun DeepLink.handleLoginLink(): DeeplinkType.Login? { val uri = data.toUri() var entropy = uri.fragments[Key.entropy] 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 c1657b9f3f..9c98158ebe 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 @@ -6,6 +6,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.util.Linkify +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.AuthState import com.getcode.opencode.model.core.ID @@ -33,10 +34,11 @@ class AppRouterTest { private var authState: AuthState = AuthState.Ready private var currentUserId: ID? = null + private var currentUsername: String? = null private val router = AppRouter( authStateProvider = { authState }, - currentUserIdProvider = { currentUserId }, + currentUserProvider = { AppRouter.CurrentUser(currentUserId, currentUsername) }, ) private fun loggedIn() { authState = AuthState.Ready } @@ -393,7 +395,7 @@ class AppRouterTest { val action = router.dispatch(DeepLink("https://app.flipcash.com/tip/$userId")) assertIs(action) - assertEquals(UUID.fromString(userId).bytes, action.userId) + assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner) } @Test @@ -422,6 +424,116 @@ class AppRouterTest { // endregion + // region classify + dispatch — vanity profile links + + @Test + fun `classify recognizes a vanity profile link`() { + val type = router.classify(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) + assertIs(type) + assertEquals("sally_streamer", type.username) + } + + @Test + fun `classify lowercases a vanity profile link`() { + val type = router.classify(DeepLink("https://flipcash.com/Sally_Streamer")) + assertIs(type) + assertEquals("sally_streamer", type.username) + } + + @Test + fun `classify recognizes a vanity profile link on the www host`() { + val type = router.classify(DeepLink("https://www.flipcash.com/sally_streamer")) + assertIs(type) + } + + @Test + fun `classify ignores the website's own pages on the vanity host`() { + assertNull(router.classify(DeepLink(Linkify.download("abc123")))) + assertNull(router.classify(DeepLink("https://flipcash.com/privacy"))) + assertNull(router.classify(DeepLink("https://flipcash.com/terms"))) + } + + @Test + fun `classify ignores a vanity path that isn't shaped like a handle`() { + // Too short, too long, and outside the server's charset. + assertNull(router.classify(DeepLink("https://flipcash.com/a"))) + assertNull(router.classify(DeepLink("https://flipcash.com/sixteencharacter"))) + assertNull(router.classify(DeepLink("https://flipcash.com/sally.streamer"))) + } + + @Test + fun `classify ignores a multi-segment path on the vanity host`() { + assertNull(router.classify(DeepLink("https://flipcash.com/sally/streamer"))) + } + + @Test + fun `classify does not treat the app host as a vanity link`() { + assertNull(router.classify(DeepLink("https://app.flipcash.com/sally_streamer"))) + } + + @Test + fun `dispatch presents the tip card for a vanity profile link`() { + loggedIn() + val action = router.dispatch(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) + assertIs(action) + assertEquals(TipCardOwner.ByUsername("sally_streamer"), action.owner) + } + + // Your own handle diverts to the You tab here rather than after resolution: the session + // announces a self-tip through a replay-less event only the scanner collects, so a link + // opened onto any other tab would drop it silently. + @Test + fun `dispatch routes your own vanity profile link to the You tab`() { + loggedIn() + currentUsername = "sally_streamer" + val action = router.dispatch(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) + assertIs(action) + assertEquals(AppRoute.Sheets.Menu, action.routes.single()) + } + + // A link can be typed or pasted in any case; handles are lowercase on the wire. + @Test + fun `dispatch matches your own handle regardless of case`() { + loggedIn() + currentUsername = "sally_streamer" + val action = router.dispatch(DeepLink("https://flipcash.com/Sally_Streamer")) + assertIs(action) + assertEquals(AppRoute.Sheets.Menu, action.routes.single()) + } + + @Test + fun `dispatch routes a vanity profile link to onboarding when logged out`() { + loggedOut() + val action = router.dispatch(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) + assertIs(action) + assertIs(action.routes.single()) + } + + @Test + fun `dispatch hands an unrouted vanity-host link back to the web`() { + loggedIn() + val action = router.dispatch(DeepLink("https://flipcash.com/privacy")) + assertIs(action) + assertEquals("https://flipcash.com/privacy", action.url) + } + + @Test + fun `dispatch hands the vanity host back to the web even when logged out`() { + // The escape hatch runs before the auth check: onboarding is no better a landing place for + // a website link than the home screen is. + loggedOut() + assertIs(router.dispatch(DeepLink(Linkify.download("abc123")))) + } + + @Test + fun `dispatch leaves an unrouted link on another host alone`() { + loggedIn() + assertEquals(DeeplinkAction.None, router.dispatch(DeepLink("https://app.flipcash.com/nonsense"))) + assertEquals(DeeplinkAction.None, router.dispatch(DeepLink("https://example.com/privacy"))) + } + + // endregion + // region dispatch — Logged in: EmailVerification (route building) @Test 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 0e1e83cbc0..9438a27681 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 @@ -3,6 +3,7 @@ package com.flipcash.app.session import androidx.compose.runtime.staticCompositionLocalOf import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.session.BillDeterminationResult.ActedUpon import com.getcode.opencode.model.financial.Token import com.flipcash.app.core.AppRoute @@ -52,7 +53,13 @@ interface TipCardOperations { * every producer runs while the scanner is on screen. */ val tipCardEvents: Flow - fun resolveTipCard(user: ID) + + /** + * Resolves [owner]'s tip card and presents it. Both ways of naming them arrive here — a scan or + * a `/tip/{id}` link by id, a `flipcash.com/{username}` link by handle — because everything + * after resolution is the same card. + */ + fun resolveTipCard(owner: TipCardOwner) } interface DepositOperations { 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 5d72de19be..55566d60b8 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 @@ -13,6 +13,7 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.updater.ProfileUpdater +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.session.BillOperations @@ -142,7 +143,8 @@ 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) + is CodeScanDelegate.Event.TipCardScanned -> + resolveTipCard(TipCardOwner.ById(event.userId)) } }.launchIn(scope) 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 index 5abc9d6432..50e09dfa42 100644 --- 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 @@ -2,11 +2,17 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.tipping.OwnTipCard +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.session.TipCardEvent import com.flipcash.app.session.TipCardOperations +import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.GetUserProfileError import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -24,8 +30,9 @@ 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]). + * user's tip card, whether it arrives via a deeplink (`/tip/{userId}` or the vanity + * `flipcash.com/{username}`), 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). @@ -42,6 +49,7 @@ import javax.inject.Singleton class TipCardDelegate @Inject constructor( private val tippingCoordinator: TippingCoordinator, private val analytics: FlipcashAnalyticsService, + private val resources: ResourceHelper, dispatchers: DispatcherProvider, ) : TipCardOperations { @@ -65,16 +73,25 @@ class TipCardDelegate @Inject constructor( // Users with an in-flight resolve — coalesces duplicate requests (e.g. repeated scan frames). private val inFlight = MutableStateFlow>(emptySet()) - override fun resolveTipCard(user: ID) { - // You can't tip yourself, so there's no card to present for your own id. Both scan paths - // (a QR tip link and an OpenCode tip payload) land here, so this is the one place that has - // to answer for them: signal the UI to show the You tab, which owns your card, instead of - // silently doing nothing. A `/tip/{self}` deeplink is diverted earlier, by AppRouter. + override fun resolveTipCard(owner: TipCardOwner) { + // You can't tip yourself, so there's no card to present for your own account, by either + // name. Both scan paths (a QR tip link and an OpenCode tip payload) land here, so this is + // the one place that has to answer for them: signal the UI to show the You tab, which owns + // your card, instead of silently doing nothing. Checked before dispatch so your own card + // never costs a profile fetch. A self deeplink is diverted earlier still, by AppRouter. // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. - if (user == tippingCoordinator.currentUserId) { + if (owner.isSelf(tippingCoordinator.currentUserId, tippingCoordinator.currentUsername)) { _tipCardEvents.tryEmit(TipCardEvent.OwnCardScanned) return } + + when (owner) { + is TipCardOwner.ById -> resolveById(owner.userId) + is TipCardOwner.ByUsername -> resolveByUsername(owner.username) + } + } + + private fun resolveById(user: ID) { if (!inFlight.add(user)) return scope.launch { @@ -98,6 +115,58 @@ class TipCardDelegate @Inject constructor( } } + private fun resolveByUsername(username: String) { + // No coalescing here, unlike the id path: that one exists because the camera hands the same + // user over on every frame. A vanity link arrives once, from a tap. + scope.launch { + tippingCoordinator.resolveTipCard(username) + .onSuccess { card -> + analytics.tipCardPresented() + _events.trySend(Event.Present(card)) + } + .onFailure { cause -> + // Your own handle, discovered only after the fetch — see OwnTipCard. Same + // answer as the pre-dispatch guard above, not a failure to report. + if (cause is OwnTipCard) { + _tipCardEvents.tryEmit(TipCardEvent.OwnCardScanned) + return@launch + } + trace( + tag = "Session", + message = "Failed to resolve tip card for @$username", + error = cause, + ) + announceUnresolvable(username, cause) + } + } + } + + /** + * Says out loud that a vanity link went nowhere. + * + * The id path stays silent on failure because an id is machine-supplied — it comes off a code + * the camera just read, so a miss there is a transient fetch, not a wrong address. A handle is + * the opposite: it is typed, printed on merch, or pasted out of a bio, and it goes stale the + * moment its owner changes it. Without this the app opens on the home screen and looks like it + * ignored the tap. + * + * Informational, not an error — an unclaimed handle is a fact about the link, not a fault in + * the app. Only the network case is ours to apologise for. + */ + private fun announceUnresolvable(username: String, cause: Throwable) { + if (cause is GetUserProfileError.NotFound) { + BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_usernameNotFound), + message = resources.getString(R.string.error_description_usernameNotFound, username), + ) + } else { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_tipCardUnavailable), + message = resources.getString(R.string.error_description_tipCardUnavailable), + ) + } + } + /** Atomically adds [user]; returns true only if it wasn't already in flight. */ private fun MutableStateFlow>.add(user: ID): Boolean { var added = false diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt index 59fc25587b..5e2b8b0434 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt @@ -3,10 +3,12 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.session.TipCardEvent import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -37,10 +39,12 @@ class TipCardDelegateTest { every { currentUserId } returns self } private val analytics = mockk(relaxed = true) + private val resources = mockk(relaxed = true) private fun delegate() = TipCardDelegate( tippingCoordinator = tippingCoordinator, analytics = analytics, + resources = resources, dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()), ) @@ -51,7 +55,7 @@ class TipCardDelegateTest { // Let the collector attach before emitting — the flow is replay-less. testScheduler.advanceUntilIdle() - delegate.resolveTipCard(self) + delegate.resolveTipCard(TipCardOwner.ById(self)) assertEquals(TipCardEvent.OwnCardScanned, event.await()) coVerify(exactly = 0) { tippingCoordinator.resolveTipCard(any()) } @@ -66,7 +70,7 @@ class TipCardDelegateTest { val presented = async { delegate.events.first() } testScheduler.advanceUntilIdle() - delegate.resolveTipCard(other) + delegate.resolveTipCard(TipCardOwner.ById(other)) assertEquals(TipCardDelegate.Event.Present(card), presented.await()) } 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 a621216c95..86e9d26d33 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 @@ -50,6 +50,10 @@ sealed interface Shareable { val preview: TipCodePreview? = null, // Optional Sharesheet title shown above the link (e.g. "Tip Brandon McAnsh"). val title: String? = null, + // The owner's claimed handle, when they have one. Carried so the shared link is the vanity + // form the You tab shows and copies — sharing a UUID for a card that displays + // `flipcash.com/` would hand out a second, unrecognisable address for it. + val username: String? = null, ): Shareable { override val pendingData: ShareablePendingData? = null } 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 4c4fdf6150..7388b3469a 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 @@ -14,6 +14,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.flipcash.app.core.money.formatted import com.flipcash.app.core.util.Linkify import com.flipcash.app.core.util.MessagingPackages +import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_CASH_LINK_SHARED @@ -292,7 +293,11 @@ internal class InternalShareSheetController( } private fun shareTipCard(shareable: Shareable.TipCard) { - val url = Linkify.tipcard(shareable.userId) + // Addressed the same way the You tab's link row addresses it, so what gets shared is what + // the card says it is. + val url = Linkify.tipcard( + TipCardOwner.preferringUsername(shareable.username, shareable.userId) + ) val preview = shareable.preview val intent = Intent(Intent.ACTION_SEND).apply { 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 5a6a674bcb..16bfeb6ce9 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 @@ -4,6 +4,7 @@ import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.tipping.OwnTipCard import com.flipcash.app.core.tipping.TipAmount import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.core.tipping.TipSelectionHolder @@ -80,6 +81,10 @@ class TippingCoordinator @Inject constructor( val currentUserId: ID? get() = userManager.accountId + /** The signed-in user's claimed handle, or null when they haven't claimed one. */ + val currentUsername: String? + get() = userManager.profile?.username?.takeIf { it.isNotBlank() } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _amount = MutableStateFlow(null) @@ -293,17 +298,45 @@ class TippingCoordinator @Inject constructor( */ suspend fun resolveTipCard(userId: ID): Result = 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 - vibrator.vibrate() - } + .onSuccess { onCardResolved(userId) } .map { tipCard(userId, it) } + /** + * The same, for someone named by their public handle — the only thing a + * `flipcash.com/{username}` link carries. One round trip, not two: the profile fetch answers + * with the user's id, which is what the card is actually built from. + * + * Fails when the handle is unclaimed (the server's `NotFound`), when the profile comes back + * without an id, which would leave nothing to encode into the scannable code, or when the + * handle turns out to be the viewer's own ([OwnTipCard]). + */ + suspend fun resolveTipCard(username: String): Result = + profileController.getProfileForUsername(username) + .mapCatching { profile -> + val userId = profile.userId + ?: throw IllegalStateException("Profile for @$username carries no user id") + // The second self-check, and the only one that can't be fooled. The two before it + // compare handles, so they answer "not me" for the whole window between signing in + // and this account's own profile arriving — which is exactly when a cold-started + // link lands. The id off the wire has no such window. + // Mirrors iOS TipFlow.prepare's `guard userID != session.userID`. + if (userId == currentUserId) throw OwnTipCard(username) + onCardResolved(userId) + tipCard(userId, profile) + } + + /** + * 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. + */ + private suspend fun onCardResolved(userId: ID) { + _canTip.value = tokenCoordinator.hasGiveableBalance() + _userId.value = userId + vibrator.vibrate() + } + /** Updates the tip submission's processing state; used by the send path (see [confirmTip]). */ private fun setSendState(state: LoadingSuccessState) { _sendState.value = state diff --git a/apps/flipcash/shared/tokens/core/src/main/kotlin/com/flipcash/app/tokens/core/TotalBalanceProvider.kt b/apps/flipcash/shared/tokens/core/src/main/kotlin/com/flipcash/app/tokens/core/TotalBalanceProvider.kt new file mode 100644 index 0000000000..d1da4bfa3c --- /dev/null +++ b/apps/flipcash/shared/tokens/core/src/main/kotlin/com/flipcash/app/tokens/core/TotalBalanceProvider.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.tokens.core + +import com.getcode.opencode.model.financial.Fiat +import kotlinx.coroutines.flow.Flow + +/** + * The account's holdings added up, for the surfaces that gate on a total rather than on any one + * token — currently the username minimum-balance rule. + * + * A narrow interface here rather than a `:shared:tokens` dependency, for the same reason as + * [ReservesBalanceProvider]: consumers get the number without the whole token stack (amount entry, + * on-ramp, transaction history) coming with it. + */ +interface TotalBalanceProvider { + fun observeTotalBalance(): Flow +} 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 340c512040..ae6448b9b1 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 @@ -15,6 +15,7 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.TokenDataSource import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.core.ReservesBalanceProvider +import com.flipcash.app.tokens.core.TotalBalanceProvider import com.getcode.opencode.controllers.AccountController import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.exchange.Exchange @@ -32,6 +33,7 @@ import com.getcode.opencode.model.financial.TokenResult import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.sum import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.providers.SessionListener import com.getcode.opencode.providers.TokenMetadataProvider @@ -112,7 +114,8 @@ class TokenCoordinator @Inject constructor( private val dataSource: TokenDataSource, private val featureFlags: FeatureFlagController, private val dispatchers: DispatcherProvider, -) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider { +) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider, + TotalBalanceProvider { companion object { private const val TAG = "TokenCoordinator" @@ -281,6 +284,19 @@ class TokenCoordinator @Inject constructor( override fun observeReservesBalance(): Flow = balanceForToken(Mint.usdf) + /** + * Every token added together, reserves included — the number the username minimum-balance rule + * is measured against. + * + * Summing the raw balances is safe because they are all USD-denominated: [Fiat.plus] refuses a + * mismatched `currencyCode`, so a non-USD balance in this map would already be failing + * elsewhere. Summing before rounding also matches how the token list computes its total, and + * how iOS computes this one. + */ + override fun observeTotalBalance(): Flow = _state + .map { state -> state.balances.values.sum() } + .distinctUntilChanged() + suspend fun add(token: Token, fiat: LocalFiat) { val rate = exchange.rateToUsd(fiat.rate.currency) val amount = rate?.let { fiat.nativeAmount.convertingTo(it) } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/inject/TokenModule.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/inject/TokenModule.kt index 60f76cd7c0..a58e88ac30 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/inject/TokenModule.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/inject/TokenModule.kt @@ -2,6 +2,7 @@ package com.flipcash.app.tokens.inject import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.core.ReservesBalanceProvider +import com.flipcash.app.tokens.core.TotalBalanceProvider import com.getcode.opencode.providers.SessionListener import com.getcode.opencode.providers.TokenMetadataProvider import dagger.Binds @@ -33,4 +34,9 @@ abstract class TokenModule { abstract fun bindReservesBalanceProvider( coordinator: TokenCoordinator ): ReservesBalanceProvider + + @Binds + abstract fun bindTotalBalanceProvider( + coordinator: TokenCoordinator + ): TotalBalanceProvider } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt new file mode 100644 index 0000000000..e39d54a624 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt @@ -0,0 +1,50 @@ +package com.flipcash.services.models + +/** + * The `@` a handle is shown with. + * + * Usernames are stored, sent and matched bare — the server's `^[a-z0-9_]{2,15}$` charset has no room + * for an `@`, and [ProfileIdentifier.Username] / [ResolveIdentifier.Username] carry them that way. + * The prefix is presentation, so it is added here rather than at each surface that shows one. + */ +const val HandlePrefix = "@" + +/** Mirrors the server's `^[a-z0-9_]{2,15}$` validation on `common.v1.Username`. */ +const val MinUsernameLength = 2 +const val MaxUsernameLength = 15 + +/** + * The server's own charset and bounds. Kept here rather than at the one screen that types a + * username, because two other places have to recognise one without being able to ask the server: + * the `flipcash.com/` deeplink, which must not mistake `/download` for a handle, and the + * tip card's link row, which decides whether a URL's last segment is readable or an opaque id. + */ +private val UsernamePattern = Regex("^[a-z0-9_]{$MinUsernameLength,$MaxUsernameLength}$") + +/** + * Whether this could be a username — the charset and length the server accepts, with or without a + * leading `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's + * answer, not ours. + */ +fun String.isUsernameShaped(): Boolean = UsernamePattern.matches(removePrefix(HandlePrefix)) + +/** + * This username as a handle — `sally_streamer` becomes `@sally_streamer`. + * + * Idempotent, so a string that already carries the prefix (a pasted handle, or a social username + * stored with its `@`) doesn't come back with two. + */ +fun String.asHandle(): String = HandlePrefix + removePrefix(HandlePrefix) + +/** + * The user's public Flipcash handle as displayed, or null when they haven't claimed one. + * + * Blank counts as unclaimed: every surface that shows a handle keys off this being null to leave the + * line out entirely, and a bare `@` is worse than nothing. + */ +val UserProfile.handle: String? + get() = username?.takeIf { it.isNotBlank() }?.asHandle() + +/** The linked X account's handle, shown the same way a Flipcash one is. */ +val SocialAccount.TwitterX.handle: String + get() = username.asHandle() diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt new file mode 100644 index 0000000000..979788d578 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt @@ -0,0 +1,93 @@ +package com.flipcash.services.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The shape rule stands in for the server's `^[a-z0-9_]{2,15}$`, and three places rely on it without + * being able to ask: the vanity deeplink, the tip card's link row, and the entry screen's own + * pre-submit check. Mirrors iOS `UsernameValidatorTests`. + */ +class HandleTest { + + @Test + fun `a plain handle is username shaped`() { + assertTrue("sally_streamer".isUsernameShaped()) + } + + @Test + fun `the prefix is tolerated on the way in`() { + assertTrue("@sally_streamer".isUsernameShaped()) + } + + @Test + fun `digits and underscores are in the charset`() { + assertTrue("a_1".isUsernameShaped()) + assertTrue("_".repeat(MinUsernameLength).isUsernameShaped()) + } + + @Test + fun `both ends of the length range are accepted`() { + assertTrue("a".repeat(MinUsernameLength).isUsernameShaped()) + assertTrue("a".repeat(MaxUsernameLength).isUsernameShaped()) + } + + @Test + fun `either side of the length range is not`() { + assertFalse("a".repeat(MinUsernameLength - 1).isUsernameShaped()) + assertFalse("a".repeat(MaxUsernameLength + 1).isUsernameShaped()) + assertFalse("".isUsernameShaped()) + } + + // Not normalized here on purpose: the entry screen lowercases as the user types, and a link + // is lowercased before it is matched. Accepting mixed case would let `flipcash.com/Download` + // read as a handle. + @Test + fun `uppercase is not username shaped`() { + assertFalse("SallyStreamer".isUsernameShaped()) + assertFalse("sally_Streamer".isUsernameShaped()) + } + + @Test + fun `punctuation outside the charset is rejected`() { + assertFalse("sally.streamer".isUsernameShaped()) + assertFalse("sally-streamer".isUsernameShaped()) + assertFalse("sally streamer".isUsernameShaped()) + assertFalse("sally@streamer".isUsernameShaped()) + } + + @Test + fun `an embedded newline can't smuggle a valid line past the check`() { + assertFalse("sally\ndownload".isUsernameShaped()) + } + + @Test + fun `asHandle adds the prefix`() { + assertEquals("@sally_streamer", "sally_streamer".asHandle()) + } + + @Test + fun `asHandle is idempotent`() { + assertEquals("@sally_streamer", "@sally_streamer".asHandle()) + assertEquals("@sally_streamer", "sally_streamer".asHandle().asHandle()) + } + + @Test + fun `a profile without a username has no handle`() { + assertNull(UserProfile.Empty.copy(username = null).handle) + } + + @Test + fun `a blank username counts as unclaimed rather than a bare at sign`() { + assertNull(UserProfile.Empty.copy(username = "").handle) + assertNull(UserProfile.Empty.copy(username = " ").handle) + } + + @Test + fun `a claimed username reads as a handle`() { + assertEquals("@sally_streamer", UserProfile.Empty.copy(username = "sally_streamer").handle) + } +} From 2c2a69735dd4a9672d1a96694eb5db4d98d8571c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:25:03 -0400 Subject: [PATCH 2/4] test(username): cover the vanity deeplink with two Maestro flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle comes from `LOGIN_USERNAME` rather than being written into the flows, so the suite doesn't carry a real account's handle and a rotated test account is a one-line `.env` change. `run.sh` forwards it alongside the other credentials. - `vanity_deeplink_self.yaml` — the account that owns the handle follows its own `flipcash.com/{handle}` link and lands on the You tab. Opened from cold state deliberately: the handle-based self-checks can't answer until the account's profile has loaded, and a link tapped from outside the app arrives inside that window. - `vanity_deeplink_tip.yaml` — a brand-new account follows the same link and gets that handle's owner card, asserted on the `@handle` drawn under the name. The name alone would only prove some card opened. Tagged `creates-account`, which the runner excludes by default. --- maestro/README.md | 8 ++++++++ maestro/run.sh | 3 +++ maestro/vanity_deeplink_self.yaml | 30 ++++++++++++++++++++++++++++++ maestro/vanity_deeplink_tip.yaml | 26 ++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 maestro/vanity_deeplink_self.yaml create mode 100644 maestro/vanity_deeplink_tip.yaml diff --git a/maestro/README.md b/maestro/README.md index 37b13d3cc8..a177a4eef6 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -20,6 +20,7 @@ iOS's `FlipcashUITests`. Flows are plain YAML under `maestro/`; reusable pieces SEED_PHRASE=word1 word2 ... word12 # primary account (tip-enabled) LOGIN_DEEPLINK=https://app.flipcash.com/login?data=... # same account as SEED_PHRASE TIPCARD_DEEPLINK=https://app.flipcash.com/tip/... # the primary account's tip card + LOGIN_USERNAME=sally_streamer # the handle that same account has claimed USDF_ONLY_DEEPLINK=https://app.flipcash.com/login?data=... # reserves-only gate account CONTACT_NAME=Brandon McAnsh # an on-Flipcash contact for send-to-contact CONTACT_PHONE=+15869802333 # seed this contact into the emulator @@ -120,6 +121,13 @@ maestro/run.sh maestro/tipping_setup.yaml Blocked, then unblock (leaves the account clean) - `tip_deeplink.yaml` — open a tip-card deeplink (`TIPCARD_DEEPLINK`) → presents the tip flow (waits for balances to sync first, else the empty-cache state trips the add-money gate) +- `vanity_deeplink_self.yaml` — `flipcash.com/{LOGIN_USERNAME}` followed by the account that owns + that handle → the You tab, not a tip card. Opens from cold on purpose: the handle-based + self-checks are blind until the account's own profile has loaded, and that is the window a + link tapped from outside the app lands in +- `vanity_deeplink_tip.yaml` — the same link followed by a brand-new account → that handle + owner's tip card, asserted on the `@handle` the card draws under the name (`creates-account`, + so it is excluded from the default CI tag set) - `buy.yaml` — token info → Buy → payment currency → confirm-purchase screen (fund-safe) - `sell.yaml` — token info → Sell → amount entry (fund-safe) - `currency_creator.yaml` — Discover → Create Your Own Currency → intro + $20 balance gate diff --git a/maestro/run.sh b/maestro/run.sh index 447cfab7b1..3a0c3588c4 100755 --- a/maestro/run.sh +++ b/maestro/run.sh @@ -29,6 +29,8 @@ cred() { SEED_PHRASE="$(cred SEED_PHRASE)" LOGIN_DEEPLINK="$(cred LOGIN_DEEPLINK)" TIPCARD_DEEPLINK="$(cred TIPCARD_DEEPLINK)" +# The handle the SEED_PHRASE/LOGIN_DEEPLINK account has claimed, for the vanity-link flows. +LOGIN_USERNAME="$(cred LOGIN_USERNAME)" # Dedicated USDF-only (reserves-only) account for gate tests. USDF_ONLY_DEEPLINK="$(cred USDF_ONLY_DEEPLINK)" # On-Flipcash contact for send-to-contact tests (seeded into the emulator's contacts). @@ -84,6 +86,7 @@ maestro --device "$DEVICE" test \ -e SEED_PHRASE="$SEED_PHRASE" \ -e LOGIN_DEEPLINK="$LOGIN_DEEPLINK" \ -e TIPCARD_DEEPLINK="$TIPCARD_DEEPLINK" \ + -e LOGIN_USERNAME="$LOGIN_USERNAME" \ -e USDF_ONLY_DEEPLINK="$USDF_ONLY_DEEPLINK" \ -e CONTACT_NAME="$CONTACT_NAME" \ -e CONTACT_PHONE="$CONTACT_PHONE" \ diff --git a/maestro/vanity_deeplink_self.yaml b/maestro/vanity_deeplink_self.yaml new file mode 100644 index 0000000000..d6f38623e8 --- /dev/null +++ b/maestro/vanity_deeplink_self.yaml @@ -0,0 +1,30 @@ +appId: com.flipcash.app.android +name: "Vanity deeplink — your own handle lands on the You tab" +tags: + - tipping +--- +# `flipcash.com/{username}` for the handle you own. There is nothing payable at the far end, +# so AppRouter diverts it to the You tab — the surface that owns your own tip card — rather +# than presenting a card that can't be acted on. +# +# The regression this guards is the cold start: both handle-based self-checks compare handles, +# which they can't do until this account's profile has loaded, and a link opened from cold +# lands inside that window. Hence `clearAppState`, which makes the deeplink the very first +# thing the process does. +# +# Requires env: LOGIN_DEEPLINK, LOGIN_USERNAME (the handle that account owns). +- runFlow: subflows/login_with_deeplink.yaml + +- runFlow: + file: helpers/launch_deeplink.yaml + env: + clearAppState: "true" + deeplink: https://flipcash.com/${LOGIN_USERNAME} + +- extendedWaitUntil: + visible: + id: menu_screen + timeout: 20000 + +# Not a tip card: the You tab, showing your own handle rather than an offer to tip it. +- assertVisible: "@${LOGIN_USERNAME}" diff --git a/maestro/vanity_deeplink_tip.yaml b/maestro/vanity_deeplink_tip.yaml new file mode 100644 index 0000000000..f121efdd75 --- /dev/null +++ b/maestro/vanity_deeplink_tip.yaml @@ -0,0 +1,26 @@ +appId: com.flipcash.app.android +name: "Vanity deeplink — someone else's handle presents their tip card" +tags: + - tipping + - creates-account +--- +# The other half of `vanity_deeplink_self.yaml`: the same link, followed by an account that +# doesn't own the handle, resolves it over the wire and presents that person's card. +# +# A brand-new account rather than one of the seeded logins, because the seeded ones are the +# handle's owner — this needs a viewer who is demonstrably somebody else. +# +# Requires env: LOGIN_USERNAME (a handle owned by a different account than the one created here). +- runFlow: + file: subflows/create_account.yaml + env: + BETA_FLAGS: "" + +- openLink: https://flipcash.com/${LOGIN_USERNAME} + +# The handle under the name is what proves the link resolved to its owner — the display name +# would only show that *a* card opened. See TipCard: the line is drawn only once a handle is +# claimed, so asserting it also covers the rendering. +- extendedWaitUntil: + visible: "@${LOGIN_USERNAME}" + timeout: 20000 From 7491a1a0588dd19607a0bc75dd36c2281b6d0201 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:39:24 -0400 Subject: [PATCH 3/4] fix(deeplinks): claim mixed-case vanity paths, and cover the new My Account row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pathAdvancedPattern` is a case-sensitive PatternMatcher glob, so `/[a-z0-9_]{2,15}` doesn't match `flipcash.com/Sally_Streamer` — the link opens in the browser on API 31+ and the app's own lowercasing in `isVanityProfile` never runs. Below 31 the attribute is ignored and the same link works, so one handle behaved two ways across the minSdk range. A-Z in the set closes that; mixed-case website pages it now also claims (`/Download`) classify as unrouted and bounce back out via `OpenExternally`, which is what already happens below 31. The My Account default-state test still expected three rows, and `ChangeUsername` made it four — the failing assertion on CI. --- apps/flipcash/app/src/main/AndroidManifest.xml | 13 +++++++++++-- .../internal/MyAccountScreenViewModelStateTest.kt | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index 3c1805ad02..7cf5ecee15 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -219,6 +219,15 @@ makes the same distinction again in code (isVanityProfile) and leaves anything that isn't a handle unrouted. + A-Z is in the set even though no handle contains one: the matcher is + case-sensitive, and a link is typed, printed, or auto-capitalised in any case. + Without it `flipcash.com/Sally_Streamer` isn't claimed at all on API 31+ and opens + in the browser, while the same link works below 31 — where the attribute is + ignored. Widening it hands the app a few more of the website's pages in mixed case + (`/Download`), which is what already happens below 31: isVanityProfile lowercases + before the reserved-path check, so those classify as unrouted and bounce straight + back out via DeeplinkAction.OpenExternally. + Verification needs assetlinks.json served from https://flipcash.com/.well-known/, not only from the app./send. subdomains. --> @@ -230,13 +239,13 @@ diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index fb178e55e4..6ac73f4582 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.myaccount.internal import com.flipcash.app.myaccount.internal.myaccount.Blocklist import com.flipcash.app.myaccount.internal.myaccount.ChangeDisplayName +import com.flipcash.app.myaccount.internal.myaccount.ChangeUsername import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.app.myaccount.internal.myaccount.RequireBiometrics import com.flipcash.app.myaccount.internal.myaccount.UserProfile @@ -15,9 +16,9 @@ class MyAccountScreenViewModelStateTest { private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent @Test - fun `default state lists the display name, biometrics and blocklist`() { + fun `default state lists the display name, username, biometrics and blocklist`() { val state = MyAccountScreenViewModel.State() - assertEquals(listOf(ChangeDisplayName, RequireBiometrics, Blocklist), state.items) + assertEquals(listOf(ChangeDisplayName, ChangeUsername, RequireBiometrics, Blocklist), state.items) assertFalse(state.biometricsRequired) } @@ -161,6 +162,8 @@ class MyAccountScreenViewModelStateTest { MyAccountScreenViewModel.Event.OnBiometricsToggled, MyAccountScreenViewModel.Event.OnChangeDisplayNameClicked, MyAccountScreenViewModel.Event.OnEditDisplayName, + MyAccountScreenViewModel.Event.OnChangeUsernameClicked, + MyAccountScreenViewModel.Event.OnEditUsername, MyAccountScreenViewModel.Event.OnContactMethodsClicked, MyAccountScreenViewModel.Event.OnViewUserProfile, MyAccountScreenViewModel.Event.OnBlocklistClicked, From 2eacb97b7e561148c1c099198ee40ef0501e62b0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 26 Aug 2026 09:55:53 -0400 Subject: [PATCH 4/4] fix(deeplinks): reserve every apex path the website already claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reservedVanityPaths` held eight of the website's pages; the AASA served from `flipcash.com/.well-known/apple-app-site-association` excludes twenty. The ten handle-shaped ones it had and this list didn't — /app, /api, /assets, /fonts, /icons, /js, /pool, /v1, /wallet, /currencycreator — were captured by the App Link filter, classified as vanity profiles, looked up over the wire, and dead-ended on "username not found" instead of opening the page. The two lists are the same statement made twice, so the test now walks the AASA's handle-shaped excludes rather than spot-checking three of them. --- .../flipcash/app/router/internal/AppRouter.kt | 19 ++++++++++++- .../app/router/internal/AppRouterTest.kt | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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 c091d4df70..bae5b7fe74 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 @@ -70,9 +70,26 @@ internal class AppRouter( * them is charset-valid as a username, and the server reserves them — so a link to one * could only ever fail to resolve, but it would fail *inside* the app, having taken the tap * away from the browser. Ruling them out here keeps `flipcash.com/download` a web link. + * + * This is the whole of the narrowing. An intent filter can only widen — its `data` elements + * OR together and there is no exclude form — so the manifest's `pathAdvancedPattern` can + * hold the claim to the handle *shape* but not subtract these particular words from it. + * The AASA's `exclude` entries are how iOS says the same thing, which is why this list is + * kept in step with them: that file is the website's own statement of what it serves. + * Only its handle-shaped entries appear here — it also excludes paths the filter could + * never match (`/favicon.ico`, `/robots.txt`, anything multi-segment). */ val reservedVanityPaths: Set = - setOf("download", "privacy", "terms", "support", "help", "about", "blog", "legal") + + // The website's own pages. + setOf( + "download", "privacy", "terms", "support", "help", "about", "blog", "legal", + "currencycreator", + ) + + // Static roots and the web API, served off the apex alongside the pages. + setOf("app", "api", "assets", "fonts", "icons", "js", "v1") + + // Routes belonging to the app hosts. The apex answers for them too, so a link + // naming one is a mis-hosted route, not somebody's handle. + setOf("pool", "wallet") + login + cashLink + verification + token + chat + tip } 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 9c98158ebe..6d4858bc83 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 @@ -453,6 +453,33 @@ class AppRouterTest { assertNull(router.classify(DeepLink("https://flipcash.com/terms"))) } + // The reserved list is Android's half of a pair: iOS says the same thing with the AASA's + // `exclude` entries. Anything the website serves and this list misses is a page that opens the + // app and dead-ends on "username not found", so the two are checked against each other. + @Test + fun `classify ignores every handle-shaped path the AASA excludes`() { + val excludedByTheAasa = listOf( + "app", "api", "assets", "fonts", "icons", "js", "v1", + "pool", "wallet", "currencycreator", + "blog", "download", "privacy", "support", "terms", + "c", "cash", "chat", "tip", "token", "verify", + ) + excludedByTheAasa.forEach { path -> + assertNull( + router.classify(DeepLink("https://flipcash.com/$path")), + "flipcash.com/$path belongs to the website, not to a handle", + ) + } + } + + // Reserved by path, not by case: the App Link filter admits mixed case (a link is typed or + // auto-capitalised), and isVanityProfile lowercases before consulting the list. + @Test + fun `classify ignores a reserved path in mixed case`() { + assertNull(router.classify(DeepLink("https://flipcash.com/Download"))) + assertNull(router.classify(DeepLink("https://flipcash.com/CurrencyCreator"))) + } + @Test fun `classify ignores a vanity path that isn't shaped like a handle`() { // Too short, too long, and outside the server's charset.