From 95b49879f4f974fa8474baeec4fb661f5dcd55ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:47:35 +0000 Subject: [PATCH] refactor(profile): refine badge strip on profile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../profile/header/badges/DisplayBadges.kt | 290 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 239 insertions(+), 52 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 9992202f93..f1f12a75da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -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, + 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, 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(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(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(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(), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99d6613fe6..1615aeaa2d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -448,6 +448,7 @@ Awarded to %1$d You received a badge Profile badges + Badges · %1$d Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. Pictures