diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 90b1944a1..4b0eba4be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEnd { ListOfEmojiPacksScreen(accountViewModel, nav) } + composableFromEndArgs { EmojiPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 000b5da78..06280e5cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 96b1ad6a3..63c2c5bfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt new file mode 100644 index 000000000..4f249ea91 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt new file mode 100644 index 000000000..22e7850d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt @@ -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(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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt new file mode 100644 index 000000000..707b4ba60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt @@ -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 create(modelClass: Class): T = EmojiPackViewModel(account, packIdentifier) as T + } +} 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 new file mode 100644 index 000000000..65f737f9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt @@ -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 + } + } + } + } +} 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 new file mode 100644 index 000000000..85168f6b3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt @@ -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>, + selectedPacksFlow: StateFlow>?>, + 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>, + selectedPacksFlow: StateFlow>?>, + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt new file mode 100644 index 000000000..8668d0ebe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt new file mode 100644 index 000000000..f5b144839 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt @@ -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(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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt new file mode 100644 index 000000000..bab38fb06 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt @@ -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, + ) + } + } + } + }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 510936a64..3aff2ffab 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2372,4 +2372,30 @@ More Direct Punchy + Emoji + + + Emoji Packs + Add to Emoji List + Add to my emoji list + Remove from my emoji list + New emoji pack + Edit emoji pack + Shortcode (e.g. :smile:) + Only letters, numbers, hyphens, and underscores + Image URL + Pack address (optional) + Pack name + Description (optional) + Cover image URL (optional) + You don\'t have any emoji packs yet + %1$d emojis + My Emoji List + Emoji packs you\'ve added to your selection (NIP-51 kind 10030) + Add emoji + Add custom emoji + Remove :%1$s:? + \"%1$s\" is in your emoji list + \"%1$s\" is not in your emoji list + Emoji pack actions + Manage Emoji Packs