From 99e911e914277c7570da24fb156be0a4c03e1237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:55:07 +0000 Subject: [PATCH] feat(emoji): unified EmojiPackCard with dense grid layout Introduce a shared EmojiPackCard composable that renders each pack as a compact 3x2 emoji preview with title and count, using the emojis as the visual identity. Cover image (when present) is shown as a small 24dp corner-badge so it doesn't steal focus from the emoji grid. Wire the card into three consumers and switch them all from vertical lists to LazyVerticalGrid(Adaptive, minSize=160dp): - ListOfEmojiPacksScreen (owned packs) - MyEmojiListScreen (selected/subscribed packs) - BrowseEmojiSetsScreen (kind 30030 discovery feed) For owned packs, cover is suppressed so the top-right corner stays clear for the edit/delete overflow menu. For My Emoji List, the remove button is placed on top-start to avoid clashing with the cover badge. Drop EmojiPackItem.kt (replaced entirely by EmojiPackCard + wrappers). BrowseEmojiSetsScreen now bypasses the generic note renderer and drives the feed's grid directly while keeping the subscription/filter wiring untouched. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../browse/BrowseEmojiSetsScreen.kt | 130 ++++++++- .../emojipacks/common/EmojiPackCard.kt | 158 +++++++++++ .../loggedIn/emojipacks/list/EmojiPackItem.kt | 193 -------------- .../emojipacks/list/ListOfEmojiPacksScreen.kt | 149 ++++++++--- .../membershipManagement/MyEmojiListScreen.kt | 246 ++++++------------ 5 files changed, 480 insertions(+), 396 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt index cfee538a2..3bdb01131 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -20,20 +20,41 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState -import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis @Composable fun BrowseEmojiSetsScreen( @@ -65,19 +86,114 @@ fun BrowseEmojiSetsScreen( accountViewModel = accountViewModel, ) { RefresheableBox(feedContentState, true) { - SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = feedContentState, + val gridState = rememberForeverLazyGridState(ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) + WatchScrollToTop(feedContentState, gridState) + RenderBrowseEmojiSetsGrid( + feedContentState = feedContentState, + gridState = gridState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun RenderBrowseEmojiSetsGrid( + feedContentState: FeedContentState, + gridState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty(feedContentState::invalidateData) + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage, feedContentState::invalidateData) + } + + is FeedState.Loaded -> { + BrowseEmojiSetsGridLoaded( + loaded = state, + gridState = gridState, accountViewModel = accountViewModel, - listState = listState, nav = nav, - routeForLastRead = "BrowseEmojiSetsFeed", ) } + + is FeedState.Loading -> { + LoadingFeed() + } } } } +@Composable +private fun BrowseEmojiSetsGridLoaded( + loaded: FeedState.Loaded, + gridState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items.list, + key = { note -> note.idHex }, + ) { note -> + BrowsedEmojiPackCard( + note = note, + modifier = Modifier.animateItem(), + onClick = { nav.nav(Route.Note(note.idHex)) }, + ) + } + } +} + +@Composable +private fun BrowsedEmojiPackCard( + note: Note, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val event = note.event as? EmojiPackEvent ?: return + + val title = + remember(event) { + event.titleOrName()?.takeIf { it.isNotBlank() } ?: event.dTag() + } + val emojiUrls = + remember(event) { + event.taggedEmojis().map { it.url } + } + val coverImage = remember(event) { event.image() } + + EmojiPackCard( + title = title, + emojiUrls = emojiUrls, + coverImage = coverImage, + onClick = onClick, + modifier = modifier, + ) +} + @Composable fun WatchAccountForBrowseEmojiSetsScreen( feedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt new file mode 100644 index 000000000..b8ac366e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +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.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +private const val PREVIEW_SLOTS = 6 +private val ThumbSize = 28.dp +private val CornerBadgeSize = 24.dp + +@Composable +fun EmojiPackCard( + title: String, + emojiUrls: List, + coverImage: String? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val slots = emojiUrls.take(PREVIEW_SLOTS) + val emojiCount = emojiUrls.size + + ElevatedCard( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + ) { + EmojiPreviewGrid(slots) + Spacer(Modifier.height(10.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringRes(R.string.emoji_pack_count, emojiCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Corner badge keeps the pack's cover visible without stealing the emoji grid's + // role as the visual identity; a tinted backdrop would muddy the emoji previews. + if (!coverImage.isNullOrBlank()) { + AsyncImage( + model = coverImage, + contentDescription = title, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(CornerBadgeSize) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape, + ), + contentScale = ContentScale.Crop, + ) + } + } + } +} + +@Composable +private fun EmojiPreviewGrid(urls: List) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (rowIndex in 0 until 2) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (colIndex in 0 until 3) { + val slotIndex = rowIndex * 3 + colIndex + val url = urls.getOrNull(slotIndex) + EmojiPreviewSlot(url) + } + } + } + } +} + +@Composable +private fun EmojiPreviewSlot(url: String?) { + if (url != null) { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.size(ThumbSize), + contentScale = ContentScale.Fit, + ) + } else { + Box( + modifier = + Modifier + .size(ThumbSize) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt deleted file mode 100644 index 65f737f9d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list - -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import coil3.compose.AsyncImage -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack -import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.M3ActionDialog -import com.vitorpamplona.amethyst.ui.components.M3ActionRow -import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier -import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer - -@Composable -fun EmojiPackItem( - modifier: Modifier = Modifier, - pack: OwnedEmojiPack, - onClick: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - Row( - modifier = modifier.clickable(onClick = onClick), - ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ListItem( - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(pack.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - Column( - modifier = NoSoTinyBorders, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.End, - ) { - EmojiPackOptionsButton( - onEdit = onEdit, - onDelete = onDelete, - ) - } - } - }, - supportingContent = { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - pack.description?.let { - Text( - it, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - ) - } - Spacer(StdVertSpacer) - EmojiPackPreviewThumbnails(pack) - } - }, - leadingContent = { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!pack.image.isNullOrBlank()) { - AsyncImage( - model = pack.image, - contentDescription = pack.title, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } else { - Icon( - imageVector = Icons.Outlined.EmojiEmotions, - contentDescription = null, - modifier = Size40Modifier, - ) - } - Spacer(StdVertSpacer) - Text( - text = stringRes(R.string.emoji_pack_count, pack.totalEmojis), - ) - } - }, - ) - } - } -} - -@Composable -private fun EmojiPackPreviewThumbnails(pack: OwnedEmojiPack) { - val first = remember(pack) { (pack.publicEmojis + pack.privateEmojis).take(6) } - if (first.isEmpty()) return - Row( - horizontalArrangement = SpacedBy2dp, - verticalAlignment = Alignment.CenterVertically, - ) { - first.forEach { emoji -> - Box( - modifier = Size40Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } - } - } -} - -@Composable -private fun EmojiPackOptionsButton( - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - val isMenuOpen = remember { mutableStateOf(false) } - - ClickableBox( - onClick = { isMenuOpen.value = true }, - ) { - VerticalDotsIcon() - } - - if (isMenuOpen.value) { - M3ActionDialog( - title = stringRes(R.string.emoji_pack_actions_dialog_title), - onDismiss = { isMenuOpen.value = false }, - ) { - M3ActionSection { - M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) { - onEdit() - isMenuOpen.value = false - } - } - M3ActionSection { - M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { - onDelete() - isMenuOpen.value = false - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt index 4a595724f..19fca7a54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt @@ -22,17 +22,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list 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.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.EmojiEmotions import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -43,6 +47,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -52,13 +58,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import kotlinx.coroutines.flow.StateFlow @@ -134,40 +145,108 @@ fun ListOfEmojiPacksFeedView( val feedState by listSource.collectAsStateWithLifecycle() val selectedPacks by selectedPacksFlow.collectAsStateWithLifecycle() - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, - ) { - item { - MyEmojiListRow( - selectedPackCount = selectedPacks?.size ?: 0, - onClick = openMyEmojiList, - ) - HorizontalDivider(thickness = DividerThickness) - } + Column(modifier = Modifier.fillMaxSize()) { + MyEmojiListRow( + selectedPackCount = selectedPacks?.size ?: 0, + onClick = openMyEmojiList, + ) + HorizontalDivider(thickness = DividerThickness) if (feedState.isEmpty()) { - item { - Text( - text = stringRes(R.string.no_emoji_packs), - modifier = Modifier.fillMaxWidth().padding(16.dp), - textAlign = TextAlign.Center, - ) - } + Text( + text = stringRes(R.string.no_emoji_packs), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) } else { - itemsIndexed( - feedState, - key = { _: Int, item: OwnedEmojiPack -> item.identifier }, - ) { _, pack -> - EmojiPackItem( - modifier = Modifier.fillMaxSize().animateItem(), - pack = pack, - onClick = { openItem(pack) }, - onEdit = { editItem(pack) }, - onDelete = { deleteItem(pack) }, - ) - HorizontalDivider(thickness = DividerThickness) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = rememberLazyGridState(), + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + feedState, + key = { item: OwnedEmojiPack -> item.identifier }, + ) { pack -> + OwnedEmojiPackCard( + pack = pack, + modifier = Modifier.animateItem(), + onClick = { openItem(pack) }, + onEdit = { editItem(pack) }, + onDelete = { deleteItem(pack) }, + ) + } + } + } + } +} + +@Composable +private fun OwnedEmojiPackCard( + pack: OwnedEmojiPack, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val emojiUrls = + remember(pack) { + (pack.publicEmojis + pack.privateEmojis).map { it.url } + } + + Box(modifier = modifier.fillMaxWidth()) { + // Owned packs omit the cover badge: the top-right corner is reserved for the + // edit/delete overflow menu, which the user needs more than a reminder of the + // cover they themselves uploaded. + EmojiPackCard( + title = pack.title, + emojiUrls = emojiUrls, + coverImage = null, + onClick = onClick, + ) + Box(modifier = Modifier.align(Alignment.TopEnd).padding(4.dp)) { + EmojiPackOptionsButton( + onEdit = onEdit, + onDelete = onDelete, + ) + } + } +} + +@Composable +private fun EmojiPackOptionsButton( + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val isMenuOpen = remember { mutableStateOf(false) } + + ClickableBox( + onClick = { isMenuOpen.value = true }, + ) { + VerticalDotsIcon() + } + + if (isMenuOpen.value) { + M3ActionDialog( + title = stringRes(R.string.emoji_pack_actions_dialog_title), + onDismiss = { isMenuOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) { + onEdit() + isMenuOpen.value = false + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + isMenuOpen.value = false + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt index c7bce755d..f6d40e580 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt @@ -20,27 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement -import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -49,27 +48,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier -import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -79,19 +71,6 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis // (a.k.a. "My Emoji List") and remove individual packs from it. Tapping a pack navigates to a // viewer: the owner's pack-editor screen for self-authored packs, otherwise the generic note // thread view (which already knows how to render a kind 30030 `EmojiPackEvent`). -// -// IMPORTANT: Removing a pack from the 10030 selection is observable end-to-end: -// * `account.emoji.getEmojiPackSelectionFlow()` is shared by the dropdown bookmark toggle on -// a 30030 note, and by the reaction menu custom-emoji picker -// (`UpdateReactionTypeDialog.EmojiSelector` reads `EmojiPackSelectionEvent.emojiPacks()`). -// * `EmojiPackState.myEmojis` is a derived flow that maps the selection -> per-pack note -// flows -> merged, URL-deduped emoji list. This is what the `:` autocomplete -// (`EmojiSuggestionState`) and the post-composer taggers consume. Removing a pack here -// will immediately remove its emojis from that merged list. -// Reordering is NOT implemented: NIP-51 doesn't mandate an order, but both `myEmojis` and the -// reaction menu render packs in tag order. Adding drag-to-reorder would require a new -// `EmojiPackSelectionEvent.reorder(...)` builder in quartz (no such API exists) and a bespoke -// drag surface that doesn't conflict with tap-to-view / tap-to-delete. Skipped for this pass. @Composable fun MyEmojiListScreen( accountViewModel: AccountViewModel, @@ -153,50 +132,52 @@ private fun MyEmojiListFeed( accountViewModel: AccountViewModel, nav: INav, ) { - LazyColumn( - state = rememberLazyListState(), + if (packAddresses.isEmpty()) { + Text( + text = stringRes(R.string.my_emoji_list_empty), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = rememberLazyGridState(), modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - if (packAddresses.isEmpty()) { - item { - Text( - text = stringRes(R.string.my_emoji_list_empty), - modifier = Modifier.fillMaxWidth().padding(16.dp), - textAlign = TextAlign.Center, - ) - } - } else { - itemsIndexed( - packAddresses, - key = { _, address -> address.toValue() }, - ) { _, address -> - LoadAddressableNote( - address = address, - accountViewModel = accountViewModel, - ) { packNote -> - packNote?.let { - SelectedEmojiPackRow( - packNote = it, - accountViewModel = accountViewModel, - onOpen = { - val route = - if (accountViewModel.isLoggedUser(it.author)) { - Route.EmojiPackView(it.dTag()) - } else { - Route.Note(it.idHex) - } - nav.nav(route) - }, - onRemove = { - // Publishes a replacement kind 10030 without this pack's `a` tag. - // Downstream consumers (reaction menu + `:` autocomplete) refresh - // automatically because they're all subscribed to the same flow. - accountViewModel.removeEmojiPack(it) - }, - ) - HorizontalDivider(thickness = DividerThickness) - } + items( + packAddresses, + key = { address -> address.toValue() }, + ) { address -> + LoadAddressableNote( + address = address, + accountViewModel = accountViewModel, + ) { packNote -> + packNote?.let { + SelectedEmojiPackCard( + packNote = it, + modifier = Modifier.animateItem(), + accountViewModel = accountViewModel, + onOpen = { + val route = + if (accountViewModel.isLoggedUser(it.author)) { + Route.EmojiPackView(it.dTag()) + } else { + Route.Note(it.idHex) + } + nav.nav(route) + }, + onRemove = { + // Publishes a replacement kind 10030 without this pack's `a` tag. + // Downstream consumers (reaction menu + `:` autocomplete) refresh + // automatically because they're all subscribed to the same flow. + accountViewModel.removeEmojiPack(it) + }, + ) } } } @@ -204,8 +185,9 @@ private fun MyEmojiListFeed( } @Composable -private fun SelectedEmojiPackRow( +private fun SelectedEmojiPackCard( packNote: AddressableNote, + modifier: Modifier = Modifier, accountViewModel: AccountViewModel, onOpen: () -> Unit, onRemove: () -> Unit, @@ -214,93 +196,35 @@ private fun SelectedEmojiPackRow( val title = packEvent?.titleOrName()?.takeIf { it.isNotBlank() } ?: packNote.dTag() val image = packEvent?.image() - val description = packEvent?.description() - val emojiCount = packEvent?.taggedEmojis()?.size ?: 0 - val previewEmojis = remember(packEvent) { packEvent?.taggedEmojis()?.take(6).orEmpty() } + val emojiUrls = + remember(packEvent) { + packEvent?.taggedEmojis()?.map { it.url }.orEmpty() + } - ListItem( - modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), - headlineContent = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Box(modifier = modifier.fillMaxWidth()) { + EmojiPackCard( + title = title, + emojiUrls = emojiUrls, + coverImage = image, + onClick = onOpen, + ) + // Remove button is on top-start so it doesn't clash with the cover badge (top-end). + IconButton( + onClick = onRemove, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringRes(R.string.remove_from_emoji_list), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), ) - }, - supportingContent = { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - packNote.author?.let { author -> - val authorName by observeUserName(author, accountViewModel) - Text( - text = stringRes(R.string.my_emoji_list_by_author, authorName), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - } - description?.takeIf { it.isNotBlank() }?.let { - Text( - text = it, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(StdVertSpacer) - if (previewEmojis.isNotEmpty()) { - Row( - horizontalArrangement = SpacedBy2dp, - verticalAlignment = Alignment.CenterVertically, - ) { - previewEmojis.forEach { emoji -> - Box( - modifier = Size40Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } - } - } - } - } - }, - leadingContent = { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!image.isNullOrBlank()) { - AsyncImage( - model = image, - contentDescription = title, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } else { - Icon( - imageVector = Icons.Outlined.EmojiEmotions, - contentDescription = null, - modifier = Size40Modifier, - ) - } - Spacer(StdVertSpacer) - Text(text = stringRes(R.string.emoji_pack_count, emojiCount)) - } - }, - trailingContent = { - IconButton(onClick = onRemove) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringRes(R.string.remove_from_emoji_list), - tint = MaterialTheme.colorScheme.error, - ) - } - }, - ) + } + } }