From 0ddbb4616befbe491df0e41c710a304710c444d7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 12 Nov 2025 13:50:57 -0500 Subject: [PATCH] Adds creation, edits and member update for Follow Packs --- .../nip51Lists/peopleList/FollowListsState.kt | 173 +++++++++- .../amethyst/ui/navigation/AppNavigation.kt | 10 +- .../amethyst/ui/navigation/routes/Routes.kt | 7 +- .../lists/display/ListActionsMenuButton.kt | 104 ++++++ .../lists/display/ShowUserSuggestions.kt | 281 +++++++++++++++++ .../display/{ => lists}/PeopleListScreen.kt | 298 +----------------- .../{ => lists}/PeopleListViewModel.kt | 2 +- .../lists/display/packs/FollowPackScreen.kt | 297 +++++++++++++++++ .../display/packs/FollowPackViewModel.kt | 97 ++++++ .../lists/list/FollowPackViewModel.kt | 103 ++++++ .../lists/list/ListOfPeopleListFeedView.kt | 98 +++++- .../lists/list/ListOfPeopleListsScreen.kt | 118 ++----- .../loggedIn/lists/list/PeopleListItem.kt | 12 +- .../lists/list/PeopleListViewModel.kt | 103 ++++++ ...n.kt => FollowListAndPackAndUserScreen.kt} | 57 +--- .../FollowListAndPackAndUserView.kt | 209 ++++++++++++ .../lists/memberEdit/FollowPackAndUserItem.kt | 212 +++++++++++++ .../lists/memberEdit/PeopleListAndUserItem.kt | 2 +- .../lists/memberEdit/PeopleListAndUserView.kt | 104 ------ amethyst/src/main/res/values/strings.xml | 1 + 20 files changed, 1723 insertions(+), 565 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/{ => lists}/PeopleListScreen.kt (58%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/{ => lists}/PeopleListViewModel.kt (99%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/{PeopleListAndUserScreen.kt => FollowListAndPackAndUserScreen.kt} (67%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 788272c28..8c17c7b79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -30,8 +32,16 @@ import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.update +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.description +import com.vitorpamplona.quartz.nip51Lists.followList.person +import com.vitorpamplona.quartz.nip51Lists.followList.personFirst +import com.vitorpamplona.quartz.nip51Lists.followList.removePerson +import com.vitorpamplona.quartz.nip51Lists.followList.title +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -46,6 +56,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update +import java.util.UUID /** * Maintains several stateflows for each step in processing PeopleLists @@ -59,7 +70,7 @@ class FollowListsState( ) { val user = cache.getOrCreateUser(signer.pubKey) - fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.Companion.KIND, user.pubkeyHex) + fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex) val followListVersions = MutableStateFlow(0) @@ -83,7 +94,7 @@ class FollowListsState( .transformLatest { emitAll(it.updateFlow()) } .onStart { emit(followListNotes.value.events()) } .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) fun List.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet() @@ -112,6 +123,14 @@ class FollowListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + fun selectListFlow(selectedDTag: String) = + uiListFlow + .map { peopleLists -> + peopleLists.firstOrNull { list -> list.identifierTag == selectedDTag } + }.onStart { + emit(uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag }) + } + fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) @@ -140,4 +159,154 @@ class FollowListsState( fun forceRefresh() { followListVersions.update { it + 1 } } + + // -------------- + // Updating Lists + // -------------- + + fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier } + + fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent + + fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint()) + + fun Set.toUserTags() = map { it.toUserTag() } + + suspend fun addFollowList( + name: String, + desc: String?, + member: User? = null, + isPrivate: Boolean = false, + account: Account, + ) { + val newListTemplate = + FollowListEvent.build( + name = name, + people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), + dTag = UUID.randomUUID().toString(), + ) { + if (desc != null) description(desc) + } + + val newList = signer.sign(newListTemplate) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun renameFollowList( + newName: String, + followPack: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(followPack.identifierTag) + + val template = + listEvent.update { + title(newName) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun modifyFollowSetDescription( + newDescription: String, + followPack: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(followPack.identifierTag) + + val template = + listEvent.update { + description(newDescription) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun cloneFollowSet( + currentFollowPack: PeopleList, + customCloneName: String?, + customCloneDescription: String?, + account: Account, + ) { + val listEvent = getPeopleList(currentFollowPack.identifierTag) + + val template = + listEvent.update { + // new list + dTag(UUID.randomUUID().toString()) + + // updates names + if (customCloneName != null) title(customCloneName) + if (customCloneDescription != null) description(customCloneDescription) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun deleteFollowSet( + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent))) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + + suspend fun addUserToSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + person(user.pubkeyHex, user.bestRelayHint()) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun addUserFirstToSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + personFirst(user.pubkeyHex, user.bestRelayHint()) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun removeUserFromSet( + user: User, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + + val template = + followListEvent.update { + removePerson(user.pubkeyHex) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } } 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 1fe313d0f..c4ee7eaba 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 @@ -79,9 +79,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.EditPeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -127,8 +128,9 @@ fun AppNavigation( composable { NotificationScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } - composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } - composableFromBottomArgs { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } 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 4341b4644..f0f31c890 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 @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Room import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -56,7 +55,11 @@ sealed class Route { @Serializable object Lists : Route() - @Serializable data class PeopleListView( + @Serializable data class MyPeopleListView( + val dTag: String, + ) : Route() + + @Serializable data class MyFollowPackView( val dTag: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt new file mode 100644 index 000000000..9912ac0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt @@ -0,0 +1,104 @@ +/** + * 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.lists.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.StdPadding + +@Composable +fun ListActionsMenuButton( + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + val isActionListOpen = remember { mutableStateOf(false) } + + ClickableBox( + modifier = + StdPadding + .size(30.dp) + .border( + width = Dp.Hairline, + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ).background( + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ), + onClick = { isActionListOpen.value = true }, + ) { + VerticalDotsIcon() + ListActionsMenu( + onCloseMenu = { isActionListOpen.value = false }, + isOpen = isActionListOpen.value, + onBroadcastList = onBroadcastList, + onDeleteList = onDeleteList, + ) + } +} + +@Composable +fun ListActionsMenu( + onCloseMenu: () -> Unit, + isOpen: Boolean, + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + DropdownMenu( + expanded = isOpen, + onDismissRequest = onCloseMenu, + ) { + DropdownMenuItem( + text = { + Text("Broadcast List") + }, + onClick = { + onBroadcastList() + onCloseMenu() + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text("Delete List") + }, + onClick = { + onDeleteList() + onCloseMenu() + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt new file mode 100644 index 000000000..1e4f1c93a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ShowUserSuggestions.kt @@ -0,0 +1,281 @@ +/** + * 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.lists.display + +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +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.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.LightRedColor +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@Composable +fun RenderAddUserFieldAndSuggestions( + userSuggestions: UserSuggestionState, + hasUserFlow: (User) -> Flow, + addUserToSet: (User) -> Unit, + removeUserFromSet: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + UserSearchDataSourceSubscription(userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + userSuggestions.invalidateData() + } + } + } + + Spacer(HalfVertSpacer) + + var userName by remember(userSuggestions) { mutableStateOf(TextFieldValue(userSuggestions.currentWord.value)) } + val focusManager = LocalFocusManager.current + + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + userSuggestions.processCurrentWord(it.text) + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + userName = TextFieldValue("") + userSuggestions.processCurrentWord("") + focusManager.clearFocus() + }, + ) { + ClearTextIcon() + } + }, + ) + + ShowUserSuggestions( + userSuggestions = userSuggestions, + hasUserFlow = hasUserFlow, + onSelect = { user -> + addUserToSet(user) + userName = + userName.copy( + selection = TextRange(0, userName.text.length), + ) + }, + onDelete = { user -> + removeUserFromSet(user) + userName = + userName.copy( + selection = TextRange(0, userName.text.length), + ) + }, + accountViewModel = accountViewModel, + ) +} + +@Composable +fun ShowUserSuggestions( + userSuggestions: UserSuggestionState, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + Card( + modifier = Modifier.padding(start = 11.dp, end = 11.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = + Modifier + .heightIn(0.dp, 200.dp), + state = listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + + Spacer(StdVertSpacer) +} + +@Composable +fun DrawUser( + baseUser: User, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { onSelect(baseUser) }) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay( + baseUser, + accountViewModel = accountViewModel, + ) + HasUserTag(baseUser, hasUserFlow, onDelete) + } + + AboutDisplay(baseUser, accountViewModel) + } + } +} + +@Composable +private fun RowScope.HasUserTag( + baseUser: User, + hasUserFlow: (User) -> Flow, + onDelete: (User) -> Unit, +) { + val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false) + if (hasUserState) { + Spacer(StdHorzSpacer) + Text( + text = stringRes(id = R.string.in_the_list), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, + ) + Spacer(Modifier.weight(1f)) + IconButton( + modifier = Modifier.size(30.dp).padding(start = 10.dp), + onClick = { onDelete(baseUser) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringRes(id = R.string.remove), + modifier = Modifier.size(15.dp), + tint = LightRedColor, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index b330b0d51..707c33f80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -18,16 +18,10 @@ * 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.lists.display +package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -35,23 +29,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -62,60 +48,39 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ClearTextIcon -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.ListActionsMenuButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer -import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.SmallBorder -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -173,7 +138,7 @@ fun PeopleListScreen( } @Composable -fun TopAppTabs( +private fun TopAppTabs( viewModel: PeopleListViewModel, pagerState: PagerState, ) { @@ -212,7 +177,7 @@ fun TopAppTabs( } @Composable -fun TitleAndDescription(viewModel: PeopleListViewModel) { +private fun TitleAndDescription(viewModel: PeopleListViewModel) { val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() selectedSetState.value?.let { selectedSet -> Text( @@ -255,70 +220,20 @@ private fun RenderAddUserFieldAndSuggestions( pagerState: PagerState, accountViewModel: AccountViewModel, ) { - UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel) - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { - viewModel.userSuggestions.invalidateData() - } - } - launch(Dispatchers.IO) { - LocalCache.live.deletedEventBundles.collect { - viewModel.userSuggestions.invalidateData() - } - } - } - - Spacer(HalfVertSpacer) - - var userName by remember(viewModel) { mutableStateOf(TextFieldValue(viewModel.userSuggestions.currentWord.value)) } - val focusManager = LocalFocusManager.current - - OutlinedTextField( - label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, - modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(), - value = userName, - onValueChange = { - userName = it - viewModel.userSuggestions.processCurrentWord(it.text) - }, - singleLine = true, - trailingIcon = { - IconButton( - onClick = { - userName = TextFieldValue("") - viewModel.userSuggestions.processCurrentWord("") - focusManager.clearFocus() - }, - ) { - ClearTextIcon() - } - }, - ) - - ShowUserSuggestions( - userSuggestions = viewModel.userSuggestions, + RenderAddUserFieldAndSuggestions( + viewModel.userSuggestions, hasUserFlow = { user -> viewModel.hasUserFlow(user, pagerState.currentPage == 1) }, - onSelect = { user -> + addUserToSet = { user -> accountViewModel.launchSigner { viewModel.addUserToSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, - onDelete = { user -> + removeUserFromSet = { user -> accountViewModel.launchSigner { viewModel.removeUserFromSet(user, pagerState.currentPage == 1) } - userName = - userName.copy( - selection = TextRange(0, userName.text.length), - ) }, accountViewModel = accountViewModel, ) @@ -365,7 +280,7 @@ private fun PeopleListPager( @Composable @Preview(device = "spec:width=2160px,height=2940px,dpi=440") -fun FollowSetListViewPreview() { +private fun PeopleListViewPreview() { val accountViewModel = mockAccountViewModel() val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") @@ -433,130 +348,7 @@ fun FollowSetListViewPreview() { } @Composable -fun ShowUserSuggestions( - userSuggestions: UserSuggestionState, - hasUserFlow: (User) -> Flow, - onSelect: (User) -> Unit, - onDelete: (User) -> Unit, - accountViewModel: AccountViewModel, -) { - val listState = rememberLazyListState() - - AnimateOnNewSearch(userSuggestions, listState) - - val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) - - if (suggestions.isNotEmpty()) { - Card( - modifier = Modifier.padding(start = 11.dp, end = 11.dp), - elevation = cardElevation(5.dp), - shape = PopupUpEffect, - ) { - LazyColumn( - contentPadding = PaddingValues(top = 10.dp), - modifier = - Modifier - .heightIn(0.dp, 200.dp), - state = listState, - ) { - itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> - DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel) - - HorizontalDivider( - thickness = DividerThickness, - ) - } - } - } - } - - Spacer(StdVertSpacer) -} - -@Composable -private fun DrawUser( - baseUser: User, - hasUserFlow: (User) -> Flow, - onSelect: (User) -> Unit, - onDelete: (User) -> Unit, - accountViewModel: AccountViewModel, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = { onSelect(baseUser) }) - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay( - baseUser, - accountViewModel = accountViewModel, - ) - HasUserTag(baseUser, hasUserFlow, onDelete) - } - - AboutDisplay(baseUser, accountViewModel) - } - } -} - -@Composable -fun RowScope.HasUserTag( - baseUser: User, - hasUserFlow: (User) -> Flow, - onDelete: (User) -> Unit, -) { - val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false) - if (hasUserState) { - Spacer(StdHorzSpacer) - Text( - text = stringRes(id = R.string.in_the_list), - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, - ) - Spacer(Modifier.weight(1f)) - IconButton( - modifier = Modifier.size(30.dp).padding(start = 10.dp), - onClick = { onDelete(baseUser) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = Modifier.size(15.dp), - tint = LightRedColor, - ) - } - } -} - -@Composable -fun ListActionsMenuButton( +private fun ListActionsMenuButton( viewModel: PeopleListViewModel, accountViewModel: AccountViewModel, nav: INav, @@ -577,67 +369,3 @@ fun ListActionsMenuButton( }, ) } - -@Composable -fun ListActionsMenuButton( - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - val isActionListOpen = remember { mutableStateOf(false) } - - ClickableBox( - modifier = - StdPadding - .size(30.dp) - .border( - width = Dp.Hairline, - color = ButtonDefaults.filledTonalButtonColors().containerColor, - shape = ButtonBorder, - ).background( - color = ButtonDefaults.filledTonalButtonColors().containerColor, - shape = ButtonBorder, - ), - onClick = { isActionListOpen.value = true }, - ) { - VerticalDotsIcon() - ListActionsMenu( - onCloseMenu = { isActionListOpen.value = false }, - isOpen = isActionListOpen.value, - onBroadcastList = onBroadcastList, - onDeleteList = onDeleteList, - ) - } -} - -@Composable -fun ListActionsMenu( - onCloseMenu: () -> Unit, - isOpen: Boolean, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - DropdownMenu( - expanded = isOpen, - onDismissRequest = onCloseMenu, - ) { - DropdownMenuItem( - text = { - Text("Broadcast List") - }, - onClick = { - onBroadcastList() - onCloseMenu() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text("Delete List") - }, - onClick = { - onDeleteList() - onCloseMenu() - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index d36a0fb86..73745a9ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -18,7 +18,7 @@ * 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.lists.display +package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt new file mode 100644 index 000000000..f8c44b093 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -0,0 +1,297 @@ +/** + * 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.lists.display.packs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.ListActionsMenuButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FollowPackScreen( + selectedDTag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: FollowPackViewModel = viewModel() + viewModel.init(accountViewModel.account, selectedDTag) + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + TitleAndDescription(viewModel) + }, + navigationIcon = { + IconButton(nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + ListActionsMenuButton(viewModel, accountViewModel, nav) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + }, + ) { padding -> + ListViewAndEditColumn( + viewModel = viewModel, + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun TitleAndDescription(viewModel: FollowPackViewModel) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + Text( + text = selectedSet.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ListViewAndEditColumn( + viewModel: FollowPackViewModel, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = modifier) { + PeopleListPager( + viewModel = viewModel, + modifier = Modifier.weight(1f), + onDeleteUser = { user -> + accountViewModel.launchSigner { + viewModel.removeUserFromSet(user) + } + }, + accountViewModel = accountViewModel, + nav = nav, + ) + + RenderAddUserFieldAndSuggestions(viewModel, accountViewModel) + } +} + +@Composable +private fun RenderAddUserFieldAndSuggestions( + viewModel: FollowPackViewModel, + accountViewModel: AccountViewModel, +) { + RenderAddUserFieldAndSuggestions( + viewModel.userSuggestions, + hasUserFlow = { user -> + viewModel.hasUserFlow(user) + }, + addUserToSet = { user -> + accountViewModel.launchSigner { + viewModel.addUserToSet(user) + } + }, + removeUserFromSet = { user -> + accountViewModel.launchSigner { + viewModel.removeUserFromSet(user) + } + }, + accountViewModel = accountViewModel, + ) +} + +@Composable +private fun PeopleListPager( + viewModel: FollowPackViewModel, + modifier: Modifier, + onDeleteUser: (User) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + PeopleListView( + memberList = selectedSet.publicMembersList, + onDeleteUser = onDeleteUser, + modifier = modifier, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun ListActionsMenuButton( + viewModel: FollowPackViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + ListActionsMenuButton( + onBroadcastList = { + accountViewModel.launchSigner { + viewModel.loadNote()?.let { updatedSetNote -> + accountViewModel.broadcast(updatedSetNote) + } + } + }, + onDeleteList = { + accountViewModel.launchSigner { + viewModel.deleteFollowSet() + } + nav.popBack() + }, + ) +} + +@Composable +@Preview(device = "spec:width=2160px,height=2940px,dpi=440") +fun FollowPackViewPreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf") + + ThemeComparisonRow { + Column { + PeopleListView( + memberList = persistentListOf(user1, user2, user3), + onDeleteUser = { user -> }, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + + Spacer(HalfVertSpacer) + + var userName by remember { mutableStateOf("") } + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = + Modifier + .padding(horizontal = Size10dp) + .fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = {}, + ) { + ClearTextIcon() + } + }, + ) + + Card( + modifier = Modifier.padding(horizontal = 10.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = Modifier.heightIn(0.dp, 200.dp), + ) { + itemsIndexed(persistentListOf(user1, user2, user3), key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser( + baseUser, + { MutableStateFlow(false) }, + {}, + {}, + accountViewModel, + ) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt new file mode 100644 index 000000000..c7289a338 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -0,0 +1,97 @@ +/** + * 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.lists.display.packs + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +@Stable +class FollowPackViewModel : ViewModel() { + lateinit var account: Account + lateinit var userSuggestions: UserSuggestionState + + var userSuggestionFocus by mutableStateOf(null) + + val selectedDTag = MutableStateFlow("") + + @OptIn(ExperimentalCoroutinesApi::class) + val selectedList = + selectedDTag + .transformLatest { + emitAll( + account.followLists.selectListFlow(it).flowOn(Dispatchers.IO), + ) + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun init( + account: Account, + selectedDTag: String, + ) { + if (!this::account.isInitialized || this.account != account) { + this.account = account + this.userSuggestions = UserSuggestionState(account, false) + } + + this.selectedDTag.tryEmit(selectedDTag) + } + + suspend fun deleteFollowSet() { + account.followLists.deleteFollowSet(selectedDTag.value, account) + } + + fun loadNote(): AddressableNote? = account.followLists.getPeopleListNote(selectedDTag.value) + + suspend fun removeUserFromSet(user: User) { + account.followLists.removeUserFromSet(user, selectedDTag.value, account) + } + + suspend fun addUserToSet(user: User) { + account.followLists.addUserToSet(user, selectedDTag.value, account) + } + + fun hasUserFlow(user: User): Flow = + selectedList.map { + if (it == null) { + false + } else { + user in it.publicMembers + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt new file mode 100644 index 000000000..5fa98ee4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt @@ -0,0 +1,103 @@ +/** + * 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.lists.list + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class FollowPackViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun listFlow() = accountViewModel.account.followLists.uiListFlow + + fun addItem( + title: String, + description: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.addFollowList( + name = title, + desc = description, + account = accountViewModel.account, + ) + } + } + + fun openItem(dTag: String) = Route.MyFollowPackView(dTag) + + fun renameItem( + followSet: PeopleList, + newValue: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.renameFollowList( + newName = newValue, + followPack = followSet, + account = accountViewModel.account, + ) + } + } + + fun changeItemDescription( + followSet: PeopleList, + newDescription: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.modifyFollowSetDescription( + newDescription = newDescription, + followPack = followSet, + account = accountViewModel.account, + ) + } + } + + fun cloneItem( + followSet: PeopleList, + customName: String?, + customDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.cloneFollowSet( + currentFollowPack = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + } + } + + fun deleteItem(followSet: PeopleList) { + accountViewModel.launchSigner { + accountViewModel.account.followLists.deleteFollowSet( + identifierTag = followSet.identifierTag, + account = accountViewModel.account, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt index 2cf015bfc..448dae60d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt @@ -22,40 +22,44 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list 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.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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import kotlinx.coroutines.flow.StateFlow +import com.vitorpamplona.amethyst.ui.theme.grayText @Composable fun AllPeopleListFeedView( - listFlow: StateFlow>, - onOpenItem: (String) -> Unit = {}, - onRenameItem: (targetSet: PeopleList, newName: String) -> Unit, - onItemDescriptionChange: (peopleList: PeopleList, newDescription: String?) -> Unit, - onItemClone: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, - onDeleteItem: (peopleList: PeopleList) -> Unit, + peopleListModel: PeopleListViewModel, + followPackModel: FollowPackViewModel, + nav: INav, ) { - val followSetFeedState by listFlow.collectAsStateWithLifecycle() + val peopleListFeedState by peopleListModel.listFlow().collectAsStateWithLifecycle() + val followPackFeedState by followPackModel.listFlow().collectAsStateWithLifecycle() - if (followSetFeedState.isEmpty()) { + if (peopleListFeedState.isEmpty() && followPackFeedState.isEmpty()) { AllPeopleListFeedEmpty( message = stringRes(R.string.follow_set_empty_feed_msg), ) @@ -64,15 +68,75 @@ fun AllPeopleListFeedView( state = rememberLazyListState(), contentPadding = FeedPadding, ) { - itemsIndexed(followSetFeedState, key = { _, item -> item.identifierTag }) { _, list -> + stickyHeader { + Row( + modifier = MaxWidthWithHorzPadding, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.follow_sets), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.follow_sets_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_set_creation_dialog_title, + onAddSet = peopleListModel::addItem, + ) + } + } + itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> PeopleListItem( modifier = Modifier.fillMaxSize().animateItem(), - peopleList = list, - onClick = { onOpenItem(list.identifierTag) }, - onRename = { onRenameItem(list, it) }, - onDescriptionChange = { newDescription -> onItemDescriptionChange(list, newDescription) }, - onClone = { cloneName, cloneDescription -> onItemClone(list, cloneName, cloneDescription) }, - onDelete = { onDeleteItem(list) }, + peopleList = followSet, + onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) }, + onRename = { peopleListModel.renameItem(followSet, it) }, + onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) }, + onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) }, + onDelete = { peopleListModel.deleteItem(followSet) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + stickyHeader { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.discover_follows), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.discover_follows_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_pack_creation_dialog_title, + onAddSet = followPackModel::addItem, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> + PeopleListItem( + modifier = Modifier.fillMaxSize().animateItem(), + peopleList = followSet, + onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) }, + onRename = { followPackModel.renameItem(followSet, it) }, + onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) }, + onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) }, + onDelete = { followPackModel.deleteItem(followSet) }, ) HorizontalDivider(thickness = DividerThickness) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt index bb13ab681..7b63414e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt @@ -21,108 +21,52 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold 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.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList 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 kotlinx.coroutines.flow.StateFlow +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp @Composable fun ListOfPeopleListsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - ListOfPeopleListsScreen( - listFlow = accountViewModel.account.peopleLists.uiListFlow, - addItem = { title: String, description: String? -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addFollowList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - }, - openItem = { - nav.nav(Route.PeopleListView(it)) - }, - renameItem = { followSet, newValue -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.renameFollowList( - newName = newValue, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - changeItemDescription = { followSet, newDescription -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.modifyFollowSetDescription( - newDescription = newDescription, - peopleList = followSet, - account = accountViewModel.account, - ) - } - }, - cloneItem = { followSet, customName, customDescription -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.cloneFollowSet( - currentPeopleList = followSet, - customCloneName = customName, - customCloneDescription = customDescription, - account = accountViewModel.account, - ) - } - }, - deleteItem = { followSet -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.deleteFollowSet( - identifierTag = followSet.identifierTag, - account = accountViewModel.account, - ) - } - }, - nav, - ) + val list: PeopleListViewModel = viewModel() + list.init(accountViewModel) + + val pack: FollowPackViewModel = viewModel() + pack.init(accountViewModel) + + ListOfPeopleListsScreen(list, pack, nav) } @Composable fun ListOfPeopleListsScreen( - listFlow: StateFlow>, - addItem: (title: String, description: String?) -> Unit, - openItem: (identifier: String) -> Unit, - renameItem: (peopleList: PeopleList, newName: String) -> Unit, - changeItemDescription: (peopleList: PeopleList, newDescription: String?) -> Unit, - cloneItem: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, - deleteItem: (peopleList: PeopleList) -> Unit, + list: PeopleListViewModel, + pack: FollowPackViewModel, nav: INav, ) { Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.my_lists), nav::popBack) }, - floatingActionButton = { - PeopleListFabsAndMenu( - onAddSet = addItem, - ) - }, ) { paddingValues -> Column( Modifier @@ -131,41 +75,35 @@ fun ListOfPeopleListsScreen( bottom = paddingValues.calculateBottomPadding(), ).fillMaxHeight(), ) { - AllPeopleListFeedView( - listFlow = listFlow, - onOpenItem = openItem, - onRenameItem = renameItem, - onItemDescriptionChange = changeItemDescription, - onItemClone = cloneItem, - onDeleteItem = deleteItem, - ) + AllPeopleListFeedView(list, pack, nav) } } } @Composable -private fun PeopleListFabsAndMenu(onAddSet: (name: String, description: String?) -> Unit) { +fun PeopleListFabsAndMenu( + title: Int = R.string.follow_set_creation_dialog_title, + onAddSet: (name: String, description: String?) -> Unit, +) { val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - ExtendedFloatingActionButton( - text = { - Text(text = stringRes(R.string.follow_set_create_btn_label)) + OutlinedButton( + onClick = { + isSetAdditionDialogOpen.value = true }, - icon = { + ) { + Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null, ) - }, - onClick = { - isSetAdditionDialogOpen.value = true - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) + Text(stringRes(R.string.follow_set_create_btn_label)) + } + } if (isSetAdditionDialogOpen.value) { NewPeopleListCreationDialog( + title = title, onDismiss = { isSetAdditionDialogOpen.value = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index bc671c5b6..87e71d986 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -162,7 +162,7 @@ fun PeopleListItem( peopleList: PeopleList, onClick: () -> Unit, onRename: (String) -> Unit, - onDescriptionChange: (String?) -> Unit, + onDescriptionChange: (String) -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit, onDelete: () -> Unit, ) { @@ -279,7 +279,7 @@ private fun PeopleListOptionsButton( peopleListName: String, peopleListDescription: String?, onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListDescriptionChange: (String) -> Unit, onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListDelete: () -> Unit, ) { @@ -310,7 +310,7 @@ private fun ListOptionsMenu( setName: String, setDescription: String?, onListRename: (String) -> Unit, - onListDescriptionChange: (String?) -> Unit, + onListDescriptionChange: (String) -> Unit, onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, @@ -470,9 +470,9 @@ private fun ListModifyDescriptionDialog( modifier: Modifier = Modifier, currentDescription: String?, onDismissDialog: () -> Unit, - onModifyDescription: (String?) -> Unit, + onModifyDescription: (String) -> Unit, ) { - val updatedDescription = remember { mutableStateOf(null) } + val updatedDescription = remember { mutableStateOf("") } val modifyIndicatorLabel = if (currentDescription == null) { @@ -509,7 +509,7 @@ private fun ListModifyDescriptionDialog( fontStyle = FontStyle.Italic, ) TextField( - value = updatedDescription.value ?: "", + value = updatedDescription.value, onValueChange = { updatedDescription.value = it }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt new file mode 100644 index 000000000..fd036ed02 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt @@ -0,0 +1,103 @@ +/** + * 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.lists.list + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class PeopleListViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + } + + fun listFlow() = accountViewModel.account.peopleLists.uiListFlow + + fun addItem( + title: String, + description: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addFollowList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } + } + + fun openItem(dTag: String) = Route.MyPeopleListView(dTag) + + fun renameItem( + followSet: PeopleList, + newValue: String, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.renameFollowList( + newName = newValue, + peopleList = followSet, + account = accountViewModel.account, + ) + } + } + + fun changeItemDescription( + followSet: PeopleList, + newDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.modifyFollowSetDescription( + newDescription = newDescription, + peopleList = followSet, + account = accountViewModel.account, + ) + } + } + + fun cloneItem( + followSet: PeopleList, + customName: String?, + customDescription: String?, + ) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.cloneFollowSet( + currentPeopleList = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + } + } + + fun deleteItem(followSet: PeopleList) { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.deleteFollowSet( + identifierTag = followSet.identifierTag, + account = accountViewModel.account, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt index d86ea40f5..9333c185f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserScreen.kt @@ -26,15 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.recalculateWindowInsets -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -49,13 +42,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewPeopleListCreationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditPeopleListScreen( +fun FollowListAndPackAndUserScreen( userToAddOrRemove: HexKey, accountViewModel: AccountViewModel, nav: INav, @@ -72,22 +64,19 @@ fun EditPeopleListScreen( } userBase?.let { - EditPeopleListScreen(it, accountViewModel, nav) + FollowListAndPackAndUserScreen(it, accountViewModel, nav) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditPeopleListScreen( +fun FollowListAndPackAndUserScreen( userToAddOrRemove: User, accountViewModel: AccountViewModel, nav: INav, ) { Scaffold( modifier = Modifier.fillMaxSize().recalculateWindowInsets(), - floatingActionButton = { - PeopleListAndUserFab(accountViewModel) - }, topBar = { val userName by observeUserName(userToAddOrRemove, accountViewModel) TopBarWithBackButton( @@ -105,45 +94,7 @@ fun EditPeopleListScreen( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - PeopleListAndUserView(userToAddOrRemove, accountViewModel, nav) + FollowListAndPackAndUserView(userToAddOrRemove, accountViewModel, nav) } } } - -@Composable -private fun PeopleListAndUserFab(accountViewModel: AccountViewModel) { - var isOpen by remember { mutableStateOf(false) } - - ExtendedFloatingActionButton( - text = { - Text(text = stringRes(R.string.follow_set_create_btn_label)) - }, - icon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = null, - ) - }, - onClick = { isOpen = !isOpen }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) - - if (isOpen) { - NewPeopleListCreationDialog( - onDismiss = { - isOpen = false - }, - onCreateList = { name, description -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addFollowList( - listName = name, - listDescription = description, - account = accountViewModel.account, - ) - } - isOpen = false - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt new file mode 100644 index 000000000..74a4ff24b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt @@ -0,0 +1,209 @@ +/** + * 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.lists.memberEdit + +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.fillMaxHeight +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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListFabsAndMenu +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@Composable +fun FollowListAndPackAndUserView( + userToAddOrRemove: User, + accountViewModel: AccountViewModel, + nav: INav, +) { + val followSetsState by accountViewModel.account.peopleLists.uiListFlow + .collectAsStateWithLifecycle() + val followPackFeedState by accountViewModel.account.followLists.uiListFlow + .collectAsStateWithLifecycle() + + if (followSetsState.isEmpty() && followPackFeedState.isEmpty()) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.5f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(text = stringRes(R.string.follow_set_empty_dialog_msg)) + Spacer(modifier = StdVertSpacer) + } + } else { + val userName by observeUserName(userToAddOrRemove, accountViewModel) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + stickyHeader { + Row( + modifier = MaxWidthWithHorzPadding, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.follow_sets), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.follow_sets_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_set_creation_dialog_title, + onAddSet = { title, description -> + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addFollowList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } + }, + ) + } + } + itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list -> + PeopleListAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), + userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), + onRemoveUser = { + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.removeUserFromSet( + userToAddOrRemove, + isPrivate = list.privateMembers.contains(userToAddOrRemove), + list.identifierTag, + accountViewModel.account, + ) + } + }, + privateMemberSize = list.privateMembers.size, + publicMemberSize = list.publicMembers.size, + onAddUserToList = { userShouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.peopleLists.addUserToSet( + userToAddOrRemove, + list.identifierTag, + userShouldBePrivate, + accountViewModel.account, + ) + } + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + stickyHeader { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.discover_follows), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.discover_follows_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + PeopleListFabsAndMenu( + title = R.string.follow_pack_creation_dialog_title, + onAddSet = { title, description -> + accountViewModel.launchSigner { + accountViewModel.account.followLists.addFollowList( + name = title, + desc = description, + account = accountViewModel.account, + ) + } + }, + ) + } + } + itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list -> + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + isMember = list.publicMembers.contains(userToAddOrRemove), + onRemoveUser = { + accountViewModel.launchSigner { + accountViewModel.account.followLists.removeUserFromSet( + userToAddOrRemove, + list.identifierTag, + accountViewModel.account, + ) + } + }, + memberSize = list.publicMembers.size, + onAddUserToList = { + accountViewModel.launchSigner { + accountViewModel.account.followLists.addUserToSet( + userToAddOrRemove, + list.identifierTag, + accountViewModel.account, + ) + } + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt new file mode 100644 index 000000000..2c8b0a7bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowPackAndUserItem.kt @@ -0,0 +1,212 @@ +/** + * 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.lists.memberEdit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10 +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun FollowPackAndUserMemberPreview() { + ThemeComparisonColumn { + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + isMember = true, + memberSize = 2, + onAddUserToList = {}, + onRemoveUser = {}, + ) + } +} + +@Preview +@Composable +fun FollowPackAndUserNotMemberPreview() { + ThemeComparisonColumn { + FollowPackAndUserItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + isMember = false, + memberSize = 2, + onAddUserToList = {}, + onRemoveUser = {}, + ) + } +} + +@Composable +fun FollowPackAndUserItem( + modifier: Modifier = Modifier, + listHeader: String, + userName: String, + isMember: Boolean, + memberSize: Int, + onAddUserToList: () -> Unit, + onRemoveUser: () -> Unit, +) { + ListItem( + modifier = modifier, + headlineContent = { + Text( + text = listHeader, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + UserStatusInList(userName, isMember) + }, + leadingContent = { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Groups, + contentDescription = stringRes(R.string.follow_set_icon_description), + modifier = Size50ModifierOffset10, + ) + DisplayParticipantNumberAndStatus( + modifier = Modifier.align(Alignment.BottomCenter), + privateMembersSize = 0, + publicMembersSize = memberSize, + ) + } + }, + trailingContent = { + UserAdditionOptions(isMember, onAddUserToList, onRemoveUser) + }, + ) +} + +@Composable +private fun UserStatusInList( + userName: String, + isMember: Boolean, +) { + Row( + modifier = HalfHalfVertPadding, + horizontalArrangement = SpacedBy5dp, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = + if (isMember) { + stringRes(R.string.follow_set_public_presence_indicator, userName) + } else { + stringRes(R.string.follow_set_absence_indicator2, userName) + } + + val icon = + if (isMember) { + Icons.Outlined.Public + } else { + Icons.Outlined.RemoveCircleOutline + } + + Icon( + imageVector = icon, + contentDescription = text, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} + +@Composable +private fun UserAdditionOptions( + isUserInList: Boolean, + onAddUserToList: () -> Unit, + onRemoveUser: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton( + onClick = { + if (isUserInList) { + onRemoveUser() + } else { + onAddUserToList() + } + }, + modifier = + Modifier + .background( + color = + if (isUserInList) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(percent = 80), + ), + ) { + if (isUserInList) { + Icon( + imageVector = Icons.Filled.PersonRemove, + contentDescription = stringRes(R.string.remove_user_from_the_list), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + Icon( + imageVector = Icons.Filled.PersonAdd, + contentDescription = stringRes(R.string.add_user_to_the_list), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt index 70acacfce..d66a4b1b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt @@ -139,7 +139,7 @@ fun PeopleListAndUserItem( } @Composable -fun UserStatusInList( +private fun UserStatusInList( userName: String, userIsPrivateMember: Boolean, userIsPublicMember: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt deleted file mode 100644 index b07704762..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserView.kt +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit - -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.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -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.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -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.StdVertSpacer - -@Composable -fun PeopleListAndUserView( - userToAddOrRemove: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val followSetsState by accountViewModel.account.peopleLists.uiListFlow - .collectAsStateWithLifecycle() - - if (followSetsState.isEmpty()) { - Column( - Modifier - .fillMaxWidth() - .fillMaxHeight(0.5f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = stringRes(R.string.follow_set_empty_dialog_msg)) - Spacer(modifier = StdVertSpacer) - } - } else { - val userName by observeUserName(userToAddOrRemove, accountViewModel) - - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list -> - PeopleListAndUserItem( - modifier = Modifier.fillMaxWidth(), - listHeader = list.title, - userName = userName, - userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), - userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), - onRemoveUser = { - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.removeUserFromSet( - userToAddOrRemove, - isPrivate = list.privateMembers.contains(userToAddOrRemove), - list.identifierTag, - accountViewModel.account, - ) - } - }, - privateMemberSize = list.privateMembers.size, - publicMemberSize = list.publicMembers.size, - onAddUserToList = { userShouldBePrivate -> - accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addUserToSet( - userToAddOrRemove, - list.identifierTag, - userShouldBePrivate, - accountViewModel.account, - ) - } - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - } - } -} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f3c18eb54..08bee6659 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -549,6 +549,7 @@ New list with %1$s membership Creates a new follow set, and adds %1$s as a %2$s member. New Follow List + New Follow Pack Copy/Clone Follow List Modify description This list doesn\'t have a description