diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
index c7b28bb77e..f6966ce2e6 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt
@@ -20,6 +20,7 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -91,6 +92,17 @@ private val pushedTokenInfoEntries: List =
private fun isGiveKey(key: Any?): Boolean =
key?.toString()?.startsWith("Give(") == true
+/**
+ * True when a scene key is [AppRoute.Menu.ProfileCard]. Same stringified-key match as
+ * [isTokenInfoKey]; a `data object` prints its simple name.
+ */
+private fun isProfileCardKey(key: Any?): Boolean =
+ key?.toString() == "ProfileCard"
+
+/** The profile card's own fade; the screen's spring-in does the rest, so nothing slides. */
+private const val ProfileCardFadeMillis = 250
+private val EaseOutEasing = CubicBezierEasing(0f, 0f, 0.58f, 1f)
+
@Composable
internal fun AppContent(
codeNavigator: CodeNavigator,
@@ -201,6 +213,11 @@ internal fun AppContent(
// CardExpandTransition + TokenCardStack.
isTokenInfoKey(targetState.key) ->
CardExpandTransition.openEnter togetherWith CardExpandTransition.openExit
+ // The card presents like a scanned bill: the backdrop fades up over the
+ // You tab, which stays put beneath, and the screen pops the card itself.
+ isProfileCardKey(targetState.key) ->
+ fadeIn(tween(ProfileCardFadeMillis, easing = EaseOutEasing)) togetherWith
+ ExitTransition.None
landsOnTab ->
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
else ->
@@ -221,6 +238,10 @@ internal fun AppContent(
// Swapping in a single frame keeps the scrim continuously up.
isGiveKey(initialState.key) ->
EnterTransition.None togetherWith ExitTransition.None
+ // The whole screen fades out at full size, no slide.
+ isProfileCardKey(initialState.key) ->
+ EnterTransition.None togetherWith
+ fadeOut(tween(ProfileCardFadeMillis, easing = EaseOutEasing))
else ->
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
@@ -234,6 +255,9 @@ internal fun AppContent(
isTokenInfoKey(initialState.key) ->
CardExpandTransition.predictiveCloseEnter togetherWith
CardExpandTransition.predictiveCloseExit
+ isProfileCardKey(initialState.key) ->
+ EnterTransition.None togetherWith
+ fadeOut(tween(ProfileCardFadeMillis, easing = EaseOutEasing))
else ->
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
index 5d94fa9c27..cb78b1a95e 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
@@ -47,6 +47,8 @@ import com.flipcash.app.lab.LabsScreen
import com.flipcash.app.login.AccountSelectionScreen
import com.flipcash.app.login.OnboardingFlowScreen
import com.flipcash.app.menu.MenuScreen
+import com.flipcash.app.menu.ProfileCardScreen
+import com.flipcash.app.menu.ProfileShareScreen
import com.flipcash.app.myaccount.BlocklistScreen
import com.flipcash.app.myaccount.UserProfileScreen
import com.flipcash.app.myaccount.SettingsScreen
@@ -169,6 +171,8 @@ fun appEntryProvider(
annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) }
annotatedEntry { UserProfileScreen() }
annotatedEntry { SettingsScreen() }
+ annotatedEntry { ProfileShareScreen() }
+ annotatedEntry { ProfileCardScreen() }
annotatedEntry { BlocklistScreen() }
annotatedEntry { BackupKeyScreen() }
annotatedEntry { DeviceLogsScreen() }
diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt
index 30b7a1f0c7..cb9c858261 100644
--- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt
+++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddCircleOutline
-import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -26,12 +25,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
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 androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
-import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.core.R
import com.getcode.theme.CodeTheme
import com.getcode.theme.extraSmall
@@ -39,9 +35,8 @@ import com.getcode.theme.extraSmall
/**
* A row in a "what's left to do" checklist.
*
- * Split by the screen that owns it: [Wallet] and [Profile] are drawn by different tabs and share
- * nothing but the row layout, so each call site's `when` stays exhaustive over its own family and
- * cannot be handed an item it has no branch for.
+ * [Wallet] is the family the wallet tab draws, so its call site's `when` stays exhaustive over
+ * the milestones it owns.
*/
sealed interface TutorialItem {
val title: String
@@ -55,9 +50,6 @@ sealed interface TutorialItem {
/** The wallet tab's new-user milestones. */
sealed interface Wallet : TutorialItem
- /** The "You" tab's profile-completion steps (node 9544:18140). */
- sealed interface Profile : TutorialItem
-
class AddMoney(override val isCompleted: Boolean) : Wallet {
override val title: String
@Composable get() = stringResource(R.string.title_addMoney)
@@ -75,41 +67,6 @@ sealed interface TutorialItem {
override val icon: Painter
@Composable get() = painterResource(R.drawable.ic_nav_tipcard)
}
-
- /**
- * Accounts created before onboarding asked for a name have none, and chat shows them as
- * "Flipcash User" until they set one.
- */
- class DisplayName(override val isCompleted: Boolean) : Profile {
- override val title: String
- @Composable get() = stringResource(R.string.title_addDisplayName)
- override val description: String
- @Composable get() = stringResource(R.string.subtitle_addDisplayName)
- override val icon: Painter
- @Composable get() = rememberVectorPainter(Icons.Outlined.Edit)
- }
-
- class ProfilePicture(override val isCompleted: Boolean) : Profile {
- override val title: String
- @Composable get() = stringResource(R.string.title_addProfilePicture)
- override val description: String
- @Composable get() = stringResource(R.string.subtitle_addProfilePicture)
- override val icon: Painter
- @Composable get() = painterResource(R.drawable.ic_people_circle)
- }
-
- /**
- * The fee another user has to pay to open a DM, stored on the profile as `minDmChatInitFee`.
- * Completes once one is set; the row stays outstanding while the server default applies.
- */
- class MinimumTip(override val isCompleted: Boolean = false) : Profile {
- override val title: String
- @Composable get() = stringResource(R.string.title_setMinimumTip)
- override val description: String
- @Composable get() = stringResource(R.string.subtitle_setMinimumTip)
- override val icon: Painter
- @Composable get() = painterResource(R.drawable.ic_coins)
- }
}
@Composable
@@ -210,33 +167,3 @@ private fun OnboardingItemRow(
)
}
}
-
-@Preview(name = "Finish Your Profile — nothing done")
-@PreviewWrapper(FlipcashThemeWrapper::class)
-@Composable
-private fun PreviewFinishProfileEmpty() {
- NewUserTutorial(
- title = stringResource(R.string.title_finishYourProfile),
- items = listOf(
- TutorialItem.DisplayName(isCompleted = false),
- TutorialItem.ProfilePicture(isCompleted = false),
- TutorialItem.MinimumTip(),
- ),
- onItemClicked = {},
- )
-}
-
-@Preview(name = "Finish Your Profile — photo set")
-@PreviewWrapper(FlipcashThemeWrapper::class)
-@Composable
-private fun PreviewFinishProfilePhotoSet() {
- NewUserTutorial(
- title = stringResource(R.string.title_finishYourProfile),
- items = listOf(
- TutorialItem.DisplayName(isCompleted = true),
- TutorialItem.ProfilePicture(isCompleted = true),
- TutorialItem.MinimumTip(),
- ),
- onItemClicked = {},
- )
-}
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 d1fbdb5b24..d70d89c45b 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
@@ -328,6 +328,20 @@ sealed interface AppRoute : NavKey, Parcelable {
data object AccountSelection : Menu
@Serializable
data object UserProfile : Menu
+
+ /**
+ * What to do with your own profile: share it, show its card, copy its link. A
+ * [com.getcode.navigation.WrapContentSheet], for the reason [Messaging.MuteChat] is one.
+ */
+ @Serializable
+ data object ShareProfile :
+ Menu,
+ com.getcode.navigation.Sheet,
+ com.getcode.navigation.WrapContentSheet
+
+ /** Your profile card, full screen, with its Download action. */
+ @Serializable
+ data object ProfileCard : Menu
@Serializable
data class Lab(val onboarding: Boolean = false) : Menu
}
diff --git a/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml
index a711ab1e3a..6f016f0d43 100644
--- a/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml
+++ b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml
@@ -1,6 +1,5 @@
+ A 24dp stroked chevron, which sits lighter beside 16sp text than a filled 16dp one. -->
Scan a Tip Card
Give your first tip
- Finish Your Profile
- Add your display name
- Choose the name people see in chat
- Add a profile picture
- Select a photo from your gallery
- Set your minimum tip amount
- Decide what size tip matters to you
Amount to Buy
Amount to Sell
@@ -1206,8 +1199,6 @@
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
@@ -1333,8 +1324,14 @@
Send Your First Tip
- Full Screen
- Close
+ Edit Profile
+ Claim your username ›
+ Share User Profile
+
+ %1$s · @%2$s
+ Share Profile
+ Show Profile Card
+ Copy Link
Download
Tip Card Link
Group Invite Link
diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts
index 588a0898bd..d3f8e3c8a6 100644
--- a/apps/flipcash/features/menu/build.gradle.kts
+++ b/apps/flipcash/features/menu/build.gradle.kts
@@ -9,13 +9,16 @@ android {
dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.robolectric)
implementation(libs.bundles.haze)
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:bills"))
+ implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:funding"))
+ implementation(project(":apps:flipcash:shared:payments"))
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
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt
new file mode 100644
index 0000000000..c6ef7d6057
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt
@@ -0,0 +1,164 @@
+package com.flipcash.app.menu
+
+import dev.chrisbanes.haze.rememberHazeState
+import dev.chrisbanes.haze.hazeSource
+import com.flipcash.app.menu.internal.ProfileBarButton
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.flipcash.app.bills.ScannableRenderer
+import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha
+import com.flipcash.app.bills.components.cards.LocalTipCardColor
+import com.flipcash.app.bills.components.cards.TipCardFlattened
+import com.flipcash.app.menu.internal.ProfileCardViewModel
+import com.flipcash.core.R as CoreR
+import com.flipcash.features.menu.R
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.utils.KeepScreenOn
+import com.getcode.ui.theme.ButtonState
+import com.getcode.ui.theme.CodeButton
+
+/**
+ * The viewer's own profile card, full screen, behind `AppRoute.Menu.ProfileCard`. It presents like
+ * a scanned bill: the card pops in on a spring while the Close button fades in. The backdrop's
+ * fade in and the whole screen's fade out belong to the navigation transition, which fades this
+ * route in place of the default slide. Download, where the You tab keeps its settings gear, hands
+ * the card to the system share sheet as an image.
+ */
+@Composable
+fun ProfileCardScreen() {
+ val viewModel = hiltViewModel()
+ val navigator = LocalCodeNavigator.current
+ val haptics = LocalHapticFeedback.current
+ val card = viewModel.card
+
+ var appeared by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
+ appeared = true
+ }
+
+ val fade by animateFloatAsState(
+ targetValue = if (appeared) 1f else 0f,
+ animationSpec = tween(durationMillis = RevealFadeMillis, easing = EaseOut),
+ label = "profile card fade",
+ )
+ val cardScale by animateFloatAsState(
+ targetValue = if (appeared) 1f else CardInitialScale,
+ animationSpec = spring(dampingRatio = CardSpringDamping, stiffness = CardSpringStiffness),
+ label = "profile card scale",
+ )
+
+ // As on iOS: the card is a code someone is about to scan, so a dim screen is raised to 0.6 for
+ // as long as it is up (the boost is capped there, so anything under 0.4 lands on exactly 0.6).
+ KeepScreenOn(
+ isEnabled = true,
+ useBrightness = true,
+ minBrightness = ScanMinimumBrightness,
+ brightnessBoost = ScanBoostedBrightness,
+ maxBrightness = ScanBoostedBrightness,
+ )
+
+ // Everything under the Download button is its frosting source, as the cover is for the You tab's gear.
+ val hazeState = rememberHazeState()
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(CodeTheme.colors.background),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .hazeSource(hazeState),
+ ) {
+ if (card != null) {
+ // Static backdrop, so draw the card opaque at its flattened tone, as the You tab did.
+ CompositionLocalProvider(
+ LocalTipCardColor provides TipCardFlattened,
+ LocalTipCardBaseAlpha provides 1f,
+ ) {
+ ScannableRenderer(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .graphicsLayer {
+ alpha = fade
+ scaleX = cardScale
+ scaleY = cardScale
+ },
+ scannable = card,
+ tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction,
+ )
+ }
+ }
+ }
+
+ // Download stands where the You tab keeps its gear, so the gear reads as turning into it.
+ // Nothing to export until the card resolves (or if it never does).
+ if (card != null) {
+ ProfileBarButton(
+ modifier = Modifier.align(Alignment.TopEnd),
+ icon = R.drawable.ic_file_download,
+ contentDescription = stringResource(R.string.action_download),
+ onClick = viewModel::download,
+ hazeState = hazeState,
+ testTag = "you-download-button",
+ )
+ }
+
+ CodeButton(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(bottom = CodeTheme.dimens.grid.x4)
+ .graphicsLayer { alpha = fade },
+ buttonState = ButtonState.Subtle,
+ onClick = { navigator.pop() },
+ text = stringResource(CoreR.string.description_chatMediaClose),
+ )
+ }
+}
+
+private const val ScanMinimumBrightness = 0.4f
+private const val ScanBoostedBrightness = 0.6f
+
+/** How much of the display the card spans, the proportion the earlier full-screen card used. */
+private const val CardWidthFraction = 0.75f
+
+private const val RevealFadeMillis = 250
+
+/** The card's size as it starts to pop in. */
+private const val CardInitialScale = 0.55f
+
+// SwiftUI `.spring(duration: 0.4, bounce: 0.6)`: damping ratio is 1 - bounce, and stiffness is
+// (2 * pi / duration)^2. The damping ratio matches the scanned bill's pop,
+// `AnimationUtils.animationBillEnterGrabbed`.
+private const val CardSpringDamping = 0.4f
+private const val CardSpringStiffness = 247f
+
+private val EaseOut = CubicBezierEasing(0f, 0f, 0.58f, 1f)
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt
new file mode 100644
index 0000000000..e6a7f4ad1d
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt
@@ -0,0 +1,60 @@
+package com.flipcash.app.menu
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.menu.internal.ProfileShareViewModel
+import com.flipcash.features.menu.R
+import com.flipcash.shared.common.ui.profile.ProfileShareRow
+import com.flipcash.shared.common.ui.profile.ProfileShareSheet
+import com.getcode.navigation.core.LocalCodeNavigator
+
+private const val RowShare = "share"
+private const val RowCard = "card"
+private const val RowCopy = "copy"
+
+/**
+ * The share sheet for the viewer's own profile, behind `AppRoute.Menu.ShareProfile`.
+ *
+ * A row does not act on the tap. It asks the navigator to animate the sheet away and runs the
+ * action once that has finished, so the system share sheet or the card screen is not opened over a
+ * sheet that is still on its way out.
+ */
+@Composable
+fun ProfileShareScreen() {
+ val viewModel = hiltViewModel()
+ val navigator = LocalCodeNavigator.current
+ val profile = remember(viewModel) { viewModel.profile }
+
+ val name = profile?.displayName.orEmpty()
+ val handle = profile?.username
+ val subtitle = if (handle.isNullOrEmpty()) {
+ name
+ } else {
+ stringResource(R.string.subtitle_shareUserProfile, name, handle)
+ }
+ val rows = listOf(
+ ProfileShareRow(RowShare, R.drawable.ic_share_os, stringResource(R.string.action_shareProfileLink)),
+ ProfileShareRow(RowCard, R.drawable.ic_qr_code, stringResource(R.string.action_showProfileCard)),
+ ProfileShareRow(RowCopy, R.drawable.ic_copy, stringResource(R.string.action_copyLink)),
+ )
+
+ ProfileShareSheet(
+ title = stringResource(R.string.title_shareUserProfile),
+ subtitle = subtitle,
+ rows = rows,
+ onRow = { row ->
+ // A second tap while the sheet animates out would replace the pending action and run both.
+ if (navigator.pendingSheetDismiss != null) return@ProfileShareSheet
+ navigator.pendingSheetDismiss = {
+ when (row.id) {
+ RowShare -> viewModel.share()
+ RowCard -> navigator.push(AppRoute.Menu.ProfileCard)
+ RowCopy -> viewModel.copyLink()
+ }
+ }
+ },
+ )
+}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt
new file mode 100644
index 0000000000..0811b2e495
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt
@@ -0,0 +1,26 @@
+package com.flipcash.app.menu.internal
+
+import android.text.format.DateFormat
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.time.Instant
+
+/**
+ * The join date as the profile's stats card shows it: full month and year ("October 2026"), in
+ * [locale]'s language and word order. Null when the account has no join date, which the card draws
+ * as a dash.
+ *
+ * The pattern comes from [DateFormat.getBestDateTimePattern] for the `MMMMy` skeleton, so word
+ * order and connectives follow [locale] ("octubre de 2026" in Spanish).
+ */
+internal fun joinedLabel(
+ joinedAt: Instant?,
+ locale: Locale = Locale.getDefault(),
+ zone: ZoneId = ZoneId.systemDefault(),
+): String? {
+ joinedAt ?: return null
+ return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "MMMMy"), locale)
+ .withZone(zone)
+ .format(java.time.Instant.ofEpochMilli(joinedAt.toEpochMilliseconds()))
+}
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 efb49b87ff..2ad7c00fa0 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
@@ -1,625 +1,270 @@
package com.flipcash.app.menu.internal
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.Crossfade
-import androidx.compose.animation.core.EaseInOut
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.statusBars
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.layout.boundsInRoot
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
-import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.lerp
-import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.bills.ScannableRenderer
import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha
import com.flipcash.app.bills.components.cards.LocalTipCardColor
+import com.flipcash.app.bills.components.cards.TipCardFlattened
+import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.bill.Scannable
-import com.flipcash.app.core.navigation.HideTabBar
import com.flipcash.app.core.navigation.LocalTabBarPadding
-import com.flipcash.app.core.AppRoute
import com.flipcash.app.menu.internal.MenuScreenViewModel.Event
-import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState
-import com.flipcash.app.core.ui.onboarding.NewUserTutorial
-import com.flipcash.app.core.ui.onboarding.TutorialItem
-import com.flipcash.app.core.util.abbreviatedLink
-import com.flipcash.app.menu.internal.components.UsernameProgress
-import com.flipcash.app.menu.internal.components.UsernameProgressCard
+import com.flipcash.app.menu.internal.MenuScreenViewModel.ProfileState
import com.flipcash.app.theme.FlipcashThemeWrapper
-import com.flipcash.services.models.UserProfile
import com.flipcash.core.R as CoreR
import com.flipcash.features.menu.R
+import com.flipcash.services.models.UserProfile
+import com.flipcash.services.models.chat.BlobAccessContext
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.flipcash.shared.common.ui.profile.ProfileActionButton
+import com.flipcash.shared.common.ui.profile.ProfileHeader
+import com.flipcash.shared.common.ui.profile.ProfileStatsCard
import com.getcode.theme.CodeTheme
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.core.noRippleClickable
import com.getcode.ui.core.verticalScrollStateGradient
-import com.getcode.ui.utils.sheetResignmentBehavior
import com.getcode.ui.theme.CodeScaffold
+import com.getcode.ui.utils.sheetResignmentBehavior
import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeBlurDefaults
import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.hazeBlur
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
-import kotlinx.coroutines.delay
+/**
+ * The "You" tab: the viewer's own profile, in the same layout other people's profiles use. The
+ * settings gear floats over the cover's top trailing corner and scrolls away with the page.
+ */
@Composable
internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
-
val listState = rememberLazyListState()
- // Full screen is a state of *this* screen, not a destination: the card grows into the middle of
- // the display and everything else — rows, footer, tab bar — animates out from under it
- // (node 9277:121410). Pushing a route would cross-fade a second copy of the card in instead.
- val expansion = rememberTipCardExpansion()
- val cardExpanded = expansion.isExpanded
- // Only a claimed card expands — the unclaimed stand-in is decoration behind the prompt.
- val canExpand = state.tipCard != null
+ // 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 under it. Per-entry
+ // via LocalTabBarPadding, which is only non-zero for tab homes. v1 has no such bar.
+ val bottomInset = LocalTabBarPadding.current.calculateBottomPadding()
- LaunchedEffect(canExpand) {
- // Losing the card (sign-out) must not strand the page expanded.
- if (!canExpand) expansion.collapse()
- }
- HideTabBar(hidden = cardExpanded)
- BackHandler(enabled = cardExpanded) { expansion.collapse() }
-
- // No app bar — the card is the first thing on the page (node 9276:4634). The only chrome is the
- // settings gear, floated over the page's top trailing corner.
+ // No app bar: the cover is the first thing on the page, and reaches the top of the display. It
+ // is not padded for the status bar, so the gear takes that clearance for itself.
CodeScaffold { padding ->
- BoxWithConstraints(
+ LazyColumn(
modifier = Modifier
.fillMaxSize()
- .padding(padding),
+ .padding(padding)
+ .verticalScrollStateGradient(scrollState = listState, isLongGradient = true)
+ .sheetResignmentBehavior(listState),
+ state = listState,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = bottomInset),
) {
- // No app bar in v2, so the page owns its own status-bar clearance; the design puts the
- // card 74dp below it (node 9278:7301).
- // The settings gear is the first thing in the list, so it scrolls away with the page.
- // The row it sits in takes the status-bar clearance plus the button's own height out of
- // the card's resting offset, which leaves the card exactly where it was.
- val restingTop = (CardTopSpacing - SettingsButtonSize).coerceAtLeast(0.dp)
- // The design's width, narrowed only if the display can't hold it inside the page's
- // margins — same rule iOS applies.
- val expandedCardWidth = minOf(
- FullScreenCardWidth,
- 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
- // stops part-way is as legal a state as either end, and none of it can drift out of
- // step with the rest.
- //
- // Handed down as a lambda and never read here: every consumer below reads it inside a
- // graphicsLayer block, which the frame re-runs on its own when the value changes.
- // Reading it in composition instead recomposed this entire page — settings list
- // included — on every frame of the animation.
- val progress = remember(expansion) { { expansion.progress } }
- // The card is scaled, not re-laid-out. Animating its width made every frame re-measure
- // the card and everything inside it: a new corner radius to clip to, a new font size
- // for the name (a full text re-layout), and a new size for the interop View that draws
- // the code, which re-derives the code's geometry from scratch on each draw. That is
- // work for the UI thread, and there is more of it than a frame has time for. iOS hit
- // the same wall and settled on one scale over a card drawn once: laid out, each of the
- // card's metrics is a separate animatable value — and the name's font size is not
- // animatable at all — so the parts arrive at their new sizes at different moments and
- // overlap mid-flight. Scaled, the card travels as one figure.
- //
- // The card is drawn at [FullScreenCardWidth], the widest it ever gets, so the scale
- // only ever samples the drawing down.
- //
- // 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
- // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding,
- // which is only non-zero for tab homes. v1 has no such bar.
- //
- // It does not animate away with the expansion. It is content padding, so every frame of
- // it relaid the whole list — and by the time the released space could be seen, the list
- // has already faded out and slid away under the card.
- val bottomInset = LocalTabBarPadding.current.calculateBottomPadding()
- // 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 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 = slideDistance.toPx() * fraction
+ when (val profileState = state.profileState) {
+ // A named account resolves in a frame or two, and a prompt that flashed at it would
+ // be a lie. The gear is still there, so Settings is never out of reach.
+ ProfileState.Unknown -> item(key = "settings_gear") {
+ SettingsGear(onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) })
}
- }
- // The card doesn't hand off to a second copy of itself: the one in the list keeps its
- // slot and is drawn travelling out of it, the way iOS offsets the card from its own
- // measured frame. Measuring the slot (which never carries the offset) rather than the
- // card keeps the measurement out of its own feedback loop.
- //
- // The slot keeps its resting size for the whole expansion: it is what the card scales
- // out of, not something that grows with it. A slot that grew would move its own centre
- // mid-flight, and that centre is what the shift measures from, so the card would travel
- // against itself — a frame behind its own size the whole way.
- var cardSlotCenterY by remember { mutableFloatStateOf(0f) }
- val displayCenterY = LocalWindowInfo.current.containerSize.height / 2f
- val cardShift = remember(displayCenterY, expansion) {
- {
- if (cardSlotCenterY <= 0f) 0f
- // The overdrag rides on the card's position alone: pushed down past full
- // screen the card has nowhere left to go, so it gives a little where it
- // stands while its size and every fade hold where the expansion left them.
- else (displayCenterY - cardSlotCenterY) * (progress() + expansion.overdrag)
+ is ProfileState.Unclaimed -> {
+ // As on iOS: the header keeps its cover, avatar and Edit Profile, with no Share
+ // (there is nothing to share yet), and the claim prompt takes the stats' place.
+ item(key = "profile_header") {
+ OwnProfileHeader(
+ profile = profileState.profile,
+ onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
+ onShare = null,
+ onClaimUsername = state.usernameProgress?.let {
+ { viewModel.dispatchEvent(Event.ClaimUsername) }
+ },
+ onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
+ )
+ }
+ item(key = "claim_prompt") {
+ Spacer(Modifier.height(UnclaimedTopSpacing))
+ UnclaimedTipCardPrompt(
+ placeholder = profileState.placeholder,
+ cardWidth = YouCardWidth,
+ onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) },
+ )
+ }
}
- }
- // Swiping the expanded card back up puts it away. The card's own travel is the
- // gesture's travel: expanding walks the card DOWN out of its slot into the middle of
- // the display — which is what the "Full Screen" chevron points at, and why "Close"
- // points back up — so the way out is up, and a finger that covers the distance the card
- // has left to go closes it exactly. The card stays under the finger the whole way and
- // springs to whichever end the release picks.
- //
- // It goes on the page rather than on the card, as iOS's does: expanded, the card IS the
- // page, and a pull that has to land on the card exactly is a pull that misses — the
- // card is 302dp of a display wider than that, with live margin either side. Nothing
- // here scrolls while the card is up (see userScrollEnabled below), so there is no
- // scroll for the drag to take events from.
- val density = LocalDensity.current
- val minTravel = with(density) { MinDragTravel.toPx() }
- val flingVelocity = with(density) { MinFlingVelocity.toPx() }
- val touchSlop = LocalViewConfiguration.current.touchSlop
- // The same distance cardShift moves the card, so the card keeps pace with the finger
- // exactly. It holds still for the length of the gesture without having to be pinned:
- // the slot it measures from no longer grows with the card.
- val dragTravel = (displayCenterY - cardSlotCenterY).coerceAtLeast(minTravel)
- val cardDrag = Modifier.draggable(
- state = rememberDraggableState { delta -> expansion.dragBy(delta / dragTravel) },
- orientation = Orientation.Vertical,
- enabled = cardExpanded,
- onDragStarted = { expansion.startDrag(touchSlop / dragTravel) },
- onDragStopped = { velocity ->
- expansion.settle(velocity / dragTravel, flingVelocity / dragTravel)
- },
- )
-
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .verticalScrollStateGradient(scrollState = listState, isLongGradient = true)
- .sheetResignmentBehavior(listState)
- .then(cardDrag),
- state = listState,
- contentPadding = PaddingValues(
- top = restingTop,
- bottom = bottomInset,
- ),
- userScrollEnabled = !cardExpanded,
- ) {
- item(key = "settings_gear", contentType = "settings_gear") {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .statusBarsPadding()
- .then(slideAway),
- ) {
- IconButton(
- onClick = {
+ is ProfileState.Named -> {
+ item(key = "profile_header") {
+ OwnProfileHeader(
+ profile = profileState.profile,
+ onEdit = {
+ // Slice 4 gives this its own screen; Settings is where profile
+ // editing lives until then.
viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings))
},
- enabled = !cardExpanded,
- modifier = Modifier.align(Alignment.TopEnd),
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_settings_outline),
- contentDescription = stringResource(CoreR.string.title_settings),
- tint = CodeTheme.colors.textMain,
- )
- }
+ onShare = { viewModel.dispatchEvent(Event.ShareProfile) },
+ onClaimUsername = state.usernameProgress?.let {
+ { viewModel.dispatchEvent(Event.ClaimUsername) }
+ },
+ onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
+ )
}
- }
- item {
- YouHeader(
- tipCardState = state.tipCardState,
- enabled = !cardExpanded,
- expansion = progress,
- slideAway = slideAway,
- cardScale = cardScale,
- cardShift = cardShift,
- onCardSlotPositioned = { cardSlotCenterY = it },
- onToggleFullScreen = { expansion.toggle() },
- onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) },
- 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) },
- profileTutorial = state.profileTutorial,
- onSetProfilePicture = {
- viewModel.dispatchEvent(Event.SetProfilePicture)
- },
- onSetMinimumTip = {
- viewModel.dispatchEvent(Event.SetMinimumTip)
- },
- )
+ item(key = "stats") {
+ ProfileStatsCard(
+ modifier = Modifier
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = CodeTheme.dimens.staticGrid.x4),
+ minimumToChat = state.minimumToChat,
+ joined = profileState.joined,
+ )
+ }
+ item(key = "bottom_spacer") { Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) }
}
}
-
- // Close sits at the foot of the display rather than under the card (node 9277:121410).
- // It fades on the same progress as everything else rather than on a transition of its
- // own, so a swipe held half-way leaves it half-faded instead of fully drawn.
- // Gated on a derived boolean rather than on the progress itself: the row has to leave
- // the layout when the card is down (nothing invisible left at the foot of the page for
- // a tap or TalkBack to find), but reading the progress here would recompose the page
- // every frame. Derived, it only recomposes when the answer flips.
- val closeVisible by remember { derivedStateOf { expansion.progress > 0f } }
- if (closeVisible) {
- FullScreenToggle(
- label = stringResource(R.string.action_closeFullScreen),
- chevronRotation = 180f,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- // Drawn over the list, so it needs the pull too — a hand reaching for
- // "Close" and pulling instead is the likeliest place to start one.
- .then(cardDrag)
- .graphicsLayer { alpha = progress().coerceIn(0f, 1f) }
- .navigationBarsPadding()
- .padding(bottom = CloseBottomSpacing)
- .noRippleClickable(enabled = cardExpanded) { expansion.collapse() },
- )
- }
}
}
}
-/** The settings gear's touch target, which the card's resting offset is measured past. */
-private val SettingsButtonSize: Dp = 48.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 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
-
-private val YouCardWidth: Dp
- @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction
-
-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: Dp
- @Composable get() = CodeTheme.dimens.grid.x2
-
-/** How far the page's content slides down as it fades out under the expanding card. */
-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
- * progress, not of the duration, so a slow drag fades it on exactly the terms a spring does.
- *
- * It is short because the card is chasing it. The card's lower edge travels down from the slot at
- * the sum of its own growth and its shift to the middle of the display — about 224dp of travel per
- * unit of progress on a 1080x2424 screen, and more on a taller one — against the 24dp of clearance
- * between the two. So the card is over this spot by a tenth of the way out, and the caption cannot
- * still be legible when it gets there.
- */
-private const val CaptionFadeTravel = 0.08f
-
-/**
- * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance,
- * the copyable tip link, and the Share / Download tiles.
- *
- * An account with no display name has no card yet, and gets [UnclaimedTipCardPrompt] in its place
- * rather than an empty page. Nothing is drawn while the state is still [TipCardState.Unknown] — a
- * named account resolves in a frame or two, and a prompt that flashed at it would be a lie.
- */
+/** The viewer's profile header with the settings gear over the cover's top trailing corner. */
@Composable
-private fun YouHeader(
- tipCardState: TipCardState,
- enabled: Boolean,
- expansion: () -> Float,
- slideAway: Modifier,
- cardScale: () -> Float,
- cardShift: () -> Float,
- onCardSlotPositioned: (Float) -> Unit,
- onToggleFullScreen: () -> Unit,
- onCopyLink: () -> Unit,
- onShare: () -> Unit,
- onDownload: () -> Unit,
- onClaim: () -> Unit,
- usernameProgress: UsernameProgress?,
- usernameMinimumBalance: String,
- onClaimUsername: () -> Unit,
- profileTutorial: List?,
- onSetProfilePicture: () -> Unit,
- onSetMinimumTip: () -> Unit,
+private fun OwnProfileHeader(
+ profile: UserProfile?,
+ onEdit: () -> Unit,
+ onShare: (() -> Unit)?,
+ onClaimUsername: (() -> Unit)?,
+ onSettings: () -> Unit,
) {
- when (tipCardState) {
- TipCardState.Unknown -> Unit
- is TipCardState.Unclaimed -> UnclaimedTipCardPrompt(
- placeholder = tipCardState.placeholder,
- // An unclaimed stand-in never expands, so it is only ever the resting card.
- cardWidth = YouCardWidth,
- enabled = enabled,
- onClaim = onClaim,
- profileTutorial = profileTutorial,
- onSetProfilePicture = onSetProfilePicture,
- onSetMinimumTip = onSetMinimumTip,
+ // The header is what scrolls under the gear, so it is the gear's frosting source.
+ val hazeState = rememberHazeState()
+ Box {
+ ProfileHeader(
+ modifier = Modifier.hazeSource(hazeState),
+ cover = profile?.coverPicture,
+ // The viewer's own blobs, so no profile id is needed to authorize re-minting them.
+ access = BlobAccessContext.Owned,
+ avatar = { modifier ->
+ ContactAvatar(
+ image = profile?.profilePicture,
+ displayName = profile?.displayName.orEmpty(),
+ access = BlobAccessContext.Owned,
+ modifier = modifier,
+ )
+ },
+ title = profile?.displayName?.ifEmpty { null },
+ subtitle = profile?.username?.takeIf { it.isNotEmpty() }?.let { "@$it" },
+ body = profile?.bio?.ifEmpty { null },
+ // As on iOS: a one-line offer under the handle while there is no handle, or only an
+ // auto-assigned one. The tap goes through the balance gate like the old progress card.
+ underSubtitle = {
+ if (onClaimUsername != null) {
+ Text(
+ modifier = Modifier
+ .padding(top = CodeTheme.dimens.staticGrid.x1)
+ .clickable(onClick = onClaimUsername)
+ .padding(vertical = CodeTheme.dimens.staticGrid.x1)
+ .testTag("you-claim-username"),
+ text = stringResource(R.string.action_claimYourUsernameLink),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+ },
+ actions = {
+ ProfileActionButton(
+ text = stringResource(R.string.action_editProfile),
+ onClick = onEdit,
+ )
+ // Only a named profile has something to share.
+ if (onShare != null) {
+ ProfileActionButton(
+ icon = ImageVector.vectorResource(R.drawable.ic_share_os),
+ contentDescription = stringResource(R.string.action_share),
+ onClick = onShare,
+ )
+ }
+ },
)
- is TipCardState.Claimed -> ClaimedTipCard(
- card = tipCardState.card,
- link = tipCardState.link,
- enabled = enabled,
- expansion = expansion,
- slideAway = slideAway,
- cardScale = cardScale,
- cardShift = cardShift,
- onCardSlotPositioned = onCardSlotPositioned,
- onToggleFullScreen = onToggleFullScreen,
- onCopyLink = onCopyLink,
- onShare = onShare,
- onDownload = onDownload,
- usernameProgress = usernameProgress,
- usernameMinimumBalance = usernameMinimumBalance,
- onClaimUsername = onClaimUsername,
- profileTutorial = profileTutorial,
- onSetDisplayName = onClaim,
- onSetProfilePicture = onSetProfilePicture,
- onSetMinimumTip = onSetMinimumTip,
+ SettingsGear(
+ modifier = Modifier.align(Alignment.TopEnd),
+ onClick = onSettings,
+ hazeState = hazeState,
)
}
}
/**
- * The claimed card and everything that hangs off it.
- *
- * The caller drives the full-screen state: it scales the card ([cardScale]) and draws it out of its
- * slot towards the middle of the display ([cardShift], off the slot position reported by
- * [onCardSlotPositioned]). It also hands down [slideAway] — the fade-and-slide every non-card
- * element shares — plus [expansion] for the caption, which iOS fades in place rather than sliding.
- *
- * All four arrive as lambdas so they are read inside the graphics layers that use them, off the
- * composition. Read as values, the whole page would recompose on every frame of the animation.
+ * The settings gear, clear of the status bar. Over the cover it frosts the picture behind it
+ * ([hazeState]); with no cover to frost (an account still loading or unnamed) it falls back to the
+ * flat translucent fill.
*/
-@OptIn(ExperimentalLayoutApi::class)
@Composable
-private fun ClaimedTipCard(
- card: Scannable.TipCard,
- link: String?,
- enabled: Boolean,
- expansion: () -> Float,
- slideAway: Modifier,
- cardScale: () -> Float,
- cardShift: () -> Float,
- onCardSlotPositioned: (Float) -> Unit,
- onToggleFullScreen: () -> Unit,
- onCopyLink: () -> Unit,
- onShare: () -> Unit,
- onDownload: () -> Unit,
- usernameProgress: UsernameProgress?,
- usernameMinimumBalance: String,
- onClaimUsername: () -> Unit,
- profileTutorial: List?,
- onSetDisplayName: () -> Unit,
- onSetProfilePicture: () -> Unit,
- onSetMinimumTip: () -> Unit,
+private fun SettingsGear(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ hazeState: HazeState? = null,
) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- // Static display (no camera behind the card): render it opaque at the design's flattened
- // colour rather than the translucent frosted fill. Figma flattens the card to rgb(16,16,17).
- CompositionLocalProvider(
- LocalTipCardColor provides Color(0xFF101011),
- LocalTipCardBaseAlpha provides 1f,
- ) {
- Box(
- modifier = Modifier
- // The card pads itself off the status bar for the full-screen overlay; here the
- // list's content padding already owns that clearance, so consume the inset
- // rather than paying it twice.
- .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility)
- // The slot is the card at rest, pinned: the card grows by scaling out of it,
- // so the slot's centre — which cardShift measures from — has to hold still.
- .size(YouCardWidth, YouCardWidth * TipCardAspectRatio)
- .onGloballyPositioned { onCardSlotPositioned(it.boundsInRoot().center.y) },
- contentAlignment = Alignment.Center,
- ) {
- Box(
- modifier = Modifier
- // Measured unbounded so the card can be drawn at its full width inside the
- // smaller slot; the slot's constraints would otherwise squeeze it back down
- // to the resting size and there would be nothing to scale up to.
- .wrapContentSize(unbounded = true)
- // Ahead of the gesture modifiers, so the drag and the tap travel with the
- // card rather than staying behind at the slot it left. Both reads happen
- // here rather than in composition: the layer re-runs this block by itself
- // when they change, which is a render-node transform and no relayout.
- .graphicsLayer {
- val scale = cardScale()
- scaleX = scale
- scaleY = scale
- translationY = cardShift()
- }
- .noRippleClickable { onToggleFullScreen() },
- ) {
- ScannableRenderer(scannable = card, tipCardWidth = FullScreenCardWidth)
- }
- }
- }
-
- Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
-
- // The caption belongs to the card, so it fades where it stands instead of sliding off with
- // the rest of the page — and it is gone well before the card is over it. The caption is a
- // later sibling than the card and so paints on top of it, while the card grows and travels
- // down across this very spot; faded over the whole expansion it would still be legible at
- // the point it ends up printed across the card's face. See [CaptionFadeTravel].
- FullScreenToggle(
- label = stringResource(R.string.action_viewFullScreen),
- chevronRotation = 0f,
- modifier = Modifier
- .graphicsLayer { alpha = (1f - expansion() / CaptionFadeTravel).coerceIn(0f, 1f) }
- .noRippleClickable { onToggleFullScreen() },
- )
-
- // Everything under the card gets out of the way so the card can own the display.
- Column(
- modifier = slideAway.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Spacer(Modifier.height(CodeTheme.dimens.grid.x6))
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = CodeTheme.dimens.grid.x5),
- verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
- ) {
- // Node 9641:17031 puts the checklist directly under the caption, above the
- // link row. Null while the profile is unresolved so it never draws against a
- // guess.
- if (profileTutorial != null) {
- ProfileTutorial(
- items = profileTutorial,
- onSetDisplayName = onSetDisplayName,
- onSetProfilePicture = onSetProfilePicture,
- onSetMinimumTip = onSetMinimumTip,
- )
-
- // Node 9641:17048 separates the checklist from the link row. The column
- // already spaces siblings by 10dp; the rest of the 20dp gap on each side is
- // the divider's own padding.
- HorizontalDivider(
- modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2),
- color = CodeTheme.colors.divider,
- thickness = CodeTheme.dimens.border,
- )
- }
-
- if (link != null) {
- TipLinkRow(link = link, enabled = enabled, onCopy = onCopyLink)
- }
+ ProfileBarButton(
+ icon = R.drawable.ic_settings_outline,
+ contentDescription = stringResource(CoreR.string.title_settings),
+ onClick = onClick,
+ modifier = modifier,
+ hazeState = hazeState,
+ testTag = "you-settings",
+ )
+}
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(CodeTheme.dimens.grid.x18),
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
- ) {
- ShareTile(
- modifier = Modifier.weight(1f),
- icon = R.drawable.ic_share_os,
- label = stringResource(R.string.action_share),
- enabled = enabled,
- onClick = onShare,
- )
- ShareTile(
- modifier = Modifier.weight(1f),
- icon = R.drawable.ic_file_download,
- label = stringResource(R.string.action_download),
- enabled = enabled,
- onClick = onDownload,
- )
- }
+/**
+ * The resting card's width (node 9278:7301: 241.636), kept as the fraction of a 402-wide display it
+ * was drawn at rather than the dp it happened to measure on it.
+ */
+private const val YouCardWidthFraction = 0.60f
- // 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,
- )
- }
- }
+private val YouCardWidth: Dp
+ @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction
- Spacer(Modifier.height(CodeTheme.dimens.grid.x4))
- }
- }
-}
+/** Gap between the header and the unclaimed stand-in (iOS 24). */
+private val UnclaimedTopSpacing: Dp
+ @Composable get() = CodeTheme.dimens.staticGrid.x5
/**
* What the "You" tab shows before the account has a display name: the card it *would* have, blurred
@@ -627,8 +272,8 @@ private fun ClaimedTipCard(
*
* The stand-in is the account's real scannable payload drawn over an unnamed profile, with the
* card's own fill turned off so the 8% ground shows through — the same construction iOS uses. It is
- * decoration: not tappable, not expandable, not shareable, and the tip link and Share / Download
- * tiles are absent entirely, because there is nothing yet to link to or share.
+ * decoration: not tappable, not expandable, not shareable. It takes the stats card's place under the
+ * header, and the header drops its Share button, because there is nothing yet to share.
*
* [blurEnabled] is haze's own API-31 gate, surfaced so a preview can render what an API 29/30
* device draws (see `Preview_UnclaimedTipCardPrompt_NoBlur`). Leave it at the default in app code.
@@ -638,11 +283,7 @@ private fun ClaimedTipCard(
private fun UnclaimedTipCardPrompt(
placeholder: Scannable.TipCard?,
cardWidth: Dp,
- enabled: Boolean,
onClaim: () -> Unit,
- profileTutorial: List?,
- onSetProfilePicture: () -> Unit,
- onSetMinimumTip: () -> Unit,
blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(),
) {
val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction)
@@ -700,7 +341,7 @@ private fun UnclaimedTipCardPrompt(
}
CompositionLocalProvider(
- LocalTipCardColor provides Color(0xFF101011),
+ LocalTipCardColor provides TipCardFlattened,
// Fill off, so the placeholder ground behind it is what's frosted, not an opaque card.
LocalTipCardBaseAlpha provides 0f,
) {
@@ -743,7 +384,7 @@ private fun UnclaimedTipCardPrompt(
.padding(top = CodeTheme.dimens.grid.x4)
.clip(CircleShape)
.background(CodeTheme.colors.textMain)
- .clickable(enabled = enabled, onClick = onClaim)
+ .clickable(onClick = onClaim)
.padding(
horizontal = CodeTheme.dimens.grid.x5,
vertical = CodeTheme.dimens.grid.x2,
@@ -755,47 +396,10 @@ private fun UnclaimedTipCardPrompt(
}
}
- // No link row or Share / Download to sit above here, so the checklist goes straight under
- // the stand-in, with the display-name step first.
- if (profileTutorial != null) {
- ProfileTutorial(
- modifier = Modifier
- .padding(top = CodeTheme.dimens.grid.x6)
- .padding(horizontal = CodeTheme.dimens.grid.x5),
- items = profileTutorial,
- // The same destination as "Start Receiving Tips": the name is what claims the card.
- onSetDisplayName = onClaim,
- onSetProfilePicture = onSetProfilePicture,
- onSetMinimumTip = onSetMinimumTip,
- )
- }
-
Spacer(Modifier.height(UnclaimedRowsGap))
}
}
-/** The "Finish Your Profile" checklist, drawn under whichever card the tab is showing. */
-@Composable
-private fun ProfileTutorial(
- items: List,
- onSetDisplayName: () -> Unit,
- onSetProfilePicture: () -> Unit,
- onSetMinimumTip: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- NewUserTutorial(
- modifier = modifier.fillMaxWidth(),
- title = stringResource(R.string.title_finishYourProfile),
- items = items,
- ) { item ->
- when (item) {
- is TutorialItem.DisplayName -> onSetDisplayName()
- is TutorialItem.ProfilePicture -> onSetProfilePicture()
- is TutorialItem.MinimumTip -> onSetMinimumTip()
- }
- }
-}
-
/** The tip card's height-to-width proportion and corner radius, mirrored from `TipCard`. */
private const val TipCardAspectRatio = 333f / 269f
private const val TipCardCornerFraction = 0.08f
@@ -812,156 +416,12 @@ 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)`).
+ * Gap below the unclaimed stand-in. Wider than the claimed card's 19, because the claimed
+ * card pays part of its clearance in the action buttons that the unclaimed state doesn't draw (iOS `YouScreen`: `.padding(.top, displayName == nil ? 48 : 19)`).
*/
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
- * the resting card (node 9276:4634), "Close" pointing up at the foot of the expanded one
- * (node 9277:121410). One glyph, flipped, so the two read as the same control.
- */
-@Composable
-private fun FullScreenToggle(
- label: String,
- chevronRotation: Float,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier = modifier,
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
- ) {
- Text(
- text = label,
- style = CodeTheme.typography.textSmall,
- color = White50,
- )
- Icon(
- modifier = Modifier
- .size(16.dp)
- .rotate(chevronRotation),
- painter = painterResource(R.drawable.ic_chevron_down_medium),
- contentDescription = null,
- tint = White50,
- )
- }
-}
-
-/** The tip link, tap-to-copy (node 9276:4748). Shown short — the full URL goes to the clipboard. */
-@Composable
-private fun TipLinkRow(link: String, enabled: Boolean, onCopy: () -> Unit) {
- // Bumped rather than latched so a second tap restarts the hold instead of being swallowed.
- var copyToken by remember { mutableIntStateOf(0) }
- val copied = copyToken > 0
-
- LaunchedEffect(copyToken) {
- if (copyToken > 0) {
- delay(CopyConfirmationMillis)
- copyToken = 0
- }
- }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(CodeTheme.dimens.grid.x8)
- .clip(CodeTheme.shapes.extraSmall)
- .background(White05)
- .clickable(enabled = enabled) {
- onCopy()
- copyToken++
- }
- .padding(horizontal = CodeTheme.dimens.grid.x3),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Row(
- modifier = Modifier.weight(1f, fill = false),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
- ) {
- Icon(
- modifier = Modifier.size(20.dp),
- painter = painterResource(R.drawable.ic_chain_link),
- contentDescription = null,
- tint = White,
- )
- Text(
- text = link.abbreviatedLink(),
- style = CodeTheme.typography.textSmall.copy(fontSize = 15.sp),
- color = White.copy(alpha = 0.7f),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- // Confirms the copy landed, then hands the row back to the copy glyph (mirrors iOS
- // TipCardLinkRow) — the clipboard gives no feedback of its own.
- Crossfade(
- targetState = copied,
- animationSpec = tween(CopyIconFadeMillis, easing = EaseInOut),
- label = "copyConfirmation",
- ) { showCheck ->
- Icon(
- modifier = Modifier.size(20.dp),
- painter = painterResource(
- if (showCheck) R.drawable.ic_check_circle else R.drawable.ic_copy
- ),
- contentDescription = null,
- tint = White,
- )
- }
- }
-}
-
-/** How long the copy button holds the checkmark before reverting (iOS: 1.5s). */
-private const val CopyConfirmationMillis = 1_500L
-
-/** Cross-fade between the copy and confirmation glyphs (iOS: 0.15s ease-in-out). */
-private const val CopyIconFadeMillis = 150
-
-/**
- * One of the two square-ish actions under the link (node 9276:4756). The tile's height is fixed by
- * the parent row and the arrangement centres its contents, so it takes no vertical padding of its
- * own: 20dp of it on each side left the label a 14dp box for a 16dp line and clipped its descenders.
- */
-@Composable
-private fun ShareTile(
- modifier: Modifier = Modifier,
- icon: Int,
- label: String,
- enabled: Boolean,
- onClick: () -> Unit,
-) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .clip(CodeTheme.shapes.extraSmall)
- .background(White05)
- .clickable(enabled = enabled) { onClick() },
- verticalArrangement = Arrangement.spacedBy(
- CodeTheme.dimens.grid.x1,
- Alignment.CenterVertically,
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Icon(
- modifier = Modifier.size(28.dp),
- painter = painterResource(icon),
- contentDescription = null,
- tint = White,
- )
- Text(
- text = label,
- style = CodeTheme.typography.textSmall,
- color = White50,
- )
- }
-}
-
private val PreviewCodeData = listOf(
0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0,
0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE,
@@ -978,12 +438,7 @@ private fun Preview_UnclaimedTipCardPrompt() {
UnclaimedTipCardPrompt(
placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty),
cardWidth = YouCardWidth,
- enabled = true,
onClaim = {},
- // What a nameless account sees: the display-name step first, nothing done yet.
- profileTutorial = profileTutorialItems(UserProfile.Empty),
- onSetProfilePicture = {},
- onSetMinimumTip = {},
)
}
@@ -999,11 +454,7 @@ private fun Preview_UnclaimedTipCardPrompt_NoBlur() {
UnclaimedTipCardPrompt(
placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty),
cardWidth = YouCardWidth,
- enabled = true,
onClaim = {},
- profileTutorial = null,
- onSetProfilePicture = {},
- onSetMinimumTip = {},
blurEnabled = false,
)
}
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 7596c6d7dc..67931d34f1 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
@@ -1,6 +1,5 @@
package com.flipcash.app.menu.internal
-import android.content.ClipboardManager
import androidx.lifecycle.viewModelScope
import com.flipcash.analytics.AddMoneySource
import com.flipcash.analytics.events.AddMoneyEvents
@@ -10,28 +9,19 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.app.core.bill.Scannable
-import com.flipcash.app.core.extensions.setText
-import com.flipcash.app.core.share.TipCodeExportFormat
-import com.flipcash.app.core.share.TipCodeExporter
-import com.flipcash.app.core.ui.onboarding.TutorialItem
-import com.flipcash.app.core.util.Linkify
-import com.flipcash.app.core.toast.SystemToastController
-import com.flipcash.app.core.tipping.TipCardOwner
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.tokens.core.TotalBalanceProvider
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.features.menu.R
-import com.flipcash.services.models.UserProfile
import com.flipcash.services.user.AuthState
+import com.flipcash.services.models.UserProfile
import com.flipcash.services.user.UserManager
+import com.flipcash.shared.payments.TipPaymentDelegate
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
@@ -40,10 +30,12 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
+import java.util.Locale
import javax.inject.Inject
/**
@@ -57,16 +49,13 @@ private fun Fiat.formattedGate(): String =
internal class MenuScreenViewModel @Inject constructor(
userManager: UserManager,
userFlags: UserFlagsCoordinator,
- private val toastController: SystemToastController,
dispatchers: DispatcherProvider,
purchaseMethodController: PurchaseMethodController,
totalBalance: TotalBalanceProvider,
analytics: FlipcashAnalytics,
private val tippingCoordinator: TippingCoordinator,
private val tipCodePreviewCache: TipCodePreviewCache,
- private val shareable: ShareSheetController,
- private val clipboardManager: ClipboardManager,
- private val tipCodeExporter: TipCodeExporter,
+ tipPayments: TipPaymentDelegate,
private val resources: ResourceHelper,
) :
BaseViewModel(
@@ -75,46 +64,42 @@ internal class MenuScreenViewModel @Inject constructor(
defaultDispatcher = dispatchers.Default,
) {
data class State(
- // The viewer's own tip card, shown at the top of the v2 "You" tab.
- val tipCardState: TipCardState = TipCardState.Unknown,
+ // What the header draws: nothing yet, the claim prompt, or the viewer's own profile.
+ val profileState: ProfileState = ProfileState.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 "Finish Your Profile" checklist, or null while the profile is unresolved. Only ever
- // drawn under a claimed card — see [ClaimedTipCard].
- val profileTutorial: List? = null,
- ) {
- /** The card to share, export or expand — only a claimed one qualifies. */
- val tipCard: Scannable.TipCard?
- get() = (tipCardState as? TipCardState.Claimed)?.card
-
- /** The shareable URL for [tipCard]. Displayed abbreviated; copied in full. */
- val tipLink: String?
- get() = (tipCardState as? TipCardState.Claimed)?.link
- }
+ // What another user must send to open a chat with the viewer, formatted. Null until it
+ // resolves, which the stats card draws as a dash.
+ val minimumToChat: String? = null,
+ )
/**
* What the "You" tab has to draw at the top of the page.
*
- * The three cases are deliberately distinct: `tipCard == null` used to mean both "we haven't
- * resolved it yet" and "this account has no display name, so there is nothing to resolve", and
- * the header drew nothing for either — leaving a nameless account with no card, no prompt, and
- * no way to claim one from this tab.
+ * The cases are deliberately distinct: a missing profile used to mean both "we haven't resolved
+ * it yet" and "this account has no display name", and the header drew nothing for either —
+ * leaving a nameless account with no prompt and no way to claim a name from this tab.
*/
- sealed interface TipCardState {
+ sealed interface ProfileState {
/** Still resolving (or signed out). Draw nothing rather than guessing. */
- data object Unknown : TipCardState
+ data object Unknown : ProfileState
/**
- * The account has no display name, so it has no card to show yet. [placeholder] is a real
- * scannable stand-in drawn blurred behind the claim prompt; it is never shareable.
+ * The account has no display name yet. [placeholder] is a real scannable stand-in drawn
+ * blurred behind the claim prompt; it is never shareable. [profile] is whatever the server
+ * already holds (a cover, say), or null for an account it has never seen.
*/
- data class Unclaimed(val placeholder: Scannable.TipCard?) : TipCardState
+ data class Unclaimed(
+ val placeholder: Scannable.TipCard?,
+ val profile: UserProfile? = null,
+ ) : ProfileState
- data class Claimed(val card: Scannable.TipCard, val link: String?) : TipCardState
+ /** The viewer's own profile, with [joined] already formatted for the stats card. */
+ data class Named(val profile: UserProfile, val joined: String?) : ProfileState
}
sealed interface Event {
@@ -128,27 +113,20 @@ internal class MenuScreenViewModel @Inject constructor(
) : Event
data class OpenScreen(val screen: AppRoute) : Event
- data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event
+ data class OnProfileStateChanged(val profileState: ProfileState) : Event
data class OnUsernameProgressChanged(
val progress: UsernameProgress?,
val minimumBalance: String,
) : Event
- data class OnProfileTutorialChanged(val items: List?) : Event
-
- /** The checklist's photo row — opens the photo step of the profile flow on its own. */
- data object SetProfilePicture : Event
-
- /** The checklist's minimum-tip row — opens the amount entry for the DM-init fee. */
- data object SetMinimumTip : Event
+ data class OnMinimumToChatChanged(val minimumToChat: 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
- data object CopyTipLink : Event
- data object DownloadTipCard : Event
- data class ExportTipCard(val format: TipCodeExportFormat) : Event
+
+ /** The header's share button — opens the share sheet, but only for a profile that has a name. */
+ data object ShareProfile : Event
}
init {
@@ -160,9 +138,9 @@ internal class MenuScreenViewModel @Inject constructor(
}.onEach { route -> dispatchEvent(Event.OpenScreen(route)) }
.launchIn(viewModelScope)
- // Rebuild the viewer's own tip card whenever their profile becomes available/changes, so
- // the v2 "You" tab can show it at the top. Warm the Sharesheet preview eagerly so it's ready
- // by the time the user taps "Share as a Link" — but only for a card that can be shared.
+ // Rebuild what the header draws whenever the viewer's profile becomes available/changes.
+ // Warm the Sharesheet preview eagerly so it's ready by the time the user picks "Share
+ // Profile" — but only for a profile that can be shared.
//
// Gated on Ready: a named account restores its cached profile before auth completes, so
// waiting here means it never flashes the claim prompt on the way in.
@@ -171,37 +149,38 @@ internal class MenuScreenViewModel @Inject constructor(
.map { it.userProfile }
.distinctUntilChanged()
.onEach { profile ->
- if (profile?.displayName.isNullOrEmpty()) {
- // No name means no card yet — the tab prompts to claim one instead. Built
- // locally, so an account whose profile the server has never seen still gets it.
+ if (profile == null || profile.displayName.isEmpty()) {
+ // No name yet: the header still draws (cover, avatar, Edit Profile) with the claim
+ // prompt under it, as on iOS. The placeholder is built locally, so an account whose profile the server has never seen still
+ // gets it.
dispatchEvent(
- Event.OnTipCardStateChanged(
- TipCardState.Unclaimed(tippingCoordinator.unclaimedTipCard())
+ Event.OnProfileStateChanged(
+ ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard(), profile)
)
)
} else {
- tippingCoordinator.resolveTipCard().onSuccess { card ->
- val userId = tippingCoordinator.currentUserId
- dispatchEvent(
- Event.OnTipCardStateChanged(
- TipCardState.Claimed(card, tipCardLink(card.user, userId))
- )
+ dispatchEvent(
+ Event.OnProfileStateChanged(
+ ProfileState.Named(profile, joinedLabel(profile.joinedAt, Locale.getDefault()))
)
- userId?.let { tipCodePreviewCache.prepare(it, card) }
+ )
+ tippingCoordinator.resolveTipCard().onSuccess { card ->
+ tippingCoordinator.currentUserId?.let { tipCodePreviewCache.prepare(it, card) }
}
}
}
.launchIn(viewModelScope)
- // Gated on Ready for the same reason as the tip card: a named account restores its cached
- // profile before auth completes, so the checklist would otherwise flash an outstanding
- // photo step at someone who already has one.
+ // The fee is a Flow because it moves with the exchange rate and the region minimum, and it
+ // follows the profile because the viewer's own fee setting is part of the answer.
userManager.state
.filter { it.authState is AuthState.Ready }
.map { it.userProfile }
.distinctUntilChanged()
- .map { profileTutorialItems(it) }
- .onEach { dispatchEvent(Event.OnProfileTutorialChanged(it)) }
+ .flatMapLatest { tipPayments.startChattingFee(it) }
+ .map { it?.formatted() }
+ .distinctUntilChanged()
+ .onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) }
.launchIn(viewModelScope)
// The username nudge. Gated on Ready for the same reason as the tip card: a named account
@@ -292,112 +271,17 @@ internal class MenuScreenViewModel @Inject constructor(
.launchIn(viewModelScope)
eventFlow
- .filterIsInstance()
- .onEach {
- dispatchEvent(
- Event.OpenScreen(
- AppRoute.UpdateUserProfile(
- origin = AppRoute.Tabs.Menu,
- // The account already has a name and a card by the time this
- // checklist is drawn, so the flow reduces to the one step.
- steps = listOf(UpdateProfileStep.Photo),
- )
- )
- )
- }
- .launchIn(viewModelScope)
-
- eventFlow
- .filterIsInstance()
- .onEach {
- dispatchEvent(
- Event.OpenScreen(
- AppRoute.UpdateUserProfile(
- origin = AppRoute.Tabs.Menu,
- steps = listOf(UpdateProfileStep.MinimumTip),
- )
- )
- )
- }
- .launchIn(viewModelScope)
-
- eventFlow
- .filterIsInstance()
- .mapNotNull { stateFlow.value.tipLink }
- .onEach { link ->
- // The row shows an abbreviated link; the clipboard gets the whole thing.
- clipboardManager.setText(
- text = link,
- label = resources.getString(R.string.title_clipboardLabelTipCardLink),
- )
- toastController.showToast(R.string.action_copied, replacePrevious = true)
- }
- .launchIn(viewModelScope)
-
- eventFlow
- .filterIsInstance()
- .onEach {
- BottomBarManager.showMessage(
- title = resources.getString(R.string.title_downloadTipCardAs),
- actions = downloadOptions(resources) { format ->
- dispatchEvent(Event.ExportTipCard(format))
- },
- showCancel = false,
- showScrim = true,
- )
- }
- .launchIn(viewModelScope)
-
- // Render the chosen format, then hand the file to the Sharesheet — Android has no
- // permissionless "save to Photos", and the chooser already offers Files/Drive/Photos.
- eventFlow
- .filterIsInstance()
- .mapNotNull { event -> stateFlow.value.tipCard?.let { it to event.format } }
- .onEach { (card, format) ->
- val export = tipCodeExporter.export(card, format)
- if (export == null) {
- BottomBarManager.showMessage(
- title = resources.getString(R.string.error_title_tipCardExportFailed),
- message = resources.getString(R.string.error_description_tipCardExportFailed),
- )
- return@onEach
- }
- shareable.present(
- Shareable.TipCodeImage(
- export = export,
- title = resources.getString(R.string.title_shareTipCode),
- )
- )
- }
- .launchIn(viewModelScope)
-
- eventFlow
- .filterIsInstance()
- .mapNotNull { tippingCoordinator.currentUserId }
- .map { userId ->
- val user = stateFlow.value.tipCard?.user
- // Attach the eagerly-rendered preview if it's ready; null shares the URL alone.
- shareable.present(
- Shareable.Profile(
- userId = userId,
- displayName = user?.displayName,
- username = user?.username,
- preview = tipCodePreviewCache.get(userId),
- )
- )
- }
+ .filterIsInstance()
+ .mapNotNull { shareProfileRoute(stateFlow.value.profileState) }
+ .onEach { dispatchEvent(Event.OpenScreen(it)) }
.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 val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
+ internal val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
when (event) {
- is Event.OnTipCardStateChanged -> { state ->
- state.copy(tipCardState = event.tipCardState)
+ is Event.OnProfileStateChanged -> { state ->
+ state.copy(profileState = event.profileState)
}
is Event.OnUsernameProgressChanged -> { state ->
@@ -407,21 +291,23 @@ internal class MenuScreenViewModel @Inject constructor(
)
}
- is Event.OnProfileTutorialChanged -> { state ->
- state.copy(profileTutorial = event.items)
+ is Event.OnMinimumToChatChanged -> { state ->
+ state.copy(minimumToChat = event.minimumToChat)
}
is Event.PresentDepositOptions,
Event.ClaimTipCard,
Event.ClaimUsername,
- Event.SetProfilePicture,
- Event.SetMinimumTip,
- Event.ShareTipCard,
- Event.CopyTipLink,
- Event.DownloadTipCard,
- is Event.ExportTipCard,
+ Event.ShareProfile,
is Event.OpenScreen -> { state -> state }
}
}
}
}
+
+/**
+ * Where the header's share button goes: the share sheet, once the viewer has a profile worth
+ * sharing. An account with no name has no card or link to hand out, so the tap leads nowhere.
+ */
+internal fun shareProfileRoute(profileState: MenuScreenViewModel.ProfileState): AppRoute? =
+ if (profileState is MenuScreenViewModel.ProfileState.Named) AppRoute.Menu.ShareProfile else null
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt
new file mode 100644
index 0000000000..e2828d467f
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt
@@ -0,0 +1,45 @@
+package com.flipcash.app.menu.internal
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.CircularIconButton
+import dev.chrisbanes.haze.HazeState
+
+/**
+ * The single trailing control the You tab and its profile card draw over their content: Settings on
+ * the tab, Download on the card. It sits in an untitled [AppBarWithTitle] so it lands where every
+ * other tab's trailing bar button does, and it frosts whatever is under it when given a [hazeState].
+ */
+@Composable
+internal fun ProfileBarButton(
+ @DrawableRes icon: Int,
+ contentDescription: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ hazeState: HazeState? = null,
+ testTag: String? = null,
+) {
+ AppBarWithTitle(
+ modifier = modifier,
+ endContent = {
+ CircularIconButton(
+ hazeState = hazeState,
+ onClick = onClick,
+ testTag = testTag,
+ ) { size ->
+ Icon(
+ painter = painterResource(icon),
+ contentDescription = contentDescription,
+ tint = Color.White,
+ modifier = Modifier.requiredSize(size),
+ )
+ }
+ },
+ )
+}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt
new file mode 100644
index 0000000000..8268261a32
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt
@@ -0,0 +1,71 @@
+package com.flipcash.app.menu.internal
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.app.core.share.TipCodeExportFormat
+import com.flipcash.app.core.share.TipCodeExporter
+import com.flipcash.app.shareable.ShareSheetController
+import com.flipcash.app.shareable.Shareable
+import com.flipcash.features.menu.R
+import com.flipcash.shared.tipping.TippingCoordinator
+import com.getcode.manager.BottomBarManager
+import com.getcode.util.resources.ResourceHelper
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/** The viewer's own profile card, shown full screen and exportable as an image. */
+@HiltViewModel
+internal class ProfileCardViewModel @Inject constructor(
+ private val tippingCoordinator: TippingCoordinator,
+ private val tipCodeExporter: TipCodeExporter,
+ private val shareable: ShareSheetController,
+ private val resources: ResourceHelper,
+) : ViewModel() {
+
+ /** Null until the card resolves. */
+ var card: Scannable.TipCard? by mutableStateOf(null)
+ private set
+
+ init {
+ viewModelScope.launch {
+ tippingCoordinator.resolveTipCard().onSuccess { card = it }
+ }
+ }
+
+ /** Asks which format to export, then renders it. */
+ fun download() {
+ BottomBarManager.showMessage(
+ title = resources.getString(R.string.title_downloadTipCardAs),
+ actions = downloadOptions(resources) { format -> export(format) },
+ showCancel = false,
+ showScrim = true,
+ )
+ }
+
+ // Render the chosen format, then hand the file to the Sharesheet — Android has no
+ // permissionless "save to Photos", and the chooser already offers Files/Drive/Photos.
+ private fun export(format: TipCodeExportFormat) {
+ val card = card ?: return
+ viewModelScope.launch {
+ val export = tipCodeExporter.export(card, format)
+ if (export == null) {
+ BottomBarManager.showMessage(
+ title = resources.getString(R.string.error_title_tipCardExportFailed),
+ message = resources.getString(R.string.error_description_tipCardExportFailed),
+ )
+ return@launch
+ }
+ shareable.present(
+ Shareable.TipCodeImage(
+ export = export,
+ title = resources.getString(R.string.title_shareTipCode),
+ )
+ )
+ }
+ }
+}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt
new file mode 100644
index 0000000000..56946d8594
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt
@@ -0,0 +1,78 @@
+package com.flipcash.app.menu.internal
+
+import android.content.ClipboardManager
+import androidx.lifecycle.ViewModel
+import com.flipcash.app.bills.share.TipCodePreviewCache
+import com.flipcash.app.core.extensions.setText
+import com.flipcash.app.core.tipping.TipCardOwner
+import com.flipcash.app.core.toast.SystemToastController
+import com.flipcash.app.core.util.Linkify
+import com.flipcash.app.shareable.ShareSheetController
+import com.flipcash.app.shareable.Shareable
+import com.flipcash.features.menu.R
+import com.flipcash.services.models.UserProfile
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.tipping.TippingCoordinator
+import com.getcode.util.resources.ResourceHelper
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * What the share sheet's rows do for the viewer's own profile. The sheet only chooses; the
+ * navigation host runs one of these once the sheet has finished leaving.
+ */
+@HiltViewModel
+internal class ProfileShareViewModel @Inject constructor(
+ private val userManager: UserManager,
+ private val tippingCoordinator: TippingCoordinator,
+ private val tipCodePreviewCache: TipCodePreviewCache,
+ private val shareable: ShareSheetController,
+ private val clipboardManager: ClipboardManager,
+ private val toastController: SystemToastController,
+ private val resources: ResourceHelper,
+) : ViewModel() {
+
+ // Not viewModelScope: the actions run after the sheet has finished leaving, and this view model
+ // is cleared with the sheet's entry before then. Each action is short and holds nothing but
+ // singletons, so there is nothing to leak by letting it finish.
+ private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ /** The profile being shared, read once when the sheet opens. */
+ val profile: UserProfile?
+ get() = userManager.state.value.userProfile
+
+ /** The link a profile is shared by. Null only when there is no signed-in user to address. */
+ private val link: String?
+ get() = tippingCoordinator.currentUserId?.let { userId ->
+ Linkify.tipcard(TipCardOwner.preferringUsername(profile?.username, userId))
+ }
+
+ /** Hands the profile link, with its card preview if ready, to the system share sheet. */
+ fun share() {
+ val userId = tippingCoordinator.currentUserId ?: return
+ // Attach the eagerly-rendered preview if it's ready; null shares the URL alone.
+ actionScope.launch {
+ shareable.present(
+ Shareable.Profile(
+ userId = userId,
+ displayName = profile?.displayName,
+ username = profile?.username,
+ preview = tipCodePreviewCache.get(userId),
+ )
+ )
+ }
+ }
+
+ fun copyLink() {
+ val link = link ?: return
+ clipboardManager.setText(
+ text = link,
+ label = resources.getString(R.string.title_clipboardLabelTipCardLink),
+ )
+ toastController.showToast(R.string.action_copied, replacePrevious = true)
+ }
+}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt
deleted file mode 100644
index bfafd690a3..0000000000
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import com.flipcash.app.core.ui.onboarding.TutorialItem
-import com.flipcash.services.models.UserProfile
-
-/**
- * The "Finish Your Profile" checklist for the "You" tab.
- *
- * Null while the profile is unresolved, so the card is never drawn against a guess — an account
- * that already has a photo would otherwise flash an outstanding step on the way in. Null again once
- * every step is done: a checklist with nothing left to do is just a row of ticks.
- *
- * Every step reads straight off the profile, so a step completed elsewhere — My Account's own
- * Minimum Tip row, say — closes here too. An account that already has a name sees that step ticked
- * rather than dropped, so the list keeps its length until the whole thing goes away.
- */
-internal fun profileTutorialItems(profile: UserProfile?): List? {
- profile ?: return null
- val items = listOf(
- // The same test the tab uses to choose the claimed card over the blurred stand-in.
- TutorialItem.DisplayName(isCompleted = profile.displayName.isNotEmpty()),
- TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null),
- TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null),
- )
- return items.takeUnless { steps -> steps.all { it.isCompleted } }
-}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt
deleted file mode 100644
index 4dd06b4f43..0000000000
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationSpec
-import androidx.compose.animation.core.spring
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Stable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import kotlin.math.sign
-
-/**
- * The "You" tab's full-screen state, as one continuous 0..1 progress rather than a boolean.
- *
- * Everything the expansion touches — the card's width, how far it has travelled out of its slot,
- * the page fading away beneath it, the Close row — is a reading of [progress], so a gesture can
- * park the whole page part-way through the transition and hand it back where it found it. The tap
- * affordances just drive the same progress to an end with a spring.
- *
- * [isExpanded] is the *intent*, not the position: it flips the moment a direction is committed to
- * (tap, or the release of a swipe) and stays put for the length of a drag, so the tab bar and the
- * list's scrolling don't flicker in and out as the finger crosses the half-way mark.
- */
-@Stable
-internal class TipCardExpansion(private val scope: CoroutineScope) {
-
- private val animatable = Animatable(0f)
-
- private val overdragAnimatable = Animatable(0f)
-
- /** 0 at rest, 1 full screen; anywhere in between while dragging or springing. */
- val progress: Float get() = animatable.value
-
- /**
- * How far a downward drag has pushed the card past full screen, in the same units as
- * [progress] — resisted by [OverdragResistance], and deliberately not part of the progress
- * itself. Down is where expanding already put the card, so there is nothing there to scrub:
- * only the card's position gives, and its size and everything reading the progress hold still.
- */
- val overdrag: Float get() = overdragAnimatable.value
-
- /**
- * The part of the drag that has run off the end, before resistance. Kept unresisted so the
- * finger has to give all of it back before the card starts moving home again — the alternative
- * is a card that leaves the end as soon as the finger turns around, a quarter of the way behind
- * where it was pushed.
- */
- private var overflow = 0f
-
- /** The recogniser's slack, waiting for the first delta to hand it back to. See [startDrag]. */
- private var pendingSlack = 0f
-
- var isExpanded by mutableStateOf(false)
- private set
-
- fun toggle() {
- if (isExpanded) collapse() else expand()
- }
-
- fun expand(velocity: Float = 0f, spec: AnimationSpec = ExpansionSpring) {
- isExpanded = true
- animate(target = 1f, velocity = velocity, spec = spec)
- }
-
- fun collapse(velocity: Float = 0f) {
- isExpanded = false
- animate(target = 0f, velocity = velocity, spec = ExpansionSpring)
- }
-
- /**
- * Opens a drag, with the [slack] the recogniser swallowed getting there — touch slop, in
- * progress units. It is handed back on the first delta so the card picks up from where it
- * stands rather than jumping that distance the moment the gesture is recognised. iOS hands back
- * its own 10pt activation distance for the same reason.
- */
- fun startDrag(slack: Float) {
- pendingSlack = slack
- overflow = 0f
- }
-
- /**
- * Moves the card by [deltaProgress] under the finger, cancelling whatever spring was running.
- *
- * Pulled up past its slot the card simply stops: it has arrived, and a finger that has run out
- * of card should feel the end. Pushed down past full screen it gives a little instead — see
- * [overdrag] — because there the card has nowhere to go but is still being asked to move.
- */
- fun dragBy(deltaProgress: Float) {
- val delta = deltaProgress + pendingSlack * sign(deltaProgress)
- if (deltaProgress != 0f) pendingSlack = 0f
- scope.launch {
- val moved = animatable.value + overflow + delta
- animatable.snapTo(moved.coerceIn(0f, 1f))
- overflow = (moved - 1f).coerceAtLeast(0f)
- overdragAnimatable.snapTo(overflow * OverdragResistance)
- }
- }
-
- /** Springs to whichever end [settlesExpanded] picks, carrying the release velocity into it. */
- fun settle(velocity: Float, flingThreshold: Float) {
- overflow = 0f
- pendingSlack = 0f
-
- val staysExpanded = settlesExpanded(animatable.value, velocity, flingThreshold)
- // A pull that fell short has a short way back, so it takes the short spring; one that
- // committed is running the whole transition and takes the transition's own.
- val spec = if (staysExpanded) ReturnSpring else ExpansionSpring
-
- scope.launch { overdragAnimatable.animateTo(0f, spec) }
- if (staysExpanded) expand(velocity, spec) else collapse(velocity)
- }
-
- private fun animate(target: Float, velocity: Float, spec: AnimationSpec) {
- scope.launch {
- animatable.animateTo(
- targetValue = target,
- animationSpec = spec,
- initialVelocity = velocity.coerceIn(-MaxSettleVelocity, MaxSettleVelocity),
- )
- }
- }
-}
-
-@Composable
-internal fun rememberTipCardExpansion(): TipCardExpansion {
- val scope = rememberCoroutineScope()
- return remember(scope) { TipCardExpansion(scope) }
-}
-
-/**
- * Where a released swipe lands: a flick wins on velocity alone, however far it got, and anything
- * slower goes on whether the pull covered enough of the way home ([CollapseThreshold]).
- *
- * [velocity] and [flingThreshold] are both in progress-per-second, so the caller divides the
- * gesture's pixel velocity by the distance the card actually has left to travel — a flick means
- * the same thing on a tall display as on a short one.
- */
-internal fun settlesExpanded(progress: Float, velocity: Float, flingThreshold: Float): Boolean =
- when {
- velocity <= -flingThreshold -> false
- velocity >= flingThreshold -> true
- else -> progress > 1f - CollapseThreshold
- }
-
-/**
- * How much of the card's travel home a pull has to cover for the release to finish the job, as
- * iOS's `collapseThreshold` does.
- *
- * Well short of half, because the two ends are not equally likely: the card is only ever dragged
- * from one of them, by someone who has already decided to put it away. Asking for half of the
- * display's height before that counts made the card feel like it was resisting.
- */
-private const val CollapseThreshold = 0.3f
-
-/**
- * The fraction of a downward drag the card follows past full screen. Down is where expanding
- * already took the card, so the pull has nowhere to take it and gives only enough to show that the
- * drag is being felt. iOS's `overdragResistance`.
- */
-private const val OverdragResistance = 0.25f
-
-/**
- * How far a swipe must be moving at release to decide the outcome on its own, regardless of how far
- * it travelled. Material's own swipe threshold — low enough that a flick of the card is enough,
- * high enough that letting go of a slow drag doesn't count as one.
- */
-internal val MinFlingVelocity = 125.dp
-
-/**
- * The floor on the drag's travel distance, for the frames before the card's slot has been measured
- * (and for the pathological case of a display too short to move the card at all). Without it a drag
- * would divide by a travel of zero and snap the card shut on the first pixel.
- */
-internal val MinDragTravel = 120.dp
-
-/**
- * The whole expansion — card size, card position, the content sliding away, the Close row — runs on
- * one spring, as iOS does: `.spring(response: 0.45, dampingFraction: 0.85)`. SwiftUI's `response` is
- * the undamped period, so the equivalent Compose stiffness is `(2 * PI / 0.45) ^ 2`.
- */
-private val ExpansionSpring = spring(dampingRatio = 0.85f, stiffness = 195f)
-
-/**
- * Puts a pull that fell short back where it started — iOS's `settle`, `.spring(response: 0.3,
- * dampingFraction: 0.85)`. Shorter than [ExpansionSpring] because the card has barely moved, and
- * spending the full transition on a few dp of travel reads as a stall rather than a return.
- */
-private val ReturnSpring = spring(dampingRatio = 0.85f, stiffness = 439f)
-
-/**
- * The ceiling on the release velocity a settle carries into its spring, in progress-per-second —
- * which is really a ceiling on how far the card bounces past the end it landed on, since that is
- * what an underdamped spring does with speed it is handed.
- *
- * Not clamped to no overshoot at all: the bounce is the card arriving somewhere and settling into
- * it, and a flick that stops dead reads as a dropped frame. What it can't be is the ~18% of the
- * travel an uncapped flick paid for — a card lifted clear out of its slot and up under the status
- * bar, taking the page with it. This cap costs at most 5.6% (measured, across every release point),
- * which is a bounce of about 10dp. [TipCardOvershootTest] holds both halves: some, and not much.
- *
- * It also floors how quickly a settle can be over. Uncapped, the hardest flick collapsed the card
- * in three frames, which is less a transition than a cut.
- *
- * iOS never needs the cap because it never carries the gesture's velocity into the spring at all:
- * its settle starts from a standstill, where this damping ratio overshoots half a percent.
- */
-private const val MaxSettleVelocity = 4f
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt
new file mode 100644
index 0000000000..3fb355c78c
--- /dev/null
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt
@@ -0,0 +1,16 @@
+package com.flipcash.app.menu.internal.components
+
+/**
+ * How close the account is to being allowed a `@handle`. Null in the state once a handle the user
+ * picked exists; otherwise it decides whether the claim link opens the entry screen or states the
+ * minimum balance.
+ */
+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
+}
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
deleted file mode 100644
index 8fd9e69558..0000000000
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-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/JoinedLabelTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt
new file mode 100644
index 0000000000..96cb8967c3
--- /dev/null
+++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt
@@ -0,0 +1,36 @@
+package com.flipcash.app.menu.internal
+
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.time.ZoneOffset
+import java.util.Locale
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.time.Instant
+
+// Robolectric because the pattern comes from android.text.format.DateFormat, which a plain JVM
+// test only has as a stub.
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class JoinedLabelTest {
+
+ // 2026-10-06T12:00:00Z
+ private val joinedAt = Instant.fromEpochSeconds(1_791_288_000)
+
+ @Test
+ fun `joined is the full month and year`() {
+ assertEquals("October 2026", joinedLabel(joinedAt, Locale.US, ZoneOffset.UTC))
+ }
+
+ @Test
+ fun `joined follows the locale`() {
+ assertEquals("octubre de 2026", joinedLabel(joinedAt, Locale.forLanguageTag("es"), ZoneOffset.UTC))
+ }
+
+ @Test
+ fun `no join date means no label`() {
+ assertNull(joinedLabel(null, Locale.US, ZoneOffset.UTC))
+ }
+}
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt
new file mode 100644
index 0000000000..4609a8179a
--- /dev/null
+++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt
@@ -0,0 +1,36 @@
+package com.flipcash.app.menu.internal
+
+import com.flipcash.app.core.AppRoute
+import com.flipcash.services.models.UserProfile
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class OwnProfileStateTest {
+
+ private val reduce = MenuScreenViewModel.updateStateForEvent
+
+ @Test
+ fun `the resolved fee lands in state and clears when it goes away`() {
+ val withFee = reduce(MenuScreenViewModel.Event.OnMinimumToChatChanged("$1.25"))(MenuScreenViewModel.State())
+ assertEquals("$1.25", withFee.minimumToChat)
+
+ val cleared = reduce(MenuScreenViewModel.Event.OnMinimumToChatChanged(null))(withFee)
+ assertNull(cleared.minimumToChat)
+ }
+
+ @Test
+ fun `a named profile carries its joined label`() {
+ val named = MenuScreenViewModel.ProfileState.Named(UserProfile.Empty, joined = "October 2026")
+ val state = reduce(MenuScreenViewModel.Event.OnProfileStateChanged(named))(MenuScreenViewModel.State())
+ assertEquals(named, state.profileState)
+ }
+
+ @Test
+ fun `the share sheet opens only for a named profile`() {
+ val named = MenuScreenViewModel.ProfileState.Named(UserProfile.Empty, joined = null)
+ assertEquals(AppRoute.Menu.ShareProfile, shareProfileRoute(named))
+ assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unknown))
+ assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unclaimed(placeholder = null)))
+ }
+}
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt
deleted file mode 100644
index 2a4766400d..0000000000
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import com.flipcash.app.core.ui.onboarding.TutorialItem
-import com.flipcash.services.models.UserProfile
-import com.flipcash.services.models.chat.MediaItem
-import com.getcode.opencode.model.financial.Fiat
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-
-class ProfileTutorialTest {
-
- // MediaItem is a plain data class over a rendition list, so an empty one stands in for
- // "a picture is set" without needing a mocking library in this module.
- private val anyPicture = MediaItem(renditions = emptyList())
-
- private fun profile(
- name: String = "Brandon",
- picture: MediaItem? = null,
- minimumTip: Fiat? = null,
- ) = UserProfile(
- displayName = name,
- socialAccounts = emptyList(),
- phoneNumber = null,
- email = null,
- profilePicture = picture,
- minDmChatInitFee = minimumTip,
- )
-
- @Test
- fun `an unresolved profile has no checklist`() {
- assertNull(profileTutorialItems(profile = null))
- }
-
- @Test
- fun `the steps run display name, picture, minimum tip`() {
- val items = profileTutorialItems(profile(name = ""))
- assertEquals(
- listOf(
- TutorialItem.DisplayName::class,
- TutorialItem.ProfilePicture::class,
- TutorialItem.MinimumTip::class,
- ),
- items?.map { it::class },
- )
- }
-
- @Test
- fun `a nameless profile leaves every step outstanding`() {
- val items = profileTutorialItems(profile(name = ""))
- assertTrue(items!!.none { it.isCompleted })
- }
-
- @Test
- fun `a named profile shows the display name step ticked rather than dropping it`() {
- val items = profileTutorialItems(profile())
- assertEquals(3, items?.size)
- assertEquals(1, items!!.count { it.isCompleted })
- assertTrue(items.first { it is TutorialItem.DisplayName }.isCompleted)
- }
-
- @Test
- fun `a picture completes the picture step`() {
- val items = profileTutorialItems(profile(picture = anyPicture))
- assertTrue(items!!.first { it is TutorialItem.ProfilePicture }.isCompleted)
- assertFalse(items.first { it is TutorialItem.MinimumTip }.isCompleted)
- }
-
- @Test
- fun `a saved minimum tip completes the minimum tip step`() {
- val items = profileTutorialItems(profile(minimumTip = Fiat(1.0)))
- assertTrue(items!!.first { it is TutorialItem.MinimumTip }.isCompleted)
- assertFalse(items.first { it is TutorialItem.ProfilePicture }.isCompleted)
- }
-
- @Test
- fun `a name, a picture and a minimum tip take the checklist away entirely`() {
- assertNull(profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0))))
- }
-
- @Test
- fun `a nameless profile keeps the checklist even with the other two steps done`() {
- val items = profileTutorialItems(
- profile(name = "", picture = anyPicture, minimumTip = Fiat(1.0))
- )
- assertEquals(listOf(false, true, true), items?.map { it.isCompleted })
- }
-}
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt
deleted file mode 100644
index 37bd34cac0..0000000000
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-/**
- * A drag has to move the card by every pixel the finger covered.
- *
- * Touch events arrive in batches, one batch per frame, so a slow frame hands the drag several
- * deltas in a row before any of them can be applied — which is exactly when the animation is
- * already struggling and the card can least afford to lose ground. Each delta is applied in a
- * coroutine of its own, and `Animatable` serialises those through a mutex that cancels whatever it
- * finds running, so "the batch adds up" is a property worth holding on to rather than assuming: a
- * drag that read its base value and wrote it back across a cancellation point would lose the
- * cancelled delta, and the card would stick under the finger and then lurch.
- *
- * The dispatcher here queues rather than running inline, which is what reproduces the batch.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class TipCardDragTest {
-
- @Test
- fun `a delta a frame moves the card by all of them`() = runTest {
- val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler)))
-
- repeat(5) {
- expansion.dragBy(0.1f)
- advanceUntilIdle()
- }
-
- assertEquals(0.5f, expansion.progress, Tolerance)
- }
-
- @Test
- fun `five deltas in one frame's batch move the card by all of them`() = runTest {
- val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler)))
-
- repeat(5) { expansion.dragBy(0.1f) }
- advanceUntilIdle()
-
- assertEquals(0.5f, expansion.progress, Tolerance)
- }
-
- @Test
- fun `a drag past either end stops there`() = runTest {
- val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler)))
-
- repeat(20) { expansion.dragBy(0.1f) }
- advanceUntilIdle()
- assertEquals(1f, expansion.progress, Tolerance)
-
- repeat(40) { expansion.dragBy(-0.1f) }
- advanceUntilIdle()
- assertEquals(0f, expansion.progress, Tolerance)
- }
-
- @Test
- fun `pushing the card down past full screen gives, a quarter of the way`() = runTest {
- val expansion = expandedCard()
-
- expansion.dragBy(0.4f)
- advanceUntilIdle()
-
- assertEquals(1f, expansion.progress, Tolerance)
- assertEquals(0.1f, expansion.overdrag, Tolerance)
- }
-
- @Test
- fun `a finger that turns around gives back all it pushed before the card moves`() = runTest {
- val expansion = expandedCard()
-
- expansion.dragBy(0.4f)
- expansion.dragBy(-0.3f)
- advanceUntilIdle()
-
- // Still home, a quarter of what is left of the push behind it. Unwinding the overdrag at
- // its resisted size instead would leave the card moving home while the finger is still
- // below where it pushed from.
- assertEquals(1f, expansion.progress, Tolerance)
- assertEquals(0.025f, expansion.overdrag, Tolerance)
-
- expansion.dragBy(-0.2f)
- advanceUntilIdle()
-
- assertEquals(0.9f, expansion.progress, Tolerance)
- assertEquals(0f, expansion.overdrag, Tolerance)
- }
-
- @Test
- fun `the slack the recogniser swallowed is handed back on the first delta only`() = runTest {
- val expansion = expandedCard()
-
- expansion.startDrag(slack = 0.05f)
- expansion.dragBy(-0.1f)
- advanceUntilIdle()
- assertEquals(0.85f, expansion.progress, Tolerance)
-
- expansion.dragBy(-0.1f)
- advanceUntilIdle()
- assertEquals(0.75f, expansion.progress, Tolerance)
- }
-
- /** A card dragged all the way out, which is the only state the swipe exists in. */
- private suspend fun TestScope.expandedCard(): TipCardExpansion {
- val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler)))
- expansion.dragBy(1f)
- advanceUntilIdle()
- return expansion
- }
-}
-
-private const val Tolerance = 1e-4f
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt
deleted file mode 100644
index 912d16df15..0000000000
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import androidx.compose.runtime.BroadcastFrameClock
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertTrue
-
-/**
- * A released swipe has to bounce, and not much.
- *
- * The spring carries the release velocity into the settle, and a flick can hand it far more speed
- * than the spring would ever have picked up on its own — enough that it sails a fifth of the travel
- * past the end it was aiming for, which on the resting end lifts the card clear out of its slot and
- * up under the status bar. Capping that velocity is what keeps the bounce to a settle; the cap is
- * only meaningful if it leaves a bounce there at all, so both bounds are held here. iOS meets
- * neither, having no swipe: its spring always starts from a standstill.
- *
- * Driven a frame at a time off a [BroadcastFrameClock], so these are the values the card is
- * actually drawn at, not a reading of where the animation was aimed.
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-class TipCardOvershootTest {
-
- @Test
- fun `no flick bounces the card far past full screen`() = runTest {
- for (from in ReleasePoints) {
- val overshoot = flingTravel(from, velocity = HardFlick).endInclusive - 1f
-
- assertTrue(
- overshoot <= MaxBounce,
- "released at $from, the card bounced $overshoot past full screen",
- )
- }
- }
-
- @Test
- fun `no flick lifts the card far out of its slot`() = runTest {
- for (from in ReleasePoints) {
- val overshoot = -flingTravel(from, velocity = -HardFlick).start
-
- assertTrue(
- overshoot <= MaxBounce,
- "released at $from, the card bounced $overshoot above its resting place",
- )
- }
- }
-
- @Test
- fun `a flick does still bounce`() = runTest {
- assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive > 1f + Tolerance)
- assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start < -Tolerance)
- }
-
- @Test
- fun `a flick still comes to rest where it was aimed`() = runTest {
- assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive >= 1f - Tolerance)
- assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start <= Tolerance)
- }
-}
-
-/** Every tenth of the way out, since where a flick is released changes how far it carries past. */
-private val ReleasePoints = (1..9).map { it / 10f }
-
-/**
- * The bounce budget, as a fraction of the card's travel — about 10dp of settle on a 1080x2424
- * display, against the 33dp an uncapped flick spent.
- */
-private const val MaxBounce = 0.06f
-
-/**
- * Settles the card from [from] at [velocity] and reports the range it was drawn across on the way
- * to rest — which is wider than the range between its two ends if the spring overshoots one.
- */
-private suspend fun TestScope.flingTravel(
- from: Float,
- velocity: Float,
-): ClosedFloatingPointRange {
- val clock = BroadcastFrameClock()
- val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler) + clock))
-
- expansion.dragBy(from)
- advanceUntilIdle()
- expansion.settle(velocity, FlingThreshold)
- advanceUntilIdle()
-
- var lowest = expansion.progress
- var highest = lowest
- repeat(FramesToSettle) { frame ->
- clock.sendFrame((frame + 1) * NanosPerFrame)
- advanceUntilIdle()
- lowest = minOf(lowest, expansion.progress)
- highest = maxOf(highest, expansion.progress)
- }
- return lowest..highest
-}
-
-/**
- * Android's maximum fling velocity (8000 px/s) over the ~560 px the card travels between its slot
- * and the middle of a 1080x2424 display — the fastest a release can ever hand the spring.
- */
-private const val HardFlick = 14.2f
-
-/** [MinFlingVelocity] over that same travel. */
-private const val FlingThreshold = 0.58f
-
-private const val NanosPerFrame = 16_666_667L
-
-/** Two seconds of frames — several times over what this spring needs to come to rest. */
-private const val FramesToSettle = 120
-
-private const val Tolerance = 1e-3f
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt
deleted file mode 100644
index 1a186781c0..0000000000
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-/**
- * Where a swipe on the expanded tip card lands when the finger lifts.
- *
- * The gesture drags the card back up towards its slot on the "You" tab, so the release has to
- * decide between putting it away and springing it back out — and it has to agree with what the
- * hand just did. A flick counts on its own, however short; anything slower goes on how much of the
- * way home the pull covered. iOS asks the same two questions of its own release.
- */
-class TipCardSettleTest {
-
- /** A velocity well inside the threshold: a drag that was drifting, not thrown. */
- private val slow = FlingThreshold / 4
-
- @Test
- fun `a slow release goes on how much of the way home the pull covered`() {
- assertFalse(settle(progress = 0.2f, velocity = slow))
- assertFalse(settle(progress = 0.69f, velocity = -slow))
- assertTrue(settle(progress = 0.71f, velocity = -slow))
- assertTrue(settle(progress = 0.9f, velocity = slow))
- }
-
- @Test
- fun `a pull that covered under a third of the way home springs back`() {
- // Barely started, and nothing in the release says otherwise: the card belongs where it was.
- assertTrue(settle(progress = 0.71f, velocity = 0f))
- }
-
- @Test
- fun `a pull that covered a third of the way home finishes the job`() {
- // Short of half the travel, deliberately. The card is only ever dragged from one end, by
- // someone who has already decided to put it away.
- assertFalse(settle(progress = 0.7f, velocity = 0f))
- assertFalse(settle(progress = 0.5f, velocity = 0f))
- }
-
- @Test
- fun `an upward flick closes the card however little of it was dragged`() {
- // The whole point of a flick: barely moved, but thrown at the slot it came out of.
- assertFalse(settle(progress = 0.98f, velocity = -FlingThreshold))
- }
-
- @Test
- fun `a downward flick puts the card back even from almost closed`() {
- // Changed their mind mid-swipe — the card belongs back on screen, not shut.
- assertTrue(settle(progress = 0.02f, velocity = FlingThreshold))
- }
-
- @Test
- fun `a motionless card is left where the tap put it`() {
- assertTrue(settle(progress = 1f, velocity = 0f))
- assertFalse(settle(progress = 0f, velocity = 0f))
- }
-
- private fun settle(progress: Float, velocity: Float) =
- settlesExpanded(progress, velocity, FlingThreshold)
-
- private companion object {
- /** Stand-in for the density-derived threshold the screen computes; the units cancel. */
- const val FlingThreshold = 2f
- }
-}
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt
deleted file mode 100644
index aebd2666f8..0000000000
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.flipcash.app.menu.internal
-
-import com.flipcash.app.core.bill.Scannable
-import com.flipcash.services.models.UserProfile
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNull
-
-/**
- * The "You" tab used to model the tip card as a nullable, which conflated "still resolving" with
- * "this account has no display name". The header drew nothing for either, so a nameless account got
- * a tab with no card, no prompt and no way to claim one. The three states must stay distinct — and
- * only a claimed card is shareable, linkable or expandable.
- */
-class TipCardStateTest {
-
- private val aCard = Scannable.TipCard(data = emptyList(), user = UserProfile.Empty)
-
- @Test
- fun `nothing is shareable while the card is still resolving`() {
- val state = MenuScreenViewModel.State()
- assertEquals(MenuScreenViewModel.TipCardState.Unknown, state.tipCardState)
- assertNull(state.tipCard)
- assertNull(state.tipLink)
- }
-
- @Test
- fun `an unclaimed stand-in is never treated as the viewer's card`() {
- val state = MenuScreenViewModel.State(
- tipCardState = MenuScreenViewModel.TipCardState.Unclaimed(placeholder = aCard),
- )
- // Drawn blurred behind the claim prompt, but it is not a card the viewer owns: no share,
- // no download, no link, and the caller's `canExpand` gate stays closed.
- assertNull(state.tipCard)
- assertNull(state.tipLink)
- }
-
- @Test
- fun `a claimed card carries its shareable link`() {
- val state = MenuScreenViewModel.State(
- tipCardState = MenuScreenViewModel.TipCardState.Claimed(aCard, "https://flipcash.com/x"),
- )
- assertEquals(aCard, state.tipCard)
- assertEquals("https://flipcash.com/x", state.tipLink)
- }
-}
diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts
index 519277c41c..4ebd4e40b9 100644
--- a/apps/flipcash/features/messenger/build.gradle.kts
+++ b/apps/flipcash/features/messenger/build.gradle.kts
@@ -15,6 +15,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
implementation(project(":apps:flipcash:shared:amount-entry"))
+ implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:funding"))
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt
index 0ca9807bba..644d61461c 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt
@@ -6,17 +6,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NotificationsOff
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -29,7 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ViewerState
-import com.getcode.theme.CodeTheme
+import com.flipcash.shared.common.ui.profile.ProfileStatusChip
/**
* What the chat's mute currently is, drawn under the title on both chat settings surfaces —
@@ -103,29 +95,9 @@ private fun Chip(
label: String,
modifier: Modifier = Modifier,
) {
- Row(
- modifier = modifier
- .background(
- color = CodeTheme.colors.warning.copy(alpha = 0.1f),
- shape = CircleShape,
- )
- .padding(
- horizontal = CodeTheme.dimens.staticGrid.x2,
- vertical = CodeTheme.dimens.staticGrid.x1,
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1),
- ) {
- Icon(
- modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3),
- imageVector = Icons.Outlined.NotificationsOff,
- contentDescription = null,
- tint = CodeTheme.colors.warning,
- )
- Text(
- text = label,
- style = CodeTheme.typography.textSmall,
- color = CodeTheme.colors.warning,
- )
- }
+ ProfileStatusChip(
+ icon = Icons.Outlined.NotificationsOff,
+ text = label,
+ modifier = modifier,
+ )
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 5ad26f11ec..ee39719123 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -1365,7 +1365,7 @@ internal class ChatViewModel @Inject constructor(
openingTipRecipientFlow
.flatMapLatest { recipient ->
if (recipient == null) flowOf(null)
- else tipPaymentDelegate.minimumToOpenDmWith(recipient.profile)
+ else tipPaymentDelegate.startChattingFee(recipient.profile)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt
index ad857a7b7b..a06c304f51 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt
@@ -111,7 +111,7 @@ class ChatEventStallTest {
every { exchange.preferredRate } returns Rate.oneToOne
every { transactionController.limits } returns MutableStateFlow(null)
every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0)
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
coEvery {
verifiedFiatCalculator.compute(any(), any(), any(), any(), any())
} returns Result.success(verifiedFiat)
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt
index f7c6fb8bf2..5a2dfd4c17 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt
@@ -119,7 +119,7 @@ class ChatGroupAnalyticsTest {
every { exchange.preferredRate } returns Rate.oneToOne
every { transactionController.limits } returns MutableStateFlow(null)
every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0)
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
// The gate's rule currency is looked up by mint; the name it resolves to is not under test.
coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException())
every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap())
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
index 8ef5e29cf6..1928a9f34d 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt
@@ -116,7 +116,7 @@ class ChatGroupCashLinkTest {
coEvery { tokenCoordinator.hasGiveableBalance(any()) } returns true
coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException())
every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap())
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
coEvery {
verifiedFiatCalculator.compute(any(), any(), any(), any(), any())
} returns Result.success(verifiedFiat)
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt
index acea67ae51..a41a81b7fd 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt
@@ -78,7 +78,7 @@ class ChatMentionPickerTest {
every { transactionController.limits } returns MutableStateFlow(null)
every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0)
every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap())
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException())
coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, eric)
coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.success(mockk(relaxed = true))
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt
index c1c9be864b..5a8e20a6a0 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt
@@ -73,7 +73,7 @@ class ChatOpenTranscriptTest {
every { userManager.accountCluster } returns mockk(relaxed = true)
every { exchange.preferredRate } returns Rate.oneToOne
every { transactionController.limits } returns MutableStateFlow(null)
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
coEvery { chatCoordinator.generateChatId(userId) } returns Result.success(chatId)
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt
index 663a96282d..db1b8c418b 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt
@@ -111,7 +111,7 @@ class ChatSendFailureAnalyticsTest {
every { exchange.preferredRate } returns Rate.oneToOne
every { transactionController.limits } returns MutableStateFlow(null)
every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0)
- every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
+ every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null)
coEvery {
verifiedFiatCalculator.compute(any(), any(), any(), any(), any())
} returns Result.success(verifiedFiat)
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 da96818782..cef3125653 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
@@ -56,6 +56,12 @@ val LocalTipCardColor = staticCompositionLocalOf { TipCardOpaqueFallback }
*/
val TipCardOpaqueFallback = Color(0xFF1A1A1C)
+/**
+ * The card's flattened tone on a static backdrop (no camera behind it), as Figma flattens it.
+ * Pair with [LocalTipCardBaseAlpha] `1f` for an opaque card.
+ */
+val TipCardFlattened = Color(0xFF101011)
+
/**
* The card's height-to-width proportion, from Figma (269 x 333 dp).
*/
diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts
index 87527e41bd..62850f8aa6 100644
--- a/apps/flipcash/shared/common-ui/build.gradle.kts
+++ b/apps/flipcash/shared/common-ui/build.gradle.kts
@@ -16,6 +16,7 @@ dependencies {
implementation(project(":libs:datetime"))
implementation(project(":apps:flipcash:shared:theme"))
+ implementation(libs.compose.material)
api(libs.compose.material.icons.extended)
testImplementation(libs.robolectric)
}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
index 287e342fb7..9af8fde466 100644
--- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt
@@ -158,18 +158,21 @@ fun ContactAvatar(
)
}
+/**
+ * The blob-backed picture surface behind every avatar, and behind the profile cover, which is why
+ * it takes the [background] shown while there is nothing to draw.
+ */
@Composable
-private fun ProfileAvatar(
+internal fun ProfileAvatar(
image: MediaItem?,
access: BlobAccessContext,
modifier: Modifier,
blurred: Boolean = false,
+ background: Brush = Brush.linearGradient(CodeTheme.colors.contactAvatar.colors),
fallback: @Composable BoxWithConstraintsScope.() -> Unit,
) {
BoxWithConstraints(
- modifier = modifier.background(
- Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)
- )
+ modifier = modifier.background(background)
) {
// Decoded once for the whole avatar, not just the loading state: it is the placeholder
// while the rendition downloads, what stays on screen while a stale URL is re-minted, and
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt
new file mode 100644
index 0000000000..07b9ac798d
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt
@@ -0,0 +1,117 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.IosShare
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.theme.FlipcashThemeWrapper
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White10
+
+private val ActionHeight = 38.dp
+private val ActionIconSize = 22.dp
+private const val PressedAlpha = 0.7f
+
+/** A 38dp capsule with [text], for the actions that sit beside the avatar on a profile. */
+@Composable
+fun ProfileActionButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ActionCapsule(
+ onClick = onClick,
+ modifier = modifier.heightIn(min = ActionHeight),
+ contentPadding = CodeTheme.dimens.staticGrid.x4,
+ ) {
+ Text(
+ text = text,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+/** The 38×38 icon form of [ProfileActionButton]. [contentDescription] is what a screen reader says. */
+@Composable
+fun ProfileActionButton(
+ icon: ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ActionCapsule(
+ onClick = onClick,
+ modifier = modifier.size(ActionHeight),
+ contentPadding = 0.dp,
+ ) {
+ Icon(
+ modifier = Modifier.size(ActionIconSize),
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+@Composable
+private fun ActionCapsule(
+ onClick: () -> Unit,
+ modifier: Modifier,
+ contentPadding: Dp,
+ content: @Composable () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed by interactionSource.collectIsPressedAsState()
+ Box(
+ modifier = modifier
+ .alpha(if (pressed) PressedAlpha else 1f)
+ .clip(CircleShape)
+ .background(White10)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ role = Role.Button,
+ onClick = onClick,
+ )
+ .padding(horizontal = contentPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ content()
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileActionButtons() {
+ Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) {
+ ProfileActionButton(text = "Edit Profile", onClick = {})
+ ProfileActionButton(icon = Icons.Outlined.IosShare, contentDescription = "Share", onClick = {})
+ }
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt
new file mode 100644
index 0000000000..7362398616
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt
@@ -0,0 +1,50 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+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.services.models.chat.BlobAccessContext
+import com.flipcash.services.models.chat.MediaItem
+import com.flipcash.shared.common.ui.ProfileAvatar
+import com.getcode.theme.CodeTheme
+
+internal val ProfileCoverHeight = 214.dp
+
+/**
+ * The full-bleed picture at the top of a profile. It is drawn under the status bar, so the caller
+ * lays it out edge to edge and this does not pad for insets.
+ *
+ * The [image] is scaled to fill and clipped, with its BlurHash as the placeholder while the
+ * rendition downloads; with no picture the cover is the plain surface colour. Loading is
+ * [ProfileAvatar]'s — the same rendition, re-mint and cache path every avatar uses — and [access]
+ * names the surface the picture is read from, which is what authorizes re-minting it.
+ */
+@Composable
+fun ProfileCover(
+ image: MediaItem?,
+ access: BlobAccessContext,
+ modifier: Modifier = Modifier,
+) {
+ ProfileAvatar(
+ image = image,
+ access = access,
+ modifier = modifier
+ .fillMaxWidth()
+ .height(ProfileCoverHeight),
+ background = SolidColor(CodeTheme.colors.surfaceVariant),
+ fallback = { },
+ )
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileCover_NoPicture() {
+ ProfileCover(image = null, access = BlobAccessContext.Owned)
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt
new file mode 100644
index 0000000000..50d46b0750
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt
@@ -0,0 +1,155 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.compose.foundation.border
+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.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.IosShare
+import androidx.compose.material.icons.outlined.NotificationsOff
+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.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.services.models.chat.BlobAccessContext
+import com.flipcash.services.models.chat.MediaItem
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.getcode.theme.CodeTheme
+
+private val AvatarSize = 84.dp
+private val AvatarRing = 5.dp
+private val AvatarOverlap = 42.dp
+
+/**
+ * The top of a profile: [cover], an avatar overlapping it, the actions beside the avatar, and the
+ * subject's [title], [subtitle] and [body]. A null [title] (an account with no name yet) draws
+ * nothing in its place.
+ *
+ * It renders a subject, not a user — a person today, a group next — so callers map their model to
+ * these inputs. [avatar] receives the size, clip and ring to draw with, which keeps the avatar the
+ * caller's (a user's picture, a group's) while the overlap stays the header's. [underSubtitle]
+ * holds anything that belongs between the handle and the bio, such as a status chip.
+ *
+ * [cover] is laid out edge to edge from the top of its parent and is not padded for the status
+ * bar; the page gutter applies to everything below it.
+ */
+@Composable
+fun ProfileHeader(
+ cover: MediaItem?,
+ access: BlobAccessContext,
+ avatar: @Composable (Modifier) -> Unit,
+ title: String?,
+ subtitle: String?,
+ body: String?,
+ actions: @Composable RowScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ underSubtitle: @Composable () -> Unit = {},
+) {
+ val inset = CodeTheme.dimens.inset
+ Box(modifier = modifier.fillMaxWidth()) {
+ ProfileCover(image = cover, access = access)
+ Column(modifier = Modifier.padding(top = ProfileCoverHeight - AvatarOverlap)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = inset),
+ verticalAlignment = Alignment.Top,
+ ) {
+ avatar(
+ Modifier
+ .size(AvatarSize)
+ .clip(CircleShape)
+ .border(AvatarRing, CodeTheme.colors.background, CircleShape)
+ )
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .padding(top = AvatarOverlap + CodeTheme.dimens.staticGrid.x4),
+ horizontalArrangement = Arrangement.spacedBy(
+ CodeTheme.dimens.staticGrid.x3,
+ Alignment.End,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ content = actions,
+ )
+ }
+ // iOS sets the text 8pt below the action row.
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = inset)
+ .padding(top = CodeTheme.dimens.staticGrid.x2),
+ ) {
+ if (!title.isNullOrBlank()) {
+ Text(
+ text = title,
+ style = CodeTheme.typography.displaySmall,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+ if (!subtitle.isNullOrBlank()) {
+ Text(
+ text = subtitle,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
+ underSubtitle()
+ if (!body.isNullOrBlank()) {
+ Text(
+ modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x3),
+ text = body,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PreviewHeader(withBody: Boolean, withChip: Boolean) {
+ ProfileHeader(
+ cover = null,
+ access = BlobAccessContext.Owned,
+ avatar = { ContactAvatar(image = null, displayName = "Ada Lovelace", access = BlobAccessContext.Owned, modifier = it) },
+ title = "Ada Lovelace",
+ subtitle = "@ada",
+ body = "Analytical engines and poetry.".takeIf { withBody },
+ actions = {
+ ProfileActionButton(text = "Edit Profile", onClick = {})
+ ProfileActionButton(icon = Icons.Outlined.IosShare, contentDescription = "Share", onClick = {})
+ },
+ underSubtitle = {
+ if (withChip) {
+ ProfileStatusChip(
+ modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x2),
+ icon = Icons.Outlined.NotificationsOff,
+ text = "Muted",
+ )
+ }
+ },
+ )
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileHeader_NoCover_WithBio() = PreviewHeader(withBody = true, withChip = false)
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileHeader_NoCover_NoBio() = PreviewHeader(withBody = false, withChip = true)
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt
new file mode 100644
index 0000000000..234ad66682
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt
@@ -0,0 +1,98 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import com.flipcash.app.theme.FlipcashThemeWrapper
+import com.flipcash.shared.common.ui.R
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.ChoiceRow
+
+/**
+ * One choice in a [ProfileShareSheet]. The caller decides which rows exist, so a person's profile
+ * and a group's can offer different ones; [id] is how the caller recognises the row that comes
+ * back through `onRow`.
+ */
+data class ProfileShareRow(
+ val id: String,
+ @DrawableRes val icon: Int,
+ val label: String,
+)
+
+/**
+ * The content of a profile's share sheet: [title] over [subtitle] (who is being shared), then one
+ * row per entry in [rows].
+ *
+ * It only draws and reports the tap. Hosting it in a sheet, dismissing that sheet, and running the
+ * chosen action once the sheet has finished leaving are the caller's.
+ */
+@Composable
+fun ProfileShareSheet(
+ title: String,
+ subtitle: String,
+ rows: List,
+ onRow: (ProfileShareRow) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .navigationBarsPadding(),
+ ) {
+ AppBarWithTitle(
+ title = title,
+ titleAlignment = Alignment.CenterHorizontally,
+ )
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset),
+ text = subtitle,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(vertical = CodeTheme.dimens.grid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ rows.forEach { row ->
+ ChoiceRow(
+ label = row.label,
+ icon = row.icon,
+ onClick = { onRow(row) },
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileShareSheet() {
+ ProfileShareSheet(
+ title = "Share User Profile",
+ subtitle = "Ada Lovelace · @ada",
+ rows = listOf(
+ ProfileShareRow("share", R.drawable.ic_share_os, "Share Profile"),
+ ProfileShareRow("card", R.drawable.ic_file_download, "Show Profile Card"),
+ ProfileShareRow("copy", R.drawable.ic_copy, "Copy Link"),
+ ),
+ onRow = {},
+ )
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt
new file mode 100644
index 0000000000..1b3accfba7
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt
@@ -0,0 +1,102 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.compose.foundation.background
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+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.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.shared.common.ui.R
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White05
+
+private val StatsCardHeight = 84.dp
+private val LeadingStatWidth = 176.dp
+private val StatsDividerHeight = 48.dp
+
+/**
+ * The two facts under a profile header: what it costs to start a chat, and when the subject
+ * joined. Both arrive formatted — the caller owns currency and date formatting — and a null
+ * renders as a dash rather than collapsing the stat.
+ */
+@Composable
+fun ProfileStatsCard(
+ minimumToChat: String?,
+ joined: String?,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(StatsCardHeight)
+ .clip(CodeTheme.shapes.medium)
+ .background(White05),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Stat(
+ modifier = Modifier.width(LeadingStatWidth).fillMaxHeight(),
+ caption = stringResource(R.string.title_profileMinimumToChat),
+ value = minimumToChat,
+ )
+ Box(
+ modifier = Modifier
+ .width(CodeTheme.dimens.border)
+ .height(StatsDividerHeight)
+ .background(CodeTheme.colors.divider)
+ )
+ Stat(
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ caption = stringResource(R.string.title_profileDateJoined),
+ value = joined,
+ )
+ }
+}
+
+@Composable
+private fun Stat(
+ caption: String,
+ value: String?,
+ modifier: Modifier = Modifier,
+) {
+ // Top-aligned in the full card height, so both captions sit at the same inset from the top
+ // edge rather than floating to the middle.
+ Column(
+ modifier = modifier.padding(start = CodeTheme.dimens.staticGrid.x3, top = CodeTheme.dimens.staticGrid.x3),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1),
+ ) {
+ Text(
+ text = caption,
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ Text(
+ text = value ?: stringResource(R.string.text_profileStatEmpty),
+ style = CodeTheme.typography.textLarge,
+ color = CodeTheme.colors.textMain,
+ )
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileStatsCard() {
+ Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) {
+ ProfileStatsCard(minimumToChat = "$1.00", joined = "October 2026")
+ ProfileStatsCard(minimumToChat = null, joined = null)
+ }
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt
new file mode 100644
index 0000000000..0924d83365
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt
@@ -0,0 +1,69 @@
+package com.flipcash.shared.common.ui.profile
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Block
+import androidx.compose.material.icons.outlined.NotificationsOff
+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.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import com.flipcash.app.theme.FlipcashThemeWrapper
+import com.getcode.theme.CodeTheme
+
+/**
+ * A warning-tinted pill saying one thing about the viewer's relationship to the subject — Muted,
+ * Blocked. Amber at a tenth strength rather than a theme token, because what it has to sit on is
+ * whatever the host screen's background is; tinted rather than solid so it stays under the title.
+ */
+@Composable
+fun ProfileStatusChip(
+ icon: ImageVector,
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .background(
+ color = CodeTheme.colors.warning.copy(alpha = 0.1f),
+ shape = CircleShape,
+ )
+ .padding(
+ horizontal = CodeTheme.dimens.staticGrid.x2,
+ vertical = CodeTheme.dimens.staticGrid.x1,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1),
+ ) {
+ Icon(
+ modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3),
+ imageVector = icon,
+ contentDescription = null,
+ tint = CodeTheme.colors.warning,
+ )
+ Text(
+ text = text,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.warning,
+ )
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_ProfileStatusChip() {
+ Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2)) {
+ ProfileStatusChip(icon = Icons.Outlined.NotificationsOff, text = "Muted")
+ ProfileStatusChip(icon = Icons.Outlined.Block, text = "Blocked")
+ }
+}
diff --git a/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..51a0a846a3
--- /dev/null
+++ b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Minimum to Chat
+ Date Joined
+ —
+
diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt
index 3e2e87f94d..902dac083f 100644
--- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt
+++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt
@@ -139,19 +139,19 @@ class TipPaymentDelegate @Inject constructor(
* to the currency the sender is entering in. A missing rate for either side falls back to the
* preset rather than stating a floor in a currency the entry isn't using.
*/
- fun minimumToOpenDmWith(recipient: UserProfile?): Flow =
+ fun startChattingFee(recipient: UserProfile?): Flow =
combine(minTipAmount, exchange.observePreferredRate()) { preset, rate ->
recipient?.minDmChatInitFee?.inCurrency(rate.currency) ?: preset
}
/**
* The floor for a tip to [userId]. The fee buys the conversation, so only the tip that opens it
- * pays [minimumToOpenDmWith]; once a DM with them exists, every tip after it sits on the system
+ * pays [startChattingFee]; once a DM with them exists, every tip after it sits on the system
* [minTipAmount] like any other.
*/
fun minimumTipFor(userId: ID, recipient: UserProfile?): Flow = flow {
val opensTheChat = chatCoordinator.getChatId(userId).isFailure
- emitAll(if (opensTheChat) minimumToOpenDmWith(recipient) else minTipAmount)
+ emitAll(if (opensTheChat) startChattingFee(recipient) else minTipAmount)
}
/**
diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt
index 1d46276720..86d165c8e3 100644
--- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt
+++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt
@@ -155,19 +155,19 @@ class TipPaymentDelegateTest {
}
@Test
- fun `minimumToOpenDmWith is the recipient's own fee, not the regional preset`() = runTest {
+ fun `startChattingFee is the recipient's own fee, not the regional preset`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
val recipient = recipient(Fiat(5.0, CurrencyCode.USD))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
assertEquals(5.0, min!!.toDouble())
assertEquals(CurrencyCode.USD, min.currencyCode)
}
@Test
- fun `minimumToOpenDmWith converts the recipient's fee into the currency being entered`() = runTest {
+ fun `startChattingFee converts the recipient's fee into the currency being entered`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 2.0, currency = CurrencyCode.EUR))
// The fee was set in CAD at half a dollar to the dollar; the sender enters in EUR at two.
@@ -175,7 +175,7 @@ class TipPaymentDelegateTest {
every { exchange.rateFor(CurrencyCode.EUR) } returns Rate(fx = 2.0, currency = CurrencyCode.EUR)
val recipient = recipient(Fiat(10.0, CurrencyCode.CAD))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
// CAD 10 → USD 5 → EUR 10.
assertEquals(10.0, min!!.toDouble())
@@ -183,25 +183,25 @@ class TipPaymentDelegateTest {
}
@Test
- fun `minimumToOpenDmWith falls back to the preset when the recipient charges nothing`() = runTest {
+ fun `startChattingFee falls back to the preset when the recipient charges nothing`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
val delegate = buildDelegate()
- assertEquals(1.0, delegate.minimumToOpenDmWith(recipient(fee = null)).first { it != null }!!.toDouble())
- assertEquals(1.0, delegate.minimumToOpenDmWith(null).first { it != null }!!.toDouble())
+ assertEquals(1.0, delegate.startChattingFee(recipient(fee = null)).first { it != null }!!.toDouble())
+ assertEquals(1.0, delegate.startChattingFee(null).first { it != null }!!.toDouble())
}
@Test
- fun `minimumToOpenDmWith falls back to the preset rather than state a floor in another currency`() = runTest {
+ fun `startChattingFee falls back to the preset rather than state a floor in another currency`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
// No rate for the currency the recipient set their fee in.
every { exchange.rateToUsd(CurrencyCode.CAD) } returns null
val recipient = recipient(Fiat(10.0, CurrencyCode.CAD))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
assertEquals(1.0, min!!.toDouble())
assertEquals(CurrencyCode.USD, min.currencyCode)
@@ -223,7 +223,7 @@ class TipPaymentDelegateTest {
}
@Test
- fun `minimumToOpenDmWith rounds the converted fee up so it can't be undercut`() = runTest {
+ fun `startChattingFee rounds the converted fee up so it can't be undercut`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
// 600 INR at 95.95 INR/USD is 6.2533... USD — half-up rounding states it as $6.25, which is
@@ -232,38 +232,65 @@ class TipPaymentDelegateTest {
every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 95.95, currency = CurrencyCode.USD)
val recipient = recipient(Fiat(600.0, CurrencyCode.INR))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
assertEquals(6.26, min!!.toDouble())
assertTrue(min.toDouble() * 95.95 >= 600.0)
}
@Test
- fun `minimumToOpenDmWith rounds another currency's converted fee up too`() = runTest {
+ fun `startChattingFee rounds another currency's converted fee up too`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
// 1500 NGN at 1326 NGN/USD is 1.1312... USD, which rounds up to $1.14 (not the half-up $1.13).
every { exchange.rateToUsd(CurrencyCode.NGN) } returns Rate(fx = 1 / 1326.0, currency = CurrencyCode.USD)
val recipient = recipient(Fiat(1500.0, CurrencyCode.NGN))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
assertEquals(1.14, min!!.toDouble())
}
@Test
- fun `minimumToOpenDmWith does not bump a fee that converts exactly`() = runTest {
+ fun `startChattingFee does not bump a fee that converts exactly`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
// 600 INR at an even 100 INR/USD is exactly $6.00 — rounding up must not bump an exact value.
every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 100.0, currency = CurrencyCode.USD)
val recipient = recipient(Fiat(600.0, CurrencyCode.INR))
- val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null }
+ val min = buildDelegate().startChattingFee(recipient).first { it != null }
assertEquals(6.00, min!!.toDouble())
}
+ @Test
+ fun `startChattingFee falls back to the regional minimum when the recipient set none`() = runTest {
+ every { userFlags.resolvedFlags } returns usdPresets()
+ every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
+
+ val fee = buildDelegate().startChattingFee(recipient(fee = null)).first { it != null }
+
+ assertEquals(1.0, fee!!.toDouble())
+ assertEquals(CurrencyCode.USD, fee.currencyCode)
+ }
+
+ @Test
+ fun `startChattingFee uses the recipient's fee converted to the preferred currency`() = runTest {
+ every { userFlags.resolvedFlags } returns usdPresets()
+ every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 2.0, currency = CurrencyCode.EUR))
+ every { exchange.rateToUsd(CurrencyCode.CAD) } returns Rate(fx = 0.5, currency = CurrencyCode.USD)
+ every { exchange.rateFor(CurrencyCode.EUR) } returns Rate(fx = 2.0, currency = CurrencyCode.EUR)
+
+ val fee = buildDelegate()
+ .startChattingFee(recipient(Fiat(10.0, CurrencyCode.CAD)))
+ .first { it != null }
+
+ // CAD 10 -> USD 5 -> EUR 10.
+ assertEquals(10.0, fee!!.toDouble())
+ assertEquals(CurrencyCode.EUR, fee.currencyCode)
+ }
+
@Test
fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest {
every { userFlags.resolvedFlags } returns usdPresets()
diff --git a/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml b/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml
deleted file mode 100644
index 53eaf597c2..0000000000
--- a/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-