Skip to content

Commit b66d27c

Browse files
committed
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.
1 parent b4e5161 commit b66d27c

3 files changed

Lines changed: 52 additions & 35 deletions

File tree

‎apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt‎

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,15 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
110110
}
111111

112112
is ProfileState.Unclaimed -> {
113-
item(key = "settings_gear") {
114-
SettingsGear(onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) })
113+
// As on iOS: the header keeps its cover, avatar and Edit Profile, with no Share
114+
// (there is nothing to share yet), and the claim prompt takes the stats' place.
115+
item(key = "profile_header") {
116+
OwnProfileHeader(
117+
profile = profileState.profile,
118+
onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
119+
onShare = null,
120+
onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
121+
)
115122
}
116123
item(key = "claim_prompt") {
117124
Spacer(Modifier.height(UnclaimedTopSpacing))
@@ -169,42 +176,43 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
169176
/** The viewer's profile header with the settings gear over the cover's top trailing corner. */
170177
@Composable
171178
private fun OwnProfileHeader(
172-
profile: UserProfile,
179+
profile: UserProfile?,
173180
onEdit: () -> Unit,
174-
onShare: () -> Unit,
181+
onShare: (() -> Unit)?,
175182
onSettings: () -> Unit,
176183
) {
177184
// The header is what scrolls under the gear, so it is the gear's frosting source.
178185
val hazeState = rememberHazeState()
179186
Box {
180187
ProfileHeader(
181188
modifier = Modifier.hazeSource(hazeState),
182-
cover = profile.coverPicture,
189+
cover = profile?.coverPicture,
183190
// The viewer's own blobs, so no profile id is needed to authorize re-minting them.
184191
access = BlobAccessContext.Owned,
185192
avatar = { modifier ->
186193
ContactAvatar(
187-
image = profile.profilePicture,
188-
displayName = profile.displayName,
194+
image = profile?.profilePicture,
195+
displayName = profile?.displayName.orEmpty(),
189196
access = BlobAccessContext.Owned,
190197
modifier = modifier,
191198
)
192199
},
193-
title = profile.displayName,
194-
subtitle = profile.username?.takeIf { it.isNotEmpty() }?.let { "@$it" },
195-
body = profile.bio.ifEmpty { null },
200+
title = profile?.displayName?.ifEmpty { null },
201+
subtitle = profile?.username?.takeIf { it.isNotEmpty() }?.let { "@$it" },
202+
body = profile?.bio?.ifEmpty { null },
196203
actions = {
197204
ProfileActionButton(
198205
text = stringResource(R.string.action_editProfile),
199206
onClick = onEdit,
200207
)
201-
// A named profile is the only one that gets here, which is exactly when there is
202-
// something to share.
203-
ProfileActionButton(
204-
icon = ImageVector.vectorResource(R.drawable.ic_share_os),
205-
contentDescription = stringResource(R.string.action_share),
206-
onClick = onShare,
207-
)
208+
// Only a named profile has something to share.
209+
if (onShare != null) {
210+
ProfileActionButton(
211+
icon = ImageVector.vectorResource(R.drawable.ic_share_os),
212+
contentDescription = stringResource(R.string.action_share),
213+
onClick = onShare,
214+
)
215+
}
208216
},
209217
)
210218
SettingsGear(
@@ -245,18 +253,18 @@ private const val YouCardWidthFraction = 0.60f
245253
private val YouCardWidth: Dp
246254
@Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction
247255

248-
/** Gap between the gear and the unclaimed stand-in. */
256+
/** Gap between the header and the unclaimed stand-in (iOS 24). */
249257
private val UnclaimedTopSpacing: Dp
250-
@Composable get() = CodeTheme.dimens.grid.x6
258+
@Composable get() = CodeTheme.dimens.staticGrid.x5
251259

252260
/**
253261
* What the "You" tab shows before the account has a display name: the card it *would* have, blurred
254262
* out behind a prompt to claim it. Mirrors iOS `YouScreen.setupPrompt`.
255263
*
256264
* The stand-in is the account's real scannable payload drawn over an unnamed profile, with the
257265
* card's own fill turned off so the 8% ground shows through — the same construction iOS uses. It is
258-
* decoration: not tappable, not expandable, not shareable, and the header, stats and Share button are
259-
* absent entirely, because there is nothing yet to show or share.
266+
* decoration: not tappable, not expandable, not shareable. It takes the stats card's place under the
267+
* header, and the header drops its Share button, because there is nothing yet to share.
260268
*
261269
* [blurEnabled] is haze's own API-31 gate, surfaced so a preview can render what an API 29/30
262270
* device draws (see `Preview_UnclaimedTipCardPrompt_NoBlur`). Leave it at the default in app code.

‎apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ internal class MenuScreenViewModel @Inject constructor(
8989
data object Unknown : ProfileState
9090

9191
/**
92-
* The account has no display name, so it has no profile worth showing yet. [placeholder] is
93-
* a real scannable stand-in drawn blurred behind the claim prompt; it is never shareable.
92+
* The account has no display name yet. [placeholder] is a real scannable stand-in drawn
93+
* blurred behind the claim prompt; it is never shareable. [profile] is whatever the server
94+
* already holds (a cover, say), or null for an account it has never seen.
9495
*/
95-
data class Unclaimed(val placeholder: Scannable.TipCard?) : ProfileState
96+
data class Unclaimed(
97+
val placeholder: Scannable.TipCard?,
98+
val profile: UserProfile? = null,
99+
) : ProfileState
96100

97101
/** The viewer's own profile, with [joined] already formatted for the stats card. */
98102
data class Named(val profile: UserProfile, val joined: String?) : ProfileState
@@ -146,12 +150,12 @@ internal class MenuScreenViewModel @Inject constructor(
146150
.distinctUntilChanged()
147151
.onEach { profile ->
148152
if (profile == null || profile.displayName.isEmpty()) {
149-
// No name means nothing to show yet — the tab prompts to claim one instead.
150-
// Built locally, so an account whose profile the server has never seen still
153+
// No name yet: the header still draws (cover, avatar, Edit Profile) with the claim
154+
// prompt under it, as on iOS. The placeholder is built locally, so an account whose profile the server has never seen still
151155
// gets it.
152156
dispatchEvent(
153157
Event.OnProfileStateChanged(
154-
ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard())
158+
ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard(), profile)
155159
)
156160
)
157161
} else {

‎apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ private val AvatarOverlap = 42.dp
3333

3434
/**
3535
* The top of a profile: [cover], an avatar overlapping it, the actions beside the avatar, and the
36-
* subject's [title], [subtitle] and [body].
36+
* subject's [title], [subtitle] and [body]. A null [title] (an account with no name yet) draws
37+
* nothing in its place.
3738
*
3839
* It renders a subject, not a user — a person today, a group next — so callers map their model to
3940
* these inputs. [avatar] receives the size, clip and ring to draw with, which keeps the avatar the
@@ -48,7 +49,7 @@ fun ProfileHeader(
4849
cover: MediaItem?,
4950
access: BlobAccessContext,
5051
avatar: @Composable (Modifier) -> Unit,
51-
title: String,
52+
title: String?,
5253
subtitle: String?,
5354
body: String?,
5455
actions: @Composable RowScope.() -> Unit,
@@ -83,16 +84,20 @@ fun ProfileHeader(
8384
content = actions,
8485
)
8586
}
87+
// iOS sets the text 8pt below the action row.
8688
Column(
8789
modifier = Modifier
8890
.fillMaxWidth()
89-
.padding(horizontal = inset),
91+
.padding(horizontal = inset)
92+
.padding(top = CodeTheme.dimens.staticGrid.x2),
9093
) {
91-
Text(
92-
text = title,
93-
style = CodeTheme.typography.displaySmall,
94-
color = CodeTheme.colors.textMain,
95-
)
94+
if (!title.isNullOrBlank()) {
95+
Text(
96+
text = title,
97+
style = CodeTheme.typography.displaySmall,
98+
color = CodeTheme.colors.textMain,
99+
)
100+
}
96101
if (!subtitle.isNullOrBlank()) {
97102
Text(
98103
text = subtitle,

0 commit comments

Comments
 (0)