enhancement: user tags and roles management • iteration 2 (#225)

* exclude special tags from user detail

even after creating new ones

* prevent creation of tags with same name of an existing tag (case-insensitive)

* load tags for main post in post detail screen

* rename "User tags" to "User tags and roles"

* introduce headers in tag list
This commit is contained in:
Dieguitux 2025-01-07 08:45:49 +01:00 committed by GitHub
parent 67fbd75acc
commit 8bd1e7b0ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 197 additions and 65 deletions

View File

@ -31,11 +31,14 @@ import com.livefast.eattrash.raccoonforlemmy.core.appearance.theme.Spacing
import com.livefast.eattrash.raccoonforlemmy.core.appearance.theme.toTypography
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.SettingsColorRow
import com.livefast.eattrash.raccoonforlemmy.core.l10n.LocalStrings
import com.livefast.eattrash.raccoonforlemmy.core.utils.ValidationError
import com.livefast.eattrash.raccoonforlemmy.core.utils.toReadableMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditUserTagDialog(
title: String,
titleError: ValidationError? = null,
value: String = "",
canEditName: Boolean = true,
color: Color = MaterialTheme.colorScheme.primary,
@ -96,6 +99,15 @@ fun EditUserTagDialog(
},
textStyle = typography.bodyMedium,
value = textFieldValue,
isError = titleError != null,
supportingText = {
if (titleError != null) {
Text(
text = titleError.toReadableMessage(),
color = MaterialTheme.colorScheme.error,
)
}
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,

View File

@ -511,6 +511,8 @@ interface Strings {
val userInfoModerates: String @Composable get
val userPostScore: String @Composable get
val userTagColor: String @Composable get
val userTagsRegularSectionTitle: String @Composable get
val userTagsSpecialSectionTitle: String @Composable get
val userTagsTitle: String @Composable get
suspend fun inboxNotificationTitle(): String

View File

@ -456,7 +456,7 @@
<string name="user_info_admin">administrator</string>
<string name="user_info_moderates">Moderator of</string>
<string name="user_tag_color">Tag color</string>
<string name="user_tags_title">User tags</string>
<string name="user_tags_title">User tags and roles</string>
<string name="action_go_back">Go back</string>
<string name="action_open_side_menu">Open side menu</string>
<string name="action_close_side_menu">Close side menu</string>
@ -510,6 +510,8 @@
<string name="swipe_action_start_two">Start side (step 2)</string>
<string name="swipe_action_end_one">End side(step 1)</string>
<string name="swipe_action_end_two">End side (step 2)</string>
<string name="user_tags_special_section_title">Special</string>
<string name="user_tags_regular_section_title">Regular</string>
<plurals name="inbox_notification_content">
<item quantity="one">There is %1$d unread item</item>

View File

@ -516,6 +516,8 @@ import raccoonforlemmy.shared.generated.resources.user_info_admin
import raccoonforlemmy.shared.generated.resources.user_info_moderates
import raccoonforlemmy.shared.generated.resources.user_post_score
import raccoonforlemmy.shared.generated.resources.user_tag_color
import raccoonforlemmy.shared.generated.resources.user_tags_regular_section_title
import raccoonforlemmy.shared.generated.resources.user_tags_special_section_title
import raccoonforlemmy.shared.generated.resources.user_tags_title
internal class SharedStrings : Strings {
@ -1535,6 +1537,10 @@ internal class SharedStrings : Strings {
@Composable get() = stringResource(Res.string.user_post_score)
override val userTagColor: String
@Composable get() = stringResource(Res.string.user_tag_color)
override val userTagsRegularSectionTitle: String
@Composable get() = stringResource(Res.string.user_tags_regular_section_title)
override val userTagsSpecialSectionTitle: String
@Composable get() = stringResource(Res.string.user_tags_special_section_title)
override val userTagsTitle: String
@Composable get() = stringResource(Res.string.user_tags_title)

View File

@ -28,6 +28,7 @@ import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.repository.LemmyItemCa
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.repository.LemmyValueCache
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.repository.PostRepository
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.repository.SiteRepository
import com.livefast.eattrash.raccoonforlemmy.domain.lemmy.repository.UserTagHelper
import com.livefast.eattrash.raccoonforlemmy.unit.postdetail.utils.populateLoadMoreComments
import com.livefast.eattrash.raccoonforlemmy.unit.postdetail.utils.sortToNestedOrder
import kotlinx.coroutines.Dispatchers
@ -59,6 +60,7 @@ class PostDetailViewModel(
private val settingsRepository: SettingsRepository,
private val accountRepository: AccountRepository,
private val userTagRepository: UserTagRepository,
private val userTagHelper: UserTagHelper,
private val shareHelper: ShareHelper,
private val notificationCenter: NotificationCenter,
private val hapticFeedback: HapticFeedback,
@ -228,11 +230,16 @@ class PostDetailViewModel(
val auth = identityRepository.authToken.value
val updatedPost =
postRepository.get(
id = postId,
auth = auth,
instance = otherInstance,
)
postRepository
.get(
id = postId,
auth = auth,
instance = otherInstance,
)?.let {
with(userTagHelper) {
it.copy(creator = it.creator.withTags())
}
}
if (updatedPost != null) {
updateState {
it.copy(post = updatedPost)
@ -456,13 +463,18 @@ class PostDetailViewModel(
val auth = identityRepository.authToken.value
val post = uiState.value.post
val updatedPost =
postRepository.get(
id = post.id,
auth = auth,
instance = otherInstance,
) ?: post
postRepository
.get(
id = post.id,
auth = auth,
instance = otherInstance,
)?.let {
with(userTagHelper) {
it.copy(creator = it.creator.withTags())
}
}
updateState {
it.copy(post = updatedPost)
it.copy(post = updatedPost ?: post)
}
}
}
@ -594,8 +606,14 @@ class PostDetailViewModel(
private fun handlePostUpdate(post: PostModel) {
screenModelScope.launch {
val newPost =
post.let {
with(userTagHelper) {
it.copy(creator = it.creator.withTags())
}
}
updateState {
it.copy(post = post)
it.copy(post = newPost)
}
}
}

View File

@ -34,6 +34,7 @@ val postDetailModule =
settingsRepository = instance(),
accountRepository = instance(),
userTagRepository = instance(),
userTagHelper = instance(),
shareHelper = instance(),
notificationCenter = instance(),
hapticFeedback = instance(),

View File

@ -98,6 +98,7 @@ import com.livefast.eattrash.raccoonforlemmy.core.notifications.NotificationCent
import com.livefast.eattrash.raccoonforlemmy.core.notifications.di.getNotificationCenter
import com.livefast.eattrash.raccoonforlemmy.core.persistence.data.ActionOnSwipe
import com.livefast.eattrash.raccoonforlemmy.core.persistence.di.getSettingsRepository
import com.livefast.eattrash.raccoonforlemmy.core.utils.ValidationError
import com.livefast.eattrash.raccoonforlemmy.core.utils.VoteAction
import com.livefast.eattrash.raccoonforlemmy.core.utils.toIcon
import com.livefast.eattrash.raccoonforlemmy.core.utils.toLocalDp
@ -173,6 +174,7 @@ class UserDetailScreen(
var copyPostBottomSheet by remember { mutableStateOf<PostModel?>(null) }
var manageUserTagsBottomSheetOpened by remember { mutableStateOf(false) }
var addNewUserTagDialogOpen by remember { mutableStateOf(false) }
var addNewUserTagTitleError by remember { mutableStateOf<ValidationError?>(null) }
LaunchedEffect(model) {
model.effects
@ -1312,18 +1314,42 @@ class UserDetailScreen(
}
if (addNewUserTagDialogOpen) {
val forbiddenTagNames =
buildList {
addAll(
listOf(
LocalStrings.current.defaultTagAdmin,
LocalStrings.current.defaultTagBot,
LocalStrings.current.defaultTagCurrentUser,
LocalStrings.current.defaultTagModerator,
LocalStrings.current.defaultTagOriginalPoster,
).map { it.lowercase() },
)
addAll(
uiState.availableUserTags.map { it.name.lowercase() },
)
}
EditUserTagDialog(
title = LocalStrings.current.buttonAdd,
titleError = addNewUserTagTitleError,
value = "",
onClose = { name, color ->
addNewUserTagDialogOpen = false
if (name != null) {
model.reduce(
UserDetailMviModel.Intent.AddUserTag(
name = name,
color = color?.toArgb(),
),
)
addNewUserTagTitleError =
if (name?.lowercase() in forbiddenTagNames) {
ValidationError.InvalidField
} else {
null
}
if (addNewUserTagTitleError == null) {
addNewUserTagDialogOpen = false
if (name != null) {
model.reduce(
UserDetailMviModel.Intent.AddUserTag(
name = name,
color = color?.toArgb(),
),
)
}
}
},
)

View File

@ -688,9 +688,9 @@ class UserDetailViewModel(
val accountId = accountRepository.getActive()?.id ?: return@launch
val model = UserTagModel(name = name, color = color)
userTagRepository.create(model = model, accountId = accountId)
val allTags = userTagRepository.getAll(accountId)
val tags = userTagRepository.getAll(accountId).filter { !it.isSpecial }
updateState {
it.copy(availableUserTags = allTags)
it.copy(availableUserTags = tags)
}
}
}

View File

@ -33,7 +33,8 @@ interface UserTagsMviModel :
data class UiState(
val initial: Boolean = true,
val refreshing: Boolean = false,
val tags: List<UserTagModel> = emptyList(),
val specialTags: List<UserTagModel> = emptyList(),
val regularTags: List<UserTagModel> = emptyList(),
)
sealed interface Effect {

View File

@ -47,6 +47,7 @@ import com.livefast.eattrash.raccoonforlemmy.core.commonui.components.FloatingAc
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.CommunityItemPlaceholder
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.Option
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.OptionId
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.SettingsHeader
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.UserTagItem
import com.livefast.eattrash.raccoonforlemmy.core.commonui.lemmyui.di.getFabNestedScrollConnection
import com.livefast.eattrash.raccoonforlemmy.core.commonui.modals.EditUserTagDialog
@ -55,6 +56,7 @@ import com.livefast.eattrash.raccoonforlemmy.core.navigation.di.getNavigationCoo
import com.livefast.eattrash.raccoonforlemmy.core.persistence.data.UserTagModel
import com.livefast.eattrash.raccoonforlemmy.core.persistence.data.UserTagType
import com.livefast.eattrash.raccoonforlemmy.core.persistence.data.isSpecial
import com.livefast.eattrash.raccoonforlemmy.core.utils.ValidationError
import com.livefast.eattrash.raccoonforlemmy.core.utils.compose.onClick
import com.livefast.eattrash.raccoonforlemmy.unit.usertags.detail.UserTagDetailScreen
import kotlinx.coroutines.flow.launchIn
@ -75,7 +77,9 @@ class UserTagsScreen : Screen {
val lazyListState = rememberLazyListState()
val scope = rememberCoroutineScope()
var addTagDialogOpen by remember { mutableStateOf(false) }
var addTagTitleError by remember { mutableStateOf<ValidationError?>(null) }
var tagToEdit by remember { mutableStateOf<UserTagModel?>(null) }
var editTagTitleError by remember { mutableStateOf<ValidationError?>(null) }
LaunchedEffect(model) {
model.effects
@ -181,16 +185,50 @@ class UserTagsScreen : Screen {
CommunityItemPlaceholder()
}
}
items(uiState.tags) { tag ->
if (uiState.specialTags.isNotEmpty()) {
item {
SettingsHeader(
title = LocalStrings.current.userTagsSpecialSectionTitle,
)
}
}
items(uiState.specialTags) { tag ->
UserTagItem(
modifier = Modifier.fillMaxWidth(),
tag = tag,
options =
buildList {
this +=
Option(
id = OptionId.Edit,
text = LocalStrings.current.postActionEdit,
)
},
onOptionSelected = { optionId ->
when (optionId) {
OptionId.Edit -> tagToEdit = tag
else -> Unit
}
},
)
}
if (uiState.regularTags.isNotEmpty()) {
item {
SettingsHeader(
title = LocalStrings.current.userTagsRegularSectionTitle,
)
}
}
items(uiState.regularTags) { tag ->
UserTagItem(
modifier =
Modifier.fillMaxWidth().onClick(
onClick = {
if (!tag.isSpecial) {
tag.id?.also {
val screen = UserTagDetailScreen(it)
navigatorCoordinator.pushScreen(screen)
}
tag.id?.also {
val screen = UserTagDetailScreen(it)
navigatorCoordinator.pushScreen(screen)
}
},
),
@ -202,18 +240,15 @@ class UserTagsScreen : Screen {
id = OptionId.Edit,
text = LocalStrings.current.postActionEdit,
)
if (!tag.isSpecial) {
this +=
Option(
id = OptionId.Delete,
text = LocalStrings.current.commentActionDelete,
)
}
this +=
Option(
id = OptionId.Delete,
text = LocalStrings.current.commentActionDelete,
)
},
onOptionSelected = { optionId ->
when (optionId) {
OptionId.Edit ->
tagToEdit = tag
OptionId.Edit -> tagToEdit = tag
OptionId.Delete ->
tag.id?.also {
@ -225,7 +260,7 @@ class UserTagsScreen : Screen {
},
)
}
if (uiState.tags.isEmpty() && !uiState.initial) {
if ((uiState.specialTags + uiState.regularTags).isEmpty() && !uiState.initial) {
item {
Text(
modifier =
@ -247,41 +282,71 @@ class UserTagsScreen : Screen {
}
if (addTagDialogOpen) {
val forbiddenTagNames =
(uiState.specialTags + uiState.regularTags).map {
it.name.lowercase()
}
EditUserTagDialog(
title = LocalStrings.current.buttonAdd,
titleError = addTagTitleError,
value = "",
onClose = { name, color ->
addTagDialogOpen = false
if (name != null) {
model.reduce(
UserTagsMviModel.Intent.Add(
name = name,
color = color?.toArgb(),
),
)
addTagTitleError =
if (name?.lowercase() in forbiddenTagNames) {
ValidationError.InvalidField
} else {
null
}
if (addTagTitleError == null) {
addTagDialogOpen = false
if (name != null) {
model.reduce(
UserTagsMviModel.Intent.Add(
name = name,
color = color?.toArgb(),
),
)
}
}
},
)
}
if (tagToEdit != null) {
val forbiddenTagNames =
(uiState.specialTags + uiState.regularTags).mapNotNull {
if (it.id != tagToEdit?.id) {
it.name.lowercase()
} else {
null
}
}
EditUserTagDialog(
title = LocalStrings.current.postActionEdit,
titleError = editTagTitleError,
value = tagToEdit?.name.orEmpty(),
canEditName = tagToEdit?.isSpecial != true,
color = tagToEdit?.color?.let { Color(it) } ?: MaterialTheme.colorScheme.primary,
onClose = { name, color ->
val tagId = tagToEdit?.id
val type = tagToEdit?.type ?: UserTagType.Regular
tagToEdit = null
if (tagId != null && name != null) {
model.reduce(
UserTagsMviModel.Intent.Edit(
id = tagId,
name = name,
type = type,
color = color?.toArgb(),
),
)
editTagTitleError =
if (name?.lowercase() in forbiddenTagNames) {
ValidationError.InvalidField
} else {
null
}
if (editTagTitleError == null) {
val tagId = tagToEdit?.id
val type = tagToEdit?.type ?: UserTagType.Regular
tagToEdit = null
if (tagId != null && name != null) {
model.reduce(
UserTagsMviModel.Intent.Edit(
id = tagId,
name = name,
type = type,
color = color?.toArgb(),
),
)
}
}
},
)

View File

@ -53,18 +53,17 @@ internal class UserTagsViewModel(
refreshing = !initial,
)
}
val tags =
val (specialTags, regularTags) =
userTagRepository
.getAll(accountId)
.partition { it.isSpecial }
.let { (specialTags, regularTags) ->
val sortedSpecial = specialTags.sortedBy { it.name }
val sortedRegular = regularTags.sortedBy { it.name }
sortedSpecial + sortedRegular
specialTags.sortedBy { it.name } to regularTags.sortedBy { it.name }
}
updateState {
it.copy(
tags = tags,
specialTags = specialTags,
regularTags = regularTags,
initial = false,
refreshing = false,
)