diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index b604152ba6..7cf5ecee15 100644
--- a/apps/flipcash/app/src/main/AndroidManifest.xml
+++ b/apps/flipcash/app/src/main/AndroidManifest.xml
@@ -27,6 +27,17 @@
+
+
+
+
+
+
+
@@ -197,6 +208,48 @@
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/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,
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..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
@@ -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,44 @@ 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.
+ *
+ * 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 =
+ // 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
}
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 +129,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 +184,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 +194,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 +289,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..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
@@ -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,143 @@ 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")))
+ }
+
+ // 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.
+ 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/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
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)
+ }
+}