From 5d662023af5bdb34ae05c359ab657390369a8689 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:57:43 -0400 Subject: [PATCH 01/15] feat(common-ui): add the shared profile header --- .../features/messenger/build.gradle.kts | 1 + .../messenger/internal/ChatMuteStatusChip.kt | 40 +---- .../shared/common-ui/build.gradle.kts | 1 + .../shared/common/ui/ContactAvatar.kt | 11 +- .../common/ui/profile/ProfileActionButton.kt | 116 ++++++++++++++ .../shared/common/ui/profile/ProfileCover.kt | 50 ++++++ .../shared/common/ui/profile/ProfileHeader.kt | 151 ++++++++++++++++++ .../common/ui/profile/ProfileStatsCard.kt | 101 ++++++++++++ .../common/ui/profile/ProfileStatusChip.kt | 69 ++++++++ .../common-ui/src/main/res/values/strings.xml | 6 + 10 files changed, 508 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/res/values/strings.xml 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/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..0ead4571a6 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt @@ -0,0 +1,116 @@ +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.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 + +private val ActionHeight = 38.dp +private val ActionTextPadding = 18.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.height(ActionHeight), + contentPadding = ActionTextPadding, + ) { + 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(CodeTheme.colors.action.copy(alpha = 0.1f)) + .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..ac94f8cb6e --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt @@ -0,0 +1,151 @@ +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 +private val ActionsTopPadding = 20.dp + +/** + * The top of a profile: [cover], an avatar overlapping it, the actions beside the avatar, and the + * subject's [title], [subtitle] and [body]. + * + * 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 + ActionsTopPadding), + horizontalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.staticGrid.x3, + Alignment.End, + ), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = inset), + ) { + 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/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..d906ae476d --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt @@ -0,0 +1,101 @@ +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.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 +private val StatPadding = 16.dp +private val StatCaptionGap = 6.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), + caption = stringResource(R.string.title_profileMinimumToChat), + value = minimumToChat, + ) + Box( + modifier = Modifier + .width(1.dp) + .height(StatsDividerHeight) + .background(CodeTheme.colors.divider) + ) + Stat( + modifier = Modifier.weight(1f), + caption = stringResource(R.string.title_profileDateJoined), + value = joined, + ) + } +} + +@Composable +private fun Stat( + caption: String, + value: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(start = StatPadding, top = StatPadding), + verticalArrangement = Arrangement.spacedBy(StatCaptionGap), + ) { + 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 + — + From 02ddaa3c7d8457def0f8cc51d347bd884fedadfe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:57:43 -0400 Subject: [PATCH 02/15] feat(payments): name the start-chatting fee shown on profiles --- .../shared/payments/StartChattingFee.kt | 14 ++++++++++ .../shared/payments/TipPaymentDelegateTest.kt | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt new file mode 100644 index 0000000000..82e13d1f8e --- /dev/null +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt @@ -0,0 +1,14 @@ +package com.flipcash.shared.payments + +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.financial.Fiat +import kotlinx.coroutines.flow.Flow + +/** + * The amount another user must send to open a DM with [recipient], as shown on profiles. A + * [recipient] who set no fee, or whose fee has no rate to convert by, falls back to the regional + * minimum; null only until that minimum has resolved. The You tab, Edit Profile and the other-user + * profile all show this value, so it has one name. + */ +fun TipPaymentDelegate.startChattingFee(recipient: UserProfile?): Flow = + minimumToOpenDmWith(recipient) 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..6d0562c5e4 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 @@ -264,6 +264,33 @@ class TipPaymentDelegateTest { 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() From 4cdbf593d14c7a5ead7f5a25c3931d79d5bccfcf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:58:23 -0400 Subject: [PATCH 03/15] refactor(common-ui): take profile header spacing from the grid --- .../shared/common/ui/profile/ProfileActionButton.kt | 3 +-- .../com/flipcash/shared/common/ui/profile/ProfileHeader.kt | 3 +-- .../flipcash/shared/common/ui/profile/ProfileStatsCard.kt | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) 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 index 0ead4571a6..bb6f0cefbe 100644 --- 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 @@ -32,7 +32,6 @@ import com.flipcash.app.theme.FlipcashThemeWrapper import com.getcode.theme.CodeTheme private val ActionHeight = 38.dp -private val ActionTextPadding = 18.dp private val ActionIconSize = 22.dp private const val PressedAlpha = 0.7f @@ -46,7 +45,7 @@ fun ProfileActionButton( ActionCapsule( onClick = onClick, modifier = modifier.height(ActionHeight), - contentPadding = ActionTextPadding, + contentPadding = CodeTheme.dimens.staticGrid.x4, ) { Text( text = text, 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 index ac94f8cb6e..15009435c7 100644 --- 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 @@ -30,7 +30,6 @@ import com.getcode.theme.CodeTheme private val AvatarSize = 84.dp private val AvatarRing = 5.dp private val AvatarOverlap = 42.dp -private val ActionsTopPadding = 20.dp /** * The top of a profile: [cover], an avatar overlapping it, the actions beside the avatar, and the @@ -75,7 +74,7 @@ fun ProfileHeader( Row( modifier = Modifier .weight(1f) - .padding(top = AvatarOverlap + ActionsTopPadding), + .padding(top = AvatarOverlap + CodeTheme.dimens.staticGrid.x4), horizontalArrangement = Arrangement.spacedBy( CodeTheme.dimens.staticGrid.x3, Alignment.End, 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 index d906ae476d..994f942baf 100644 --- 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 @@ -26,8 +26,6 @@ import com.getcode.theme.White05 private val StatsCardHeight = 84.dp private val LeadingStatWidth = 176.dp private val StatsDividerHeight = 48.dp -private val StatPadding = 16.dp -private val StatCaptionGap = 6.dp /** * The two facts under a profile header: what it costs to start a chat, and when the subject @@ -74,8 +72,8 @@ private fun Stat( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.padding(start = StatPadding, top = StatPadding), - verticalArrangement = Arrangement.spacedBy(StatCaptionGap), + modifier = modifier.padding(start = CodeTheme.dimens.staticGrid.x3, top = CodeTheme.dimens.staticGrid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), ) { Text( text = caption, From 286caa267a9201da3d59f5385853292b7971ff61 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:03:34 -0400 Subject: [PATCH 04/15] feat(common-ui): add a profile share sheet with caller-chosen rows ProfileShareSheet draws a title, a subtitle and one row per ProfileShareRow. The caller picks the rows, hosts the sheet and runs the chosen action after it dismisses. --- .../common/ui/profile/ProfileShareSheet.kt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt 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 = {}, + ) +} From 0822efb1b848f2dbf82cb25e3e7a922540588a3c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:09:40 -0400 Subject: [PATCH 05/15] feat(menu): make the You tab your own profile The You tab now opens on the shared profile header with Edit Profile and Share, then the stats card, instead of the tip card with link and tiles. Share opens a sheet with Share Profile, Show Profile Card and Copy Link; the card moves to its own full-screen route that keeps Download. Removes the expansion machinery, the profile checklist and their tests. --- .../ui/navigation/AppScreenContent.kt | 4 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 14 + .../core/src/main/res/values/strings.xml | 9 +- apps/flipcash/features/menu/build.gradle.kts | 2 + .../flipcash/app/menu/ProfileCardScreen.kt | 65 ++ .../flipcash/app/menu/ProfileShareScreen.kt | 58 ++ .../flipcash/app/menu/internal/JoinedLabel.kt | 26 + .../app/menu/internal/MenuScreenContent.kt | 853 +++--------------- .../app/menu/internal/MenuScreenViewModel.kt | 245 ++--- .../app/menu/internal/ProfileCardViewModel.kt | 71 ++ .../menu/internal/ProfileShareViewModel.kt | 78 ++ .../app/menu/internal/ProfileTutorial.kt | 26 - .../app/menu/internal/TipCardExpansion.kt | 213 ----- .../app/menu/internal/OwnProfileStateTest.kt | 57 ++ .../app/menu/internal/ProfileTutorialTest.kt | 90 -- .../app/menu/internal/TipCardDragTest.kt | 118 --- .../app/menu/internal/TipCardOvershootTest.kt | 116 --- .../app/menu/internal/TipCardSettleTest.kt | 67 -- .../app/menu/internal/TipCardStateTest.kt | 46 - 19 files changed, 589 insertions(+), 1569 deletions(-) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt delete mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt delete mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt delete mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt delete mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt delete mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt 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/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/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 98ccf249ca..747f90baa2 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1333,8 +1333,13 @@ Send Your First Tip - Full Screen - Close + Edit Profile + 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..6d67a1323c 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -15,7 +15,9 @@ dependencies { 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..fc0c469a24 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.bills.ScannableRenderer +import com.flipcash.app.menu.internal.ProfileCardViewModel +import com.flipcash.features.menu.R +import com.flipcash.shared.common.ui.profile.ProfileActionButton +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults + +/** + * The viewer's own profile card, full screen, behind `AppRoute.Menu.ProfileCard`. Download hands + * the card to the system share sheet as an image. + */ +@Composable +fun ProfileCardScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + val card = viewModel.card + + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background), + ) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding(), + ) { + AppBarDefaults.Close(onClick = { navigator.pop() }) + } + + if (card != null) { + ScannableRenderer( + modifier = Modifier.align(Alignment.Center), + scannable = card, + tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction, + ) + } + + ProfileActionButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x4), + text = stringResource(R.string.action_download), + onClick = viewModel::download, + ) + } +} + +/** How much of the display the card spans, the proportion the earlier full-screen card used. */ +private const val CardWidthFraction = 0.75f 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..3945ca11c0 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt @@ -0,0 +1,58 @@ +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 -> + 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..7c8e5bf237 --- /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 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. + * + * `L` rather than `M` for the month: it is the stand-alone form, which is the right one when the + * month is not part of a longer date and differs from the in-sentence form in some languages. + * [DateUtils.getDate] is not used because it pins the locale to English. + */ +internal fun joinedLabel( + joinedAt: Instant?, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): String? { + joinedAt ?: return null + return DateTimeFormatter.ofPattern("LLLL y", 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..1201ce3627 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,105 +1,72 @@ 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.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.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.MenuScreenViewModel.ProfileState import com.flipcash.app.menu.internal.components.UsernameProgressCard 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.blur.HazeBlurDefaults import dev.chrisbanes.haze.blur.HazeBlurStyle @@ -107,519 +74,175 @@ 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 -> { + item(key = "settings_gear") { + SettingsGear(onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }) + } + item(key = "claim_prompt") { + Spacer(Modifier.height(UnclaimedTopSpacing)) + UnclaimedTipCardPrompt( + placeholder = profileState.placeholder, + cardWidth = YouCardWidth, + enabled = true, + 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) }, + onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + ) + } + item(key = "stats") { + ProfileStatsCard( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + minimumToChat = state.minimumToChat, + joined = profileState.joined, + ) + } + // Only for a named account: one with no name is already being asked for one. + // Gone entirely once a handle exists — the state nulls it out. + state.usernameProgress?.let { progress -> + item(key = "username_progress") { + UsernameProgressCard( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + progress = progress, + minimumBalance = state.usernameMinimumBalance, + onClick = { viewModel.dispatchEvent(Event.ClaimUsername) }, ) } } - } - 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 = "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, +private fun OwnProfileHeader( + profile: UserProfile, + onEdit: () -> Unit, onShare: () -> Unit, - onDownload: () -> Unit, - onClaim: () -> Unit, - usernameProgress: UsernameProgress?, - usernameMinimumBalance: String, - onClaimUsername: () -> Unit, - profileTutorial: List?, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> 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, + Box { + ProfileHeader( + 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, + access = BlobAccessContext.Owned, + modifier = modifier, + ) + }, + title = profile.displayName, + subtitle = profile.username?.takeIf { it.isNotEmpty() }?.let { "@$it" }, + body = profile.bio.ifEmpty { null }, + actions = { + ProfileActionButton( + text = stringResource(R.string.action_editProfile), + onClick = onEdit, + ) + // A named profile is the only one that gets here, which is exactly when there is + // something to share. + 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, ) } } -/** - * 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. - */ -@OptIn(ExperimentalLayoutApi::class) +/** The settings gear, clear of the status bar. */ @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, -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, +private fun SettingsGear(onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding(), ) { - // 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, + IconButton( + onClick = onClick, + modifier = Modifier.align(Alignment.TopEnd), ) { - 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) - } - } + Icon( + painter = painterResource(R.drawable.ic_settings_outline), + contentDescription = stringResource(CoreR.string.title_settings), + tint = CodeTheme.colors.textMain, + ) } + } +} - 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) - } - - 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 gear and the unclaimed stand-in. */ +private val UnclaimedTopSpacing: Dp + @Composable get() = CodeTheme.dimens.grid.x6 /** * What the "You" tab shows before the account has a display name: the card it *would* have, blurred @@ -627,8 +250,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, and the header, stats and Share button are + * absent entirely, because there is nothing yet to show or 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. @@ -640,9 +263,6 @@ private fun UnclaimedTipCardPrompt( cardWidth: Dp, enabled: Boolean, onClaim: () -> Unit, - profileTutorial: List?, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> Unit, blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(), ) { val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction) @@ -755,47 +375,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 @@ -819,149 +402,6 @@ private val PromptInset: Dp private val UnclaimedRowsGap: Dp @Composable get() = CodeTheme.dimens.grid.x10 -/** - * The label + chevron that toggles the card's full-screen state — "Full Screen" pointing down under - * 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, @@ -980,10 +420,6 @@ private fun Preview_UnclaimedTipCardPrompt() { cardWidth = YouCardWidth, enabled = true, onClaim = {}, - // What a nameless account sees: the display-name step first, nothing done yet. - profileTutorial = profileTutorialItems(UserProfile.Empty), - onSetProfilePicture = {}, - onSetMinimumTip = {}, ) } @@ -1001,9 +437,6 @@ private fun Preview_UnclaimedTipCardPrompt_NoBlur() { 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..9cd2dbf09e 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,20 @@ 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.payments.startChattingFee 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 +31,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 +50,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 +65,38 @@ 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, so it has no profile worth showing yet. [placeholder] is + * a real scannable stand-in drawn blurred behind the claim prompt; it is never shareable. */ - data class Unclaimed(val placeholder: Scannable.TipCard?) : TipCardState + data class Unclaimed(val placeholder: Scannable.TipCard?) : 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 +110,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 +135,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 +146,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 means nothing to show yet — the tab prompts to claim one instead. + // 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()) ) ) } 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 +268,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 +288,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/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/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..66c83fee10 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.app.core.AppRoute +import com.flipcash.services.models.UserProfile +import java.time.ZoneOffset +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +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 `joined is the full month and year`() { + // 2026-10-06T12:00:00Z + val joinedAt = Instant.fromEpochSeconds(1_791_288_000) + assertEquals("October 2026", joinedLabel(joinedAt, Locale.US, ZoneOffset.UTC)) + } + + @Test + fun `joined follows the locale`() { + val joinedAt = Instant.fromEpochSeconds(1_791_288_000) + assertEquals("octubre 2026", joinedLabel(joinedAt, Locale.forLanguageTag("es"), ZoneOffset.UTC)) + } + + @Test + fun `no join date means no label`() { + assertNull(joinedLabel(null, Locale.US, ZoneOffset.UTC)) + } + + @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) - } -} From 8b3089d5aaaa15a9f7f1b34a4524f34be8010f72 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:16:10 -0400 Subject: [PATCH 06/15] fix(menu): draw the profile card opaque and inset its Close button The card screen dropped the opaque fill the You-tab card used, so the card read as a faint outline on the flat backdrop, and Close sat clipped against the right edge. Name the flattened tone TipCardFlattened in bills, provide it on the card screen, and host Close in AppBarWithTitle like other full-screen screens. --- .../flipcash/app/menu/ProfileCardScreen.kt | 32 ++++++++++++------- .../app/menu/internal/MenuScreenContent.kt | 3 +- .../app/bills/components/cards/TipCard.kt | 6 ++++ 3 files changed, 28 insertions(+), 13 deletions(-) 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 index fc0c469a24..293f2f01ec 100644 --- 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 @@ -7,17 +7,22 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.features.menu.R import com.flipcash.shared.common.ui.profile.ProfileActionButton import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle /** * The viewer's own profile card, full screen, behind `AppRoute.Menu.ProfileCard`. Download hands @@ -34,20 +39,23 @@ fun ProfileCardScreen() { .fillMaxSize() .background(CodeTheme.colors.background), ) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding(), - ) { - AppBarDefaults.Close(onClick = { navigator.pop() }) - } + AppBarWithTitle( + modifier = Modifier.statusBarsPadding(), + endContent = { AppBarDefaults.Close(onClick = { navigator.pop() }) }, + ) if (card != null) { - ScannableRenderer( - modifier = Modifier.align(Alignment.Center), - scannable = card, - tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction, - ) + // 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), + scannable = card, + tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction, + ) + } } ProfileActionButton( 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 1201ce3627..34652df1f4 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 @@ -46,6 +46,7 @@ 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.LocalTabBarPadding @@ -320,7 +321,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, ) { 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). */ From 060c5cd3f424f33a997018fb60bb3f3ea11a5cae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:19:20 -0400 Subject: [PATCH 07/15] feat(menu): present the profile card like a scanned bill --- .../app/internal/ui/navigation/AppContent.kt | 24 +++++ .../flipcash/app/menu/ProfileCardScreen.kt | 94 ++++++++++++++++--- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 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/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 index 293f2f01ec..5479cfc7a5 100644 --- 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 @@ -1,15 +1,31 @@ package com.flipcash.app.menu +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.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton 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 @@ -17,32 +33,61 @@ 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.flipcash.shared.common.ui.profile.ProfileActionButton import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarDefaults -import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton /** - * The viewer's own profile card, full screen, behind `AppRoute.Menu.ProfileCard`. Download hands + * 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", + ) + Box( modifier = Modifier .fillMaxSize() .background(CodeTheme.colors.background), ) { - AppBarWithTitle( - modifier = Modifier.statusBarsPadding(), - endContent = { AppBarDefaults.Close(onClick = { navigator.pop() }) }, - ) + IconButton( + onClick = viewModel::download, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding(), + ) { + Icon( + painter = painterResource(R.drawable.ic_file_download), + contentDescription = stringResource(R.string.action_download), + tint = CodeTheme.colors.textMain, + ) + } if (card != null) { // Static backdrop, so draw the card opaque at its flattened tone, as the You tab did. @@ -51,23 +96,46 @@ fun ProfileCardScreen() { LocalTipCardBaseAlpha provides 1f, ) { ScannableRenderer( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + alpha = fade + scaleX = cardScale + scaleY = cardScale + }, scannable = card, tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction, ) } } - ProfileActionButton( + CodeButton( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() - .padding(bottom = CodeTheme.dimens.grid.x4), - text = stringResource(R.string.action_download), - onClick = viewModel::download, + .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), ) } } /** 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) From 09f843105e6f15da8b07da7bdfafede0f969c110 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:24:36 -0400 Subject: [PATCH 08/15] feat(menu): frost the Settings and Download bar buttons Both now use the app's CircularIconButton in an untitled AppBarWithTitle, so they sit where other tabs keep their trailing bar button and blur the cover or card behind them. --- .../flipcash/app/menu/ProfileCardScreen.kt | 66 ++++++++++--------- .../app/menu/internal/MenuScreenContent.kt | 41 +++++++----- .../app/menu/internal/ProfileBarButton.kt | 45 +++++++++++++ 3 files changed, 104 insertions(+), 48 deletions(-) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt 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 index 5479cfc7a5..7831bcd7ee 100644 --- 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 @@ -1,5 +1,8 @@ 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 @@ -10,9 +13,7 @@ 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.foundation.layout.statusBarsPadding import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -71,44 +72,49 @@ fun ProfileCardScreen() { label = "profile card scale", ) + // 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), ) { - IconButton( - onClick = viewModel::download, + Box( modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding(), + .fillMaxSize() + .hazeSource(hazeState), ) { - Icon( - painter = painterResource(R.drawable.ic_file_download), - contentDescription = stringResource(R.string.action_download), - tint = CodeTheme.colors.textMain, - ) - } - - 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, - ) + 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. + 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) 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 34652df1f4..91e76c122c 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 @@ -69,6 +69,7 @@ import com.getcode.ui.core.verticalScrollStateGradient 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 @@ -174,8 +175,11 @@ private fun OwnProfileHeader( onShare: () -> Unit, onSettings: () -> Unit, ) { + // 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, @@ -207,29 +211,30 @@ private fun OwnProfileHeader( SettingsGear( modifier = Modifier.align(Alignment.TopEnd), onClick = onSettings, + hazeState = hazeState, ) } } -/** The settings gear, clear of the status bar. */ +/** + * 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. + */ @Composable -private fun SettingsGear(onClick: () -> Unit, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .statusBarsPadding(), - ) { - IconButton( - onClick = onClick, - modifier = Modifier.align(Alignment.TopEnd), - ) { - Icon( - painter = painterResource(R.drawable.ic_settings_outline), - contentDescription = stringResource(CoreR.string.title_settings), - tint = CodeTheme.colors.textMain, - ) - } - } +private fun SettingsGear( + onClick: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + ProfileBarButton( + icon = R.drawable.ic_settings_outline, + contentDescription = stringResource(CoreR.string.title_settings), + onClick = onClick, + modifier = modifier, + hazeState = hazeState, + testTag = "you-settings", + ) } /** 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), + ) + } + }, + ) +} From a47db36818d5765fffc4865048d874cda204ad92 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:27:16 -0400 Subject: [PATCH 09/15] fix(menu): guard double row taps, hide Download until the card resolves, localize join date Ignore a second share-sheet row tap while the first is dismissing, show the profile card's Download button only once the card exists, build the joined label from the locale's MMMMy pattern, let the profile stats card and action button grow with font scale, and drop the unused ProfileTutorial items, title_finishYourProfile and ic_chevron_down_medium. --- .../app/core/ui/onboarding/NewUserTutorial.kt | 77 +------------------ .../res/drawable/ic_chevron_down_small.xml | 3 +- .../core/src/main/res/values/strings.xml | 1 - apps/flipcash/features/menu/build.gradle.kts | 1 + .../flipcash/app/menu/ProfileCardScreen.kt | 19 +++-- .../flipcash/app/menu/ProfileShareScreen.kt | 2 + .../flipcash/app/menu/internal/JoinedLabel.kt | 8 +- .../app/menu/internal/MenuScreenContent.kt | 11 +-- .../app/menu/internal/JoinedLabelTest.kt | 36 +++++++++ .../app/menu/internal/OwnProfileStateTest.kt | 21 ----- .../common/ui/profile/ProfileActionButton.kt | 3 +- .../common/ui/profile/ProfileStatsCard.kt | 5 +- .../res/drawable/ic_chevron_down_medium.xml | 9 --- 13 files changed, 65 insertions(+), 131 deletions(-) create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt delete mode 100644 ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml 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/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 diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 6d67a1323c..d3f8e3c8a6 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) implementation(libs.bundles.haze) 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 index 7831bcd7ee..2240416caf 100644 --- 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 @@ -106,14 +106,17 @@ fun ProfileCardScreen() { } // Download stands where the You tab keeps its gear, so the gear reads as turning into it. - 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", - ) + // 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 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 index 3945ca11c0..e6a7f4ad1d 100644 --- 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 @@ -46,6 +46,8 @@ fun ProfileShareScreen() { 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() 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 index 7c8e5bf237..0811b2e495 100644 --- 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 @@ -1,5 +1,6 @@ package com.flipcash.app.menu.internal +import android.text.format.DateFormat import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale @@ -10,9 +11,8 @@ import kotlin.time.Instant * [locale]'s language and word order. Null when the account has no join date, which the card draws * as a dash. * - * `L` rather than `M` for the month: it is the stand-alone form, which is the right one when the - * month is not part of a longer date and differs from the in-sentence form in some languages. - * [DateUtils.getDate] is not used because it pins the locale to English. + * 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?, @@ -20,7 +20,7 @@ internal fun joinedLabel( zone: ZoneId = ZoneId.systemDefault(), ): String? { joinedAt ?: return null - return DateTimeFormatter.ofPattern("LLLL y", locale) + 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 91e76c122c..b45006be48 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 @@ -118,7 +118,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { UnclaimedTipCardPrompt( placeholder = profileState.placeholder, cardWidth = YouCardWidth, - enabled = true, onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) }, ) } @@ -267,7 +266,6 @@ private val UnclaimedTopSpacing: Dp private fun UnclaimedTipCardPrompt( placeholder: Scannable.TipCard?, cardWidth: Dp, - enabled: Boolean, onClaim: () -> Unit, blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(), ) { @@ -369,7 +367,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, @@ -401,9 +399,8 @@ 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 @@ -424,7 +421,6 @@ private fun Preview_UnclaimedTipCardPrompt() { UnclaimedTipCardPrompt( placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty), cardWidth = YouCardWidth, - enabled = true, onClaim = {}, ) } @@ -441,7 +437,6 @@ private fun Preview_UnclaimedTipCardPrompt_NoBlur() { UnclaimedTipCardPrompt( placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty), cardWidth = YouCardWidth, - enabled = true, onClaim = {}, blurEnabled = false, ) 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 index 66c83fee10..4609a8179a 100644 --- 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 @@ -2,12 +2,9 @@ package com.flipcash.app.menu.internal import com.flipcash.app.core.AppRoute import com.flipcash.services.models.UserProfile -import java.time.ZoneOffset -import java.util.Locale import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import kotlin.time.Instant class OwnProfileStateTest { @@ -29,24 +26,6 @@ class OwnProfileStateTest { assertEquals(named, state.profileState) } - @Test - fun `joined is the full month and year`() { - // 2026-10-06T12:00:00Z - val joinedAt = Instant.fromEpochSeconds(1_791_288_000) - assertEquals("October 2026", joinedLabel(joinedAt, Locale.US, ZoneOffset.UTC)) - } - - @Test - fun `joined follows the locale`() { - val joinedAt = Instant.fromEpochSeconds(1_791_288_000) - assertEquals("octubre 2026", joinedLabel(joinedAt, Locale.forLanguageTag("es"), ZoneOffset.UTC)) - } - - @Test - fun `no join date means no label`() { - assertNull(joinedLabel(null, Locale.US, ZoneOffset.UTC)) - } - @Test fun `the share sheet opens only for a named profile`() { val named = MenuScreenViewModel.ProfileState.Named(UserProfile.Empty, joined = null) 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 index bb6f0cefbe..2d48ed4110 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -44,7 +45,7 @@ fun ProfileActionButton( ) { ActionCapsule( onClick = onClick, - modifier = modifier.height(ActionHeight), + modifier = modifier.heightIn(min = ActionHeight), contentPadding = CodeTheme.dimens.staticGrid.x4, ) { Text( 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 index 994f942baf..894079273e 100644 --- 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text @@ -41,7 +42,7 @@ fun ProfileStatsCard( Row( modifier = modifier .fillMaxWidth() - .height(StatsCardHeight) + .heightIn(min = StatsCardHeight) .clip(CodeTheme.shapes.medium) .background(White05), verticalAlignment = Alignment.CenterVertically, @@ -53,7 +54,7 @@ fun ProfileStatsCard( ) Box( modifier = Modifier - .width(1.dp) + .width(CodeTheme.dimens.border) .height(StatsDividerHeight) .background(CodeTheme.colors.divider) ) 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 @@ - - - From 78fa626a3e9143871d798dfd401945ed305e8f37 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:27:16 -0400 Subject: [PATCH 10/15] refactor(payments): give the chat-opening fee one name, startChattingFee --- .../app/menu/internal/MenuScreenViewModel.kt | 1 - .../app/messenger/internal/ChatViewModel.kt | 2 +- .../messenger/internal/ChatEventStallTest.kt | 2 +- .../internal/ChatGroupAnalyticsTest.kt | 2 +- .../internal/ChatGroupCashLinkTest.kt | 2 +- .../internal/ChatMentionPickerTest.kt | 2 +- .../internal/ChatOpenTranscriptTest.kt | 2 +- .../internal/ChatSendFailureAnalyticsTest.kt | 2 +- .../shared/payments/StartChattingFee.kt | 14 --------- .../shared/payments/TipPaymentDelegate.kt | 6 ++-- .../shared/payments/TipPaymentDelegateTest.kt | 30 +++++++++---------- 11 files changed, 25 insertions(+), 40 deletions(-) delete mode 100644 apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt 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 9cd2dbf09e..9e8bd58317 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 @@ -18,7 +18,6 @@ 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.payments.startChattingFee import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction 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/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt deleted file mode 100644 index 82e13d1f8e..0000000000 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/StartChattingFee.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.flipcash.shared.payments - -import com.flipcash.services.models.UserProfile -import com.getcode.opencode.model.financial.Fiat -import kotlinx.coroutines.flow.Flow - -/** - * The amount another user must send to open a DM with [recipient], as shown on profiles. A - * [recipient] who set no fee, or whose fee has no rate to convert by, falls back to the regional - * minimum; null only until that minimum has resolved. The You tab, Edit Profile and the other-user - * profile all show this value, so it has one name. - */ -fun TipPaymentDelegate.startChattingFee(recipient: UserProfile?): Flow = - minimumToOpenDmWith(recipient) 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 6d0562c5e4..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,34 +232,34 @@ 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()) } From 76f7b68825589eb57b8b782f5b725b25306c0a93 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:28:38 -0400 Subject: [PATCH 11/15] chore(core): drop strings the profile checklist used --- apps/flipcash/core/src/main/res/values/strings.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 97aa805e65..eaf53b82e7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -554,13 +554,7 @@ - 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 From 651b840a34623fc6c0232c25959dfc74daafe984 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:47:59 -0400 Subject: [PATCH 12/15] fix(common-ui): fill the profile action buttons with white, not grey The buttons used the action colour (Gray50) at 10%, which barely shows on the dark background. The design calls for a 10% white fill, which is White10. --- .../flipcash/shared/common/ui/profile/ProfileActionButton.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 2d48ed4110..07b9ac798d 100644 --- 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 @@ -31,6 +31,7 @@ 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 @@ -91,7 +92,7 @@ private fun ActionCapsule( modifier = modifier .alpha(if (pressed) PressedAlpha else 1f) .clip(CircleShape) - .background(CodeTheme.colors.action.copy(alpha = 0.1f)) + .background(White10) .clickable( interactionSource = interactionSource, indication = null, From b4e5161145c93e1c9bddadcf66332c22790c66d8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 21:50:02 -0400 Subject: [PATCH 13/15] fix(common-ui): top-align the profile stats like iOS The stats card centred its two stats vertically and then padded them from the top, so the captions sat well below the 16pt inset iOS uses. Pin the card to 84dp and let each stat fill that height, top-aligned. --- .../shared/common/ui/profile/ProfileStatsCard.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 894079273e..1b3accfba7 100644 --- 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 @@ -5,9 +5,9 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text @@ -42,13 +42,13 @@ fun ProfileStatsCard( Row( modifier = modifier .fillMaxWidth() - .heightIn(min = StatsCardHeight) + .height(StatsCardHeight) .clip(CodeTheme.shapes.medium) .background(White05), verticalAlignment = Alignment.CenterVertically, ) { Stat( - modifier = Modifier.width(LeadingStatWidth), + modifier = Modifier.width(LeadingStatWidth).fillMaxHeight(), caption = stringResource(R.string.title_profileMinimumToChat), value = minimumToChat, ) @@ -59,7 +59,7 @@ fun ProfileStatsCard( .background(CodeTheme.colors.divider) ) Stat( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).fillMaxHeight(), caption = stringResource(R.string.title_profileDateJoined), value = joined, ) @@ -72,6 +72,8 @@ private fun Stat( 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), From b66d27cad59adcbfde4e4e954f80a207386a336d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:31:17 -0400 Subject: [PATCH 14/15] fix(menu): keep the profile header for an unnamed account, as iOS does iOS YouScreen draws the header for every account and puts the claim prompt where the stats card goes. Android dropped the header, so an account with no name lost its cover, avatar and Edit Profile button. Share stays hidden until there is a name to share. ProfileHeader also gets iOS's 8pt gap (staticGrid.x2) between the action row and the title, and takes a null title for the unnamed case. --- .../app/menu/internal/MenuScreenContent.kt | 50 +++++++++++-------- .../app/menu/internal/MenuScreenViewModel.kt | 16 +++--- .../shared/common/ui/profile/ProfileHeader.kt | 21 +++++--- 3 files changed, 52 insertions(+), 35 deletions(-) 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 b45006be48..799ef028bb 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 @@ -110,8 +110,15 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { } is ProfileState.Unclaimed -> { - item(key = "settings_gear") { - SettingsGear(onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }) + // 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, + onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + ) } item(key = "claim_prompt") { Spacer(Modifier.height(UnclaimedTopSpacing)) @@ -169,9 +176,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { /** The viewer's profile header with the settings gear over the cover's top trailing corner. */ @Composable private fun OwnProfileHeader( - profile: UserProfile, + profile: UserProfile?, onEdit: () -> Unit, - onShare: () -> Unit, + onShare: (() -> Unit)?, onSettings: () -> Unit, ) { // The header is what scrolls under the gear, so it is the gear's frosting source. @@ -179,32 +186,33 @@ private fun OwnProfileHeader( Box { ProfileHeader( modifier = Modifier.hazeSource(hazeState), - cover = profile.coverPicture, + 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, + image = profile?.profilePicture, + displayName = profile?.displayName.orEmpty(), access = BlobAccessContext.Owned, modifier = modifier, ) }, - title = profile.displayName, - subtitle = profile.username?.takeIf { it.isNotEmpty() }?.let { "@$it" }, - body = profile.bio.ifEmpty { null }, + title = profile?.displayName?.ifEmpty { null }, + subtitle = profile?.username?.takeIf { it.isNotEmpty() }?.let { "@$it" }, + body = profile?.bio?.ifEmpty { null }, actions = { ProfileActionButton( text = stringResource(R.string.action_editProfile), onClick = onEdit, ) - // A named profile is the only one that gets here, which is exactly when there is - // something to share. - ProfileActionButton( - icon = ImageVector.vectorResource(R.drawable.ic_share_os), - contentDescription = stringResource(R.string.action_share), - onClick = onShare, - ) + // 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, + ) + } }, ) SettingsGear( @@ -245,9 +253,9 @@ private const val YouCardWidthFraction = 0.60f private val YouCardWidth: Dp @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction -/** Gap between the gear and the unclaimed stand-in. */ +/** Gap between the header and the unclaimed stand-in (iOS 24). */ private val UnclaimedTopSpacing: Dp - @Composable get() = CodeTheme.dimens.grid.x6 + @Composable get() = CodeTheme.dimens.staticGrid.x5 /** * What the "You" tab shows before the account has a display name: the card it *would* have, blurred @@ -255,8 +263,8 @@ private val UnclaimedTopSpacing: Dp * * 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 header, stats and Share button are - * absent entirely, because there is nothing yet to show 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. 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 9e8bd58317..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 @@ -89,10 +89,14 @@ internal class MenuScreenViewModel @Inject constructor( data object Unknown : ProfileState /** - * The account has no display name, so it has no profile worth showing 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?) : ProfileState + data class Unclaimed( + val placeholder: Scannable.TipCard?, + val profile: UserProfile? = null, + ) : ProfileState /** The viewer's own profile, with [joined] already formatted for the stats card. */ data class Named(val profile: UserProfile, val joined: String?) : ProfileState @@ -146,12 +150,12 @@ internal class MenuScreenViewModel @Inject constructor( .distinctUntilChanged() .onEach { profile -> if (profile == null || profile.displayName.isEmpty()) { - // No name means nothing to show yet — the tab prompts to claim one instead. - // Built locally, so an account whose profile the server has never seen still + // 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.OnProfileStateChanged( - ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard()) + ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard(), profile) ) ) } else { 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 index 15009435c7..50d46b0750 100644 --- 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 @@ -33,7 +33,8 @@ 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]. + * 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 @@ -48,7 +49,7 @@ fun ProfileHeader( cover: MediaItem?, access: BlobAccessContext, avatar: @Composable (Modifier) -> Unit, - title: String, + title: String?, subtitle: String?, body: String?, actions: @Composable RowScope.() -> Unit, @@ -83,16 +84,20 @@ fun ProfileHeader( content = actions, ) } + // iOS sets the text 8pt below the action row. Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = inset), + .padding(horizontal = inset) + .padding(top = CodeTheme.dimens.staticGrid.x2), ) { - Text( - text = title, - style = CodeTheme.typography.displaySmall, - color = CodeTheme.colors.textMain, - ) + if (!title.isNullOrBlank()) { + Text( + text = title, + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + ) + } if (!subtitle.isNullOrBlank()) { Text( text = subtitle, From 7e2e23238536b16664de6de0ea831ade6962b11b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 22:58:09 -0400 Subject: [PATCH 15/15] fix(menu): claim link under the handle and a brighter profile card, as iOS does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS #986 replaced the You tab's username progress card with a one-line "Claim your username ›" under the handle, shown while there is no handle or only an auto-assigned one. The tap keeps the balance gate. The card component and its two strings are gone; the progress state stays, since the gate reads it. The profile card raises a screen dimmer than 0.4 to 0.6 while it is up, as iOS's ProfileCardScreen does, through the KeepScreenOn helper the scanner already uses. --- .../core/src/main/res/values/strings.xml | 3 +- .../flipcash/app/menu/ProfileCardScreen.kt | 14 ++ .../app/menu/internal/MenuScreenContent.kt | 39 ++-- .../internal/components/UsernameProgress.kt | 16 ++ .../components/UsernameProgressCard.kt | 190 ------------------ 5 files changed, 55 insertions(+), 207 deletions(-) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt delete mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index eaf53b82e7..c3c32e6c1b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1199,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 @@ -1327,6 +1325,7 @@ Edit Profile + Claim your username › Share User Profile %1$s · @%2$s 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 index 2240416caf..c6ef7d6057 100644 --- 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 @@ -38,6 +38,7 @@ 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 @@ -72,6 +73,16 @@ fun ProfileCardScreen() { 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( @@ -133,6 +144,9 @@ fun ProfileCardScreen() { } } +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 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 799ef028bb..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 @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver 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 @@ -52,7 +53,6 @@ import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.navigation.LocalTabBarPadding import com.flipcash.app.menu.internal.MenuScreenViewModel.Event import com.flipcash.app.menu.internal.MenuScreenViewModel.ProfileState -import com.flipcash.app.menu.internal.components.UsernameProgressCard import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R as CoreR import com.flipcash.features.menu.R @@ -117,6 +117,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { 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)) }, ) } @@ -140,6 +143,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, onShare = { viewModel.dispatchEvent(Event.ShareProfile) }, + onClaimUsername = state.usernameProgress?.let { + { viewModel.dispatchEvent(Event.ClaimUsername) } + }, onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, ) } @@ -152,20 +158,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { joined = profileState.joined, ) } - // Only for a named account: one with no name is already being asked for one. - // Gone entirely once a handle exists — the state nulls it out. - state.usernameProgress?.let { progress -> - item(key = "username_progress") { - UsernameProgressCard( - modifier = Modifier - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.staticGrid.x4), - progress = progress, - minimumBalance = state.usernameMinimumBalance, - onClick = { viewModel.dispatchEvent(Event.ClaimUsername) }, - ) - } - } item(key = "bottom_spacer") { Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) } } } @@ -179,6 +171,7 @@ private fun OwnProfileHeader( profile: UserProfile?, onEdit: () -> Unit, onShare: (() -> Unit)?, + onClaimUsername: (() -> Unit)?, onSettings: () -> Unit, ) { // The header is what scrolls under the gear, so it is the gear's frosting source. @@ -200,6 +193,22 @@ private fun OwnProfileHeader( 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), 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 = {}, - ) - } -}