refactor(profile): refine badge strip on profile header

- Drop the bare octagonal FlowRow of 35dp thumbs. Replace with a
  labeled strip ("Badges · N") of 44dp rounded-square thumbs matching
  the BadgeCard language used elsewhere.
- Cap the visible row at 8 badges and surface overflow as a "+N" pill
  that opens a ModalBottomSheet listing every accepted badge with its
  thumbnail, name, and description. Tapping a row closes the sheet and
  navigates to that badge's thread.
- When viewing your own profile, add a settings gear trailing the
  header that jumps to Route.ProfileBadges to manage which badges
  appear.
- Skip the entire strip (no empty header, no padding) until at least
  one badge is present.
This commit is contained in:
Claude
2026-04-19 00:47:35 +00:00
parent 7a8dc02394
commit 95b49879f4
2 changed files with 239 additions and 52 deletions
@@ -20,17 +20,44 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -43,12 +70,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BadgePictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
@@ -60,8 +86,12 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
private val ProfileBadgeSize = 44.dp
private val ProfileBadgeShape = RoundedCornerShape(8.dp)
private const val VISIBLE_BADGE_LIMIT = 8
@Composable
fun DisplayBadges(
@@ -75,91 +105,242 @@ fun DisplayBadges(
val oldNote = accountViewModel.getOrCreateAddressableNote(oldDesign)
val newNote = accountViewModel.getOrCreateAddressableNote(newDesign)
WatchAndRenderBadgeList(oldNote, newNote, accountViewModel, nav)
WatchAndRenderBadgeList(baseUser, oldNote, newNote, accountViewModel, nav)
}
@Composable
private fun WatchAndRenderBadgeList(
baseUser: User,
oldNote: AddressableNote,
newNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(oldNote, accountViewModel)
EventFinderFilterAssemblerSubscription(newNote, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(oldNote, newNote) {
combine(
oldNote.flow().metadata.stateFlow,
newNote.flow().metadata.stateFlow,
) { oldNote, newNote ->
val oldProfileBadgeEvent = oldNote.note.event as? AcceptedBadgeSetEvent
val newProfileBadgeEvent = newNote.note.event as? ProfileBadgesEvent
) { oldNoteState, newNoteState ->
val oldEvent = oldNoteState.note.event as? AcceptedBadgeSetEvent
val newEvent = newNoteState.note.event as? ProfileBadgesEvent
newProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
?: oldProfileBadgeEvent?.badgeAwardEvents()?.toImmutableList()
newEvent?.badgeAwardEvents()?.toImmutableList()
?: oldEvent?.badgeAwardEvents()?.toImmutableList()
?: persistentListOf()
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
// Subscribe in the LocalCache for changes that arrive in the device
val badgeList by flow.collectAsStateWithLifecycle(persistentListOf())
badgeList?.let { list -> RenderBadgeList(list, accountViewModel, nav) }
if (badgeList.isEmpty()) return
val isMe = baseUser.pubkeyHex == accountViewModel.userProfile().pubkeyHex
RenderProfileBadgeStrip(badgeList, isMe, accountViewModel, nav)
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderBadgeList(
@Composable
private fun RenderProfileBadgeStrip(
list: ImmutableList<ETag>,
isMe: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
FlowRow(
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp),
) {
list.forEach { badgeAwardEvent -> LoadAndRenderBadge(badgeAwardEvent, accountViewModel, nav) }
var showAllSheet by rememberSaveable { mutableStateOf(false) }
val visible = remember(list) { list.take(VISIBLE_BADGE_LIMIT) }
val overflow = (list.size - VISIBLE_BADGE_LIMIT).coerceAtLeast(0)
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringRes(R.string.profile_badges_header, list.size),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (isMe) {
IconButton(
onClick = { nav.nav(Route.ProfileBadges) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringRes(R.string.profile_badges_title),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
visible.forEach { eTag ->
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
BadgeThumb(defNote, accountViewModel, nav)
}
}
if (overflow > 0) {
OverflowChip(overflow) { showAllSheet = true }
}
}
}
if (showAllSheet) {
AllBadgesSheet(
awards = list,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = { showAllSheet = false },
)
}
}
@Composable
private fun LoadAndRenderBadge(
badgeAwardEvent: ETag,
private fun OverflowChip(
count: Int,
onClick: () -> Unit,
) {
Box(
modifier =
Modifier
.size(ProfileBadgeSize)
.clip(ProfileBadgeShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$count",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AllBadgesSheet(
awards: ImmutableList<ETag>,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
val baseNote =
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Text(
text = stringRes(R.string.profile_badges_header, awards.size),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(items = awards, key = { it.eventId }) { eTag ->
LoadDefinitionForAward(eTag, accountViewModel) { defNote ->
BadgeSheetRow(
defNote = defNote,
accountViewModel = accountViewModel,
onClick = {
val route = routeFor(defNote, accountViewModel.account)
scope.launch {
sheetState.hide()
onDismiss()
route?.let { nav.nav(it) }
}
},
)
}
}
}
}
}
@Composable
private fun BadgeSheetRow(
defNote: Note,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val event by observeNoteEvent<BadgeDefinitionEvent>(defNote, accountViewModel)
val definition = event ?: return
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RenderBadgeImage(
id = definition.id,
name = definition.name(),
image =
definition.thumb()?.ifBlank { null }
?: definition.image()?.ifBlank { null },
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = definition.name()?.ifBlank { null } ?: stringRes(R.string.badge_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
definition.description()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun LoadDefinitionForAward(
eTag: ETag,
accountViewModel: AccountViewModel,
content: @Composable (Note) -> Unit,
) {
val awardNote =
produceState(
LocalCache.getNoteIfExists(badgeAwardEvent),
badgeAwardEvent,
LocalCache.getNoteIfExists(eTag),
eTag,
) {
val newValue = LocalCache.checkGetOrCreateNote(badgeAwardEvent)
val newValue = LocalCache.checkGetOrCreateNote(eTag)
if (newValue != value) {
value = newValue
}
}
baseNote.value?.let {
ObserveAndRenderBadge(it, accountViewModel, nav)
}
}
@Composable
private fun ObserveAndRenderBadge(
it: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val badgeAwardState by observeNoteEvent<BadgeAwardEvent>(it, accountViewModel)
val badgeDefinitionId = badgeAwardState?.awardDefinition()?.firstOrNull()
if (badgeDefinitionId != null) {
LoadAddressableNote(badgeDefinitionId, accountViewModel) { badgeDefNote ->
badgeDefNote?.let {
BadgeThumb(it, accountViewModel, nav)
awardNote.value?.let { note ->
val awardEvent by observeNoteEvent<BadgeAwardEvent>(note, accountViewModel)
awardEvent?.awardDefinition()?.firstOrNull()?.let { defAddr ->
LoadAddressableNote(defAddr, accountViewModel) { defNote ->
defNote?.let { content(it) }
}
}
}
@@ -173,13 +354,16 @@ fun BadgeThumb(
) {
Box(
modifier =
Size35Modifier.clickable(
onClick = {
nav.nav {
routeFor(baseNote, accountViewModel.account)
}
},
),
Modifier
.size(ProfileBadgeSize)
.clip(ProfileBadgeShape)
.clickable(
onClick = {
nav.nav {
routeFor(baseNote, accountViewModel.account)
}
},
),
) {
WatchAndRenderBadgeImage(baseNote, accountViewModel)
}
@@ -215,11 +399,13 @@ private fun RenderBadgeImage(
stringRes(id = R.string.badge_award_image)
}
val modifier = Modifier.size(ProfileBadgeSize).clip(ProfileBadgeShape)
if (image == null) {
RobohashAsyncImage(
robot = "badgenotfound",
contentDescription = description,
modifier = BadgePictureModifier,
modifier = modifier,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
} else {
@@ -227,7 +413,7 @@ private fun RenderBadgeImage(
robot = id,
model = image,
contentDescription = description,
modifier = BadgePictureModifier,
modifier = modifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
+1
View File
@@ -448,6 +448,7 @@
<string name="badge_awardees_label">Awarded to %1$d</string>
<string name="badge_award_received_title">You received a badge</string>
<string name="profile_badges_title">Profile badges</string>
<string name="profile_badges_header">Badges · %1$d</string>
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string>