feat: add emoji pack management screens

Add screens under ui/screen/loggedIn/emojipacks/ mirroring the bookmarkgroups
layout:
 - list/ListOfEmojiPacksScreen plus EmojiPackItem for the pack feed, with a
   "My Emoji List" row at the top that surfaces kind 10030 selection count.
 - list/metadata/EmojiPackMetadataScreen(+ViewModel) for create/edit form.
 - display/EmojiPackScreen(+ViewModel) rendering the emoji grid, FAB to add,
   long-press to delete. AddEmojiDialog validates the shortcode live against
   EmojiUrlTag.isValidShortcode.
 - membershipManagement/EmojiPackSelectionScreen providing a single toggle
   for the user's kind 10030 selection.

New routes EmojiPacks, EmojiPackView, EmojiPackMetadataEdit, and
EmojiPackSelection wired through AppNavigation. A Manage Emoji Packs row is
added to the drawer. English-only strings added to strings.xml.
This commit is contained in:
Claude
2026-04-20 17:58:19 +00:00
parent 49568b9ca3
commit 2c6b8596ec
12 changed files with 1456 additions and 0 deletions
@@ -99,6 +99,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
@@ -257,6 +261,11 @@ fun BuildNavigation(
composableFromBottomArgs<Route.PostBookmarkManagement> { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) }
composableFromBottomArgs<Route.ArticleBookmarkManagement> { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.EmojiPacks> { ListOfEmojiPacksScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EmojiPackView> { EmojiPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackMetadataEdit> { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackSelection> { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
@@ -57,6 +57,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MilitaryTech
@@ -562,6 +563,14 @@ fun ListContent(
route = Route.BookmarkGroups,
)
NavigationRow(
title = R.string.manage_emoji_packs,
icon = Icons.Outlined.EmojiEmotions,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.EmojiPacks,
)
NavigationRow(
title = R.string.interest_sets_title,
icon = Icons.Outlined.Tag,
@@ -147,6 +147,28 @@ sealed class Route {
)
}
@Serializable object EmojiPacks : Route()
@Serializable data class EmojiPackView(
val dTag: String,
) : Route()
@Serializable data class EmojiPackMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class EmojiPackSelection(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object WebBookmarks : Route()
@Serializable object Drafts : Route()
@@ -0,0 +1,123 @@
/*
* 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.display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@Composable
fun AddEmojiDialog(
onDismiss: () -> Unit,
onConfirm: (EmojiUrlTag) -> Unit,
) {
var shortcode by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var packAddressText by remember { mutableStateOf("") }
val shortcodeValid by remember {
derivedStateOf {
shortcode.isNotBlank() && EmojiUrlTag.isValidShortcode(shortcode)
}
}
val shortcodeShowError by remember {
derivedStateOf {
shortcode.isNotBlank() && !EmojiUrlTag.isValidShortcode(shortcode)
}
}
val canConfirm by remember {
derivedStateOf {
shortcodeValid && url.isNotBlank()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringRes(R.string.emoji_add_dialog_title)) },
text = {
Column(
verticalArrangement = Arrangement.Top,
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = shortcode,
onValueChange = { shortcode = it.trim(':').trim() },
label = { Text(stringRes(R.string.emoji_shortcode_label)) },
isError = shortcodeShowError,
supportingText = {
if (shortcodeShowError) {
Text(stringRes(R.string.emoji_shortcode_invalid))
}
},
)
Spacer(DoubleVertSpacer)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = url,
onValueChange = { url = it },
label = { Text(stringRes(R.string.emoji_url_label)) },
)
Spacer(DoubleVertSpacer)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = packAddressText,
onValueChange = { packAddressText = it },
label = { Text(stringRes(R.string.emoji_pack_address_label)) },
)
}
},
confirmButton = {
Button(
enabled = canConfirm,
onClick = {
val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) }
onConfirm(EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress))
},
) {
Text(stringRes(R.string.add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -0,0 +1,244 @@
/*
* 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.display
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.ExtendedFloatingActionButton
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
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
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.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@Composable
fun EmojiPackScreen(
packIdentifier: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: EmojiPackViewModel =
viewModel(
factory = EmojiPackViewModel.Initializer(accountViewModel.account, packIdentifier),
)
EmojiPackScreenView(viewModel, accountViewModel, nav)
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun EmojiPackScreenView(
viewModel: EmojiPackViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
var isAddingPrivate by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) }
Scaffold(
topBar = {
ShorterTopAppBar(
title = {
pack?.let {
ListItem(
headlineContent = {
Text(
text = it.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
it.description?.let { description ->
Text(
text = description,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
},
navigationIcon = {
IconButton(nav::popBack) {
ArrowBackIcon()
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(text = stringRes(R.string.add_emoji_fab)) },
icon = {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = null,
)
},
onClick = {
isAddingPrivate = false
showAddDialog = true
},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding),
) {
pack?.let { currentPack ->
EmojiGrid(
pack = currentPack,
onLongPress = { emoji, isPrivate -> pendingDelete = EmojiDeleteTarget(emoji, isPrivate) },
)
}
}
}
if (showAddDialog) {
AddEmojiDialog(
onDismiss = { showAddDialog = false },
onConfirm = { tag ->
accountViewModel.launchSigner {
viewModel.addEmoji(tag, isAddingPrivate)
}
showAddDialog = false
},
)
}
pendingDelete?.let { target ->
M3ActionDialog(
title = stringRes(R.string.emoji_remove_dialog_title, target.emoji.code),
onDismiss = { pendingDelete = null },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.Add,
text = stringRes(R.string.quick_action_delete),
isDestructive = true,
) {
accountViewModel.launchSigner {
viewModel.removeEmoji(target.emoji.code, target.isPrivate)
}
pendingDelete = null
}
}
}
}
}
private data class EmojiDeleteTarget(
val emoji: EmojiUrlTag,
val isPrivate: Boolean,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiGrid(
pack: OwnedEmojiPack,
onLongPress: (EmojiUrlTag, Boolean) -> Unit,
) {
val allEmojis =
remember(pack) {
pack.publicEmojis.map { it to false } + pack.privateEmojis.map { it to true }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 56.dp),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(allEmojis, key = { (emoji, isPrivate) -> "${emoji.code}-${if (isPrivate) "priv" else "pub"}" }) { (emoji, isPrivate) ->
Box(
modifier =
Modifier
.combinedClickable(
onClick = {},
onLongClick = { onLongPress(emoji, isPrivate) },
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = emoji.url,
contentDescription = emoji.code,
modifier = Size35Modifier,
contentScale = ContentScale.Crop,
)
}
}
}
}
@@ -0,0 +1,67 @@
/*
* 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.display
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@Stable
class EmojiPackViewModel(
val account: Account,
val packIdentifier: String,
) : ViewModel() {
val selectedPackFlow =
account.ownedEmojiPacks
.getOwnedEmojiPackFlow(packIdentifier)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
suspend fun addEmoji(
emoji: EmojiUrlTag,
isPrivate: Boolean,
) {
account.addEmojiToOwnedPack(packIdentifier, emoji, isPrivate)
}
suspend fun removeEmoji(
shortcode: String,
isPrivate: Boolean,
) {
account.removeEmojiFromOwnedPack(packIdentifier, shortcode, isPrivate)
}
suspend fun deletePack() {
account.deleteOwnedEmojiPack(packIdentifier)
}
@Suppress("UNCHECKED_CAST")
class Initializer(
val account: Account,
val packIdentifier: String,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = EmojiPackViewModel(account, packIdentifier) as T
}
}
@@ -0,0 +1,193 @@
/*
* 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
}
}
}
}
}
@@ -0,0 +1,232 @@
/*
* 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.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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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
@Composable
fun ListOfEmojiPacksScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
ListOfEmojiPacksFeed(
listSource = accountViewModel.account.ownedEmojiPacks.listFeedFlow,
selectedPacksFlow = accountViewModel.account.emoji.flow,
openMyEmojiList = {
// kind 10030 is a user's selection of packs. No dedicated screen yet; open pack edit instead.
},
addEmojiPack = { nav.nav(Route.EmojiPackMetadataEdit()) },
openEmojiPack = { pack -> nav.nav(Route.EmojiPackView(pack.identifier)) },
editEmojiPack = { pack -> nav.nav(Route.EmojiPackMetadataEdit(pack.identifier)) },
deleteEmojiPack = { pack ->
accountViewModel.launchSigner {
accountViewModel.account.deleteOwnedEmojiPack(pack.identifier)
}
},
nav,
)
}
@Composable
fun ListOfEmojiPacksFeed(
listSource: StateFlow<List<OwnedEmojiPack>>,
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
openMyEmojiList: () -> Unit,
addEmojiPack: () -> Unit,
openEmojiPack: (OwnedEmojiPack) -> Unit,
editEmojiPack: (OwnedEmojiPack) -> Unit,
deleteEmojiPack: (OwnedEmojiPack) -> Unit,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(caption = stringRes(R.string.emoji_packs_title), nav::popBack)
},
floatingActionButton = {
EmojiPackFab(onAddPack = addEmojiPack)
},
) { paddingValues ->
Column(
Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
).fillMaxHeight(),
) {
ListOfEmojiPacksFeedView(
listSource = listSource,
selectedPacksFlow = selectedPacksFlow,
openMyEmojiList = openMyEmojiList,
openItem = openEmojiPack,
editItem = editEmojiPack,
deleteItem = deleteEmojiPack,
)
}
}
}
@Composable
fun ListOfEmojiPacksFeedView(
listSource: StateFlow<List<OwnedEmojiPack>>,
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
openMyEmojiList: () -> Unit,
openItem: (OwnedEmojiPack) -> Unit,
editItem: (OwnedEmojiPack) -> Unit,
deleteItem: (OwnedEmojiPack) -> Unit,
) {
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)
}
if (feedState.isEmpty()) {
item {
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)
}
}
}
}
@Composable
private fun MyEmojiListRow(
selectedPackCount: Int,
onClick: () -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = stringRes(R.string.my_emoji_list_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = stringRes(R.string.my_emoji_list_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
Text(text = stringRes(R.string.emoji_pack_count, selectedPackCount))
}
},
)
}
@Composable
fun EmojiPackFab(onAddPack: () -> Unit) {
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.new_emoji_pack))
},
icon = {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
)
},
onClick = onAddPack,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
}
@@ -0,0 +1,216 @@
/*
* 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.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun EmojiPackMetadataScreen(
packIdentifier: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: EmojiPackMetadataViewModel = viewModel()
viewModel.init(accountViewModel)
if (packIdentifier != null) {
LaunchedEffect(viewModel) {
viewModel.load(packIdentifier)
}
} else {
LaunchedEffect(viewModel) {
viewModel.new()
}
}
EmojiPackMetadataScaffold(viewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmojiPackMetadataScaffold(
viewModel: EmojiPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
EmojiPackMetadataTopBar(
viewModel = viewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
PackName(viewModel)
Spacer(modifier = DoubleVertSpacer)
PackImage(viewModel)
Spacer(modifier = DoubleVertSpacer)
PackDescription(viewModel)
}
}
}
}
@Composable
private fun EmojiPackMetadataTopBar(
viewModel: EmojiPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (viewModel.isNewPack) {
CreatingTopBar(
titleRes = R.string.new_emoji_pack,
isActive = viewModel::canPost,
onCancel = {
viewModel.clear()
nav.popBack()
},
onPost = {
try {
viewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.edit_emoji_pack,
isActive = viewModel::canPost,
onCancel = {
viewModel.clear()
nav.popBack()
},
onPost = {
try {
viewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun PackName(viewModel: EmojiPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = viewModel.name.value,
onValueChange = { viewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.emoji_pack_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@Composable
private fun PackImage(viewModel: EmojiPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_image_label)) },
modifier = Modifier.fillMaxWidth(),
value = viewModel.picture.value,
onValueChange = { viewModel.picture.value = it },
placeholder = {
Text(
text = "https://example.com/cover.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
}
@Composable
private fun PackDescription(viewModel: EmojiPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.emoji_pack_description_label)) },
modifier = Modifier.fillMaxWidth(),
value = viewModel.description.value,
onValueChange = { viewModel.description.value = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@@ -0,0 +1,94 @@
/*
* 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.metadata
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
class EmojiPackMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var pack by mutableStateOf<OwnedEmojiPack?>(null)
val isNewPack by derivedStateOf { pack == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
pack = null
clear()
}
fun load(dTag: String) {
val existing = account.ownedEmojiPacks.getPack(dTag)
pack = existing
name.value = TextFieldValue(existing?.title ?: "")
picture.value = TextFieldValue(existing?.image ?: "")
description.value = TextFieldValue(existing?.description ?: "")
}
fun createOrUpdate() {
accountViewModel.launchSigner {
val currentPack = pack
if (currentPack == null) {
account.createOwnedEmojiPack(
title = name.value.text,
description = description.value.text,
image = picture.value.text,
)
} else {
account.updateOwnedEmojiPackMetadata(
dTag = currentPack.identifier,
newTitle = name.value.text,
newDescription = description.value.text,
newImage = picture.value.text,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
}
@@ -0,0 +1,221 @@
/*
* 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.membershipManagement
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.outlined.EmojiEmotions
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
@Composable
fun EmojiPackSelectionScreen(
packAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address = packAddress, accountViewModel = accountViewModel) { note ->
note?.let {
EmojiPackSelectionView(
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
note = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun EmojiPackSelectionView(
modifier: Modifier = Modifier,
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = modifier,
topBar = {
TopBarWithBackButton(caption = stringRes(R.string.emoji_pack_management_title), nav::popBack)
},
) { contentPadding ->
Column(
modifier =
Modifier
.padding(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
EmojiPackSelectionBody(note, accountViewModel)
}
}
}
@Composable
private fun EmojiPackSelectionBody(
note: AddressableNote,
accountViewModel: AccountViewModel,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
) {
item {
LoadAddressableNote(
address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
accountViewModel = accountViewModel,
) { selectionNote ->
selectionNote?.let {
val hasAddedThis by observeNoteAndMap(it, accountViewModel) { currentNote ->
currentNote.event?.isTaggedAddressableNote(note.idHex) == true
}
EmojiPackSelectionItem(
isIncluded = hasAddedThis,
packTitle = (note.event as? EmojiPackEvent)?.titleOrName() ?: note.dTag(),
onAdd = {
accountViewModel.addEmojiPack(note)
},
onRemove = {
accountViewModel.removeEmojiPack(note)
},
)
}
}
}
}
}
@Composable
private fun EmojiPackSelectionItem(
isIncluded: Boolean,
packTitle: String,
onAdd: () -> Unit,
onRemove: () -> Unit,
) {
ListItem(
modifier = Modifier.fillMaxWidth().clickable(onClick = { if (isIncluded) onRemove() else onAdd() }),
headlineContent = {
Text(
text = stringRes(R.string.my_emoji_list_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text =
if (isIncluded) {
stringRes(R.string.emoji_pack_is_in_list, packTitle)
} else {
stringRes(R.string.emoji_pack_is_not_in_list, packTitle)
},
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
}
},
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { if (isIncluded) onRemove() else onAdd() },
modifier =
Modifier
.background(
color =
if (isIncluded) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primary
},
shape = RoundedCornerShape(percent = 80),
),
) {
if (isIncluded) {
Icon(
imageVector = Icons.Filled.BookmarkRemove,
contentDescription = stringRes(R.string.remove_from_emoji_list),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
} else {
Icon(
imageVector = Icons.Filled.BookmarkAdd,
contentDescription = stringRes(R.string.add_to_emoji_list),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
},
)
}
+26
View File
@@ -2372,4 +2372,30 @@
<string name="ai_tone_more_direct">More Direct</string>
<string name="ai_tone_punchy">Punchy</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji Packs</string>
<string name="emoji_pack_management_title">Add to Emoji List</string>
<string name="add_to_emoji_list">Add to my emoji list</string>
<string name="remove_from_emoji_list">Remove from my emoji list</string>
<string name="new_emoji_pack">New emoji pack</string>
<string name="edit_emoji_pack">Edit emoji pack</string>
<string name="emoji_shortcode_label">Shortcode (e.g. :smile:)</string>
<string name="emoji_shortcode_invalid">Only letters, numbers, hyphens, and underscores</string>
<string name="emoji_url_label">Image URL</string>
<string name="emoji_pack_address_label">Pack address (optional)</string>
<string name="emoji_pack_name_label">Pack name</string>
<string name="emoji_pack_description_label">Description (optional)</string>
<string name="emoji_pack_image_label">Cover image URL (optional)</string>
<string name="no_emoji_packs">You don\'t have any emoji packs yet</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">My Emoji List</string>
<string name="my_emoji_list_explainer">Emoji packs you\'ve added to your selection (NIP-51 kind 10030)</string>
<string name="add_emoji_fab">Add emoji</string>
<string name="emoji_add_dialog_title">Add custom emoji</string>
<string name="emoji_remove_dialog_title">Remove :%1$s:?</string>
<string name="emoji_pack_is_in_list">\"%1$s\" is in your emoji list</string>
<string name="emoji_pack_is_not_in_list">\"%1$s\" is not in your emoji list</string>
<string name="emoji_pack_actions_dialog_title">Emoji pack actions</string>
<string name="manage_emoji_packs">Manage Emoji Packs</string>
</resources>