Merge unified EmojiPackCard grid into integration branch
This commit is contained in:
+123
-7
@@ -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,
|
||||
|
||||
+158
@@ -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<String>,
|
||||
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<String>) {
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
-193
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-35
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-161
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user