diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/Spacings.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/Spacings.kt index fc81f4c82..8d01238a2 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/Spacings.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/Spacings.kt @@ -4,6 +4,10 @@ import androidx.compose.ui.unit.dp object Spacings { val ScreenHorizontalSpacing = 16.dp - + val ScreenHorizontalInnerSpacing = 8.dp val FabContentBottomPadding = 80.dp + + object Post { + val innerSpacing = 8.dp + } } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/DateFormats.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/DateFormats.kt new file mode 100644 index 000000000..23fe078b1 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/DateFormats.kt @@ -0,0 +1,16 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.date + +import android.icu.text.DateFormat +import android.icu.text.SimpleDateFormat + +enum class DateFormats(val format: DateFormat) { + DefaultDateAndTime(SimpleDateFormat.getDateTimeInstance( + SimpleDateFormat.MEDIUM, + SimpleDateFormat.SHORT + )), + OnlyTime(SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)), + EditTimestamp(SimpleDateFormat.getDateTimeInstance( + SimpleDateFormat.SHORT, + SimpleDateFormat.SHORT + )) +} diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/relativeTime.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/relativeTime.kt index 9ad08bc64..b609d9c23 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/relativeTime.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/date/relativeTime.kt @@ -1,6 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.date -import android.icu.text.SimpleDateFormat +import android.icu.text.DateFormat import android.text.format.DateUtils import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -27,8 +27,12 @@ fun getRelativeTime( to: Instant, clock: Clock = Clock.System, formatSeconds: Boolean = false, - showDate: Boolean = true + showDate: Boolean = true, + showDateAndTime: Boolean = false ): CharSequence { + val isShowDateParameterCombinationIllegal = showDateAndTime && !showDate + require(!isShowDateParameterCombinationIllegal) + val timeDifferenceBelowOneMinuteString = stringResource(id = R.string.time_difference_under_one_minute) @@ -41,8 +45,11 @@ fun getRelativeTime( if (timeDifference >= 1.days && !showDate) { emit( - SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT) - .format(Date.from(to.toJavaInstant())) + to.format(DateFormats.OnlyTime.format) + ) + } else if (timeDifference >= 1.days && showDateAndTime) { + emit( + to.format(DateFormats.DefaultDateAndTime.format) ) } else if (formatSeconds || timeDifference >= 1.minutes) { emit( @@ -81,4 +88,6 @@ fun getRelativeTime( } return flow.collectAsState(initial = "").value -} \ No newline at end of file +} + +fun Instant.format(f: DateFormat) = f.format(Date.from(this.toJavaInstant())) \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/PostColors.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/PostColors.kt index 70de20f8b..81fc403a3 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/PostColors.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/PostColors.kt @@ -1,5 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.material.colors +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -8,12 +10,33 @@ object PostColors { val delete: Color @Composable get() = Color(0xffdc3545) } + + object Roles { + val tutor: Color + @Composable get() = Color(0xFFFD7E14) + val student: Color + @Composable get() = Color(0xFF0C9EB6) + val instructor: Color + @Composable get() = Color(0xFFB60000) + } + + object StatusBackground { + val resolving: Color + @Composable get() = Color(0xFF28A745).copy(alpha = 0.2f) + val pinned: Color + @Composable get() = Color(0xFFFFA500).copy(alpha = 0.25f) + } + + object EmojiChipColors { + val background: Color + @Composable get() = if (isSystemInDarkTheme()) Color(0xFF282C30) else Color(0xFFD0D2D8) + val selectedBackgound: Color + @Composable get() = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) + } + val editedHintText: Color @Composable get() = Color.Gray val unsentMessageText: Color @Composable get() = Color.Gray - - val pinnedMessageBackground: Color - @Composable get() = Color(0xFFFFA500).copy(alpha = 0.25f) } \ No newline at end of file diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt index dccd0f32c..2e2e05b1a 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -215,7 +215,7 @@ fun ConversationChatListScreen( } IconButton(onClick = onNavigateToSettings) { - Icon(imageVector = Icons.Default.Settings, contentDescription = null) + Icon(imageVector = Icons.Outlined.Info, contentDescription = null) } } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt index ce4a6d43e..66160a3d6 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -38,6 +37,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.DisplayPostOrder import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostItemViewType import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostWithBottomSheet +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.determinePostItemViewJoinedType import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActionFlags import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.rememberPostActions import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.shouldDisplayHeader @@ -171,6 +171,7 @@ fun MetisChatList( MetisPostListHandler( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 8.dp) .weight(1f), serverUrl = serverUrl, courseId = courseId, @@ -249,7 +250,6 @@ private fun ChatList( ) { LazyColumn( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = listContentPadding, state = state, reverseLayout = true @@ -318,6 +318,18 @@ private fun ChatList( } } ), + joinedItemType = determinePostItemViewJoinedType( + index = index, + post = post, + postCount = posts.itemCount, + order = DisplayPostOrder.REVERSED, + getPost = { getPostIndex -> + when (val entry = posts.peek(getPostIndex)) { + is ChatListItem.PostChatListItem -> entry.post + else -> null + } + } + ), onClick = { val standalonePostId = post?.standalonePostId @@ -400,13 +412,10 @@ private fun DateDivider(modifier: Modifier, date: LocalDate) { ) } - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - HorizontalDivider(modifier = Modifier.weight(1f)) - Text( text = dateAsString, style = MaterialTheme.typography.bodyMedium, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt index 247cb0284..bef18c0bc 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll @@ -20,44 +21,50 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.InsertEmoticon import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings +import de.tum.informatics.www1.artemis.native_app.core.ui.date.DateFormats +import de.tum.informatics.www1.artemis.native_app.core.ui.date.format import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.MarkdownText import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.PostColors import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.getUnicodeForEmojiId +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.EmojiDialog +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActions import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.DisplayPriority import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IReaction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.UserRoleIcon import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.profile_picture.ProfilePictureWithDialog import io.github.fornewid.placeholder.material3.placeholder import kotlinx.datetime.Clock @@ -76,6 +83,8 @@ sealed class PostItemViewType { } private const val PlaceholderContent = "WWWWWWW" +private val postHeadlineHeight = 36.dp +private val emojiHeight = 27.dp /** * Displays a post item or a placeholder for it. @@ -87,7 +96,8 @@ internal fun PostItem( postItemViewType: PostItemViewType, clientId: Long, displayHeader: Boolean, - onClickOnReaction: ((emojiId: String, create: Boolean) -> Unit)?, + postItemViewJoinedType: PostItemViewJoinedType, + postActions: PostActions, onClick: () -> Unit, onLongClick: () -> Unit, onRequestRetrySend: () -> Unit @@ -99,15 +109,8 @@ internal fun PostItem( } val isPinned = post is IStandalonePost && post.displayPriority == DisplayPriority.PINNED - val applyPinStatusToModifier: @Composable (Modifier) -> Modifier = { - if (isPinned && !isExpanded) { - modifier - .clip( - MaterialTheme.shapes.small - ) - .background(color = PostColors.pinnedMessageBackground) - } else modifier - } + val hasFooter = (post is IStandalonePost && post.answers.orEmpty() + .isNotEmpty()) || post?.reactions.orEmpty().isNotEmpty() || isExpanded // Retrieve post status val clientPostId = post?.clientPostId @@ -129,26 +132,45 @@ internal fun PostItem( .background(color = MaterialTheme.colorScheme.errorContainer) .clickable(onClick = onRequestRetrySend) } else { - applyPinStatusToModifier(it) + it .combinedClickable( onClick = onClick, onLongClick = onLongClick ) } } - .padding(PaddingValues(horizontal = Spacings.ScreenHorizontalSpacing)), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(PaddingValues(horizontal = Spacings.ScreenHorizontalInnerSpacing)) ) { + val applyDistancePaddingToModifier: @Composable (Modifier) -> Modifier = { + if (postItemViewJoinedType in listOf( + PostItemViewJoinedType.JOINED, + PostItemViewJoinedType.FOOTER + ) + ) { + it.padding(top = Spacings.Post.innerSpacing) + } else { + it.padding(bottom = 4.dp) + } + } + if (isPinned) { IconLabel( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + modifier = applyDistancePaddingToModifier(Modifier) + .fillMaxWidth(), resourceString = R.string.post_is_pinned, icon = Icons.Outlined.PushPin ) } + if (post is IAnswerPost && post.resolvesPost) { + IconLabel( + modifier = applyDistancePaddingToModifier(Modifier) + .fillMaxWidth(), + resourceString = R.string.post_resolves, + icon = Icons.Default.Check + ) + } + PostHeadline( modifier = Modifier.fillMaxWidth(), postStatus = postStatus, @@ -158,70 +180,74 @@ internal fun PostItem( authorImageUrl = post?.authorImageUrl, creationDate = post?.creationDate, expanded = isExpanded, - displayHeader = displayHeader, + isAnswerPost = post is IAnswerPost, + displayHeader = displayHeader ) { Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.fillMaxWidth()) { - MarkdownText( - markdown = remember(post?.content, isPlaceholder) { - if (isPlaceholder) { - PlaceholderContent - } else post?.content.orEmpty() - }, - modifier = Modifier - .fillMaxWidth() - .placeholder(visible = isPlaceholder), - style = MaterialTheme.typography.bodyMedium, - onClick = onClick, - onLongClick = onLongClick, - color = if (post?.serverPostId == null) PostColors.unsentMessageText else Color.Unspecified + MarkdownText( + markdown = remember(post?.content, isPlaceholder) { + if (isPlaceholder) { + PlaceholderContent + } else post?.content.orEmpty() + }, + modifier = Modifier + .fillMaxWidth() + .placeholder(visible = isPlaceholder), + style = MaterialTheme.typography.bodyMedium, + onClick = onClick, + onLongClick = onLongClick, + color = if (post?.serverPostId == null) PostColors.unsentMessageText else Color.Unspecified + ) + + val instant = post?.updatedDate + if (instant != null) { + val updateTime = instant.format(DateFormats.EditTimestamp.format) + Spacer(modifier = Modifier.height(Spacings.Post.innerSpacing)) + + Text( + text = stringResource(id = R.string.post_edited_hint, updateTime), + style = MaterialTheme.typography.bodySmall, + color = PostColors.editedHintText ) + } - if (post?.updatedDate != null) { - Text( - text = stringResource(id = R.string.post_edited_hint), - style = MaterialTheme.typography.bodyMedium, - color = PostColors.editedHintText - ) - } + if (post is IStandalonePost && post.resolved == true) { + Spacer(modifier = Modifier.height(Spacings.Post.innerSpacing)) - when (post) { - is IStandalonePost -> { - if (post.resolved == true) { - IconLabel( - modifier = Modifier.fillMaxWidth(), - resourceString = R.string.post_is_resolved, - icon = Icons.Default.Check - ) - } - } - is IAnswerPost -> { - if (post.resolvesPost) { - IconLabel( - modifier = Modifier.fillMaxWidth(), - resourceString = R.string.post_resolves, - icon = Icons.Default.Check - ) - } - } - else -> {} - } + IconLabel( + modifier = Modifier.fillMaxWidth(), + resourceString = R.string.post_is_resolved, + icon = Icons.Default.Check + ) + } + + if (hasFooter) { + Spacer(modifier = Modifier.height(Spacings.Post.innerSpacing)) } StandalonePostFooter( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .let { + if (postItemViewJoinedType in listOf( + PostItemViewJoinedType.JOINED, + PostItemViewJoinedType.HEADER + ) && post?.reactions + .orEmpty() + .isNotEmpty() + ) { + it.padding(bottom = Spacings.Post.innerSpacing) + } else { + it + } + }, clientId = clientId, reactions = remember(post?.reactions) { post?.reactions.orEmpty() }, postItemViewType = postItemViewType, - onClickReaction = onClickOnReaction + postActions = postActions ) - - if (!post?.reactions.isNullOrEmpty()) { - Box(modifier = Modifier.height(2.dp)) - } } } } @@ -237,75 +263,53 @@ private fun PostHeadline( creationDate: Instant?, postStatus: CreatePostService.Status, expanded: Boolean = false, + isAnswerPost: Boolean, displayHeader: Boolean = true, content: @Composable () -> Unit ) { - if (expanded) { - Column(modifier = modifier) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - HeadlineProfilePicture( - userId = authorId, - userName = authorName.orEmpty(), - imageUrl = authorImageUrl, - userRole = authorRole, - ) - - HeadlineAuthorInfo( - modifier = Modifier.fillMaxWidth(), - authorName = authorName, - authorRole = authorRole, - creationDate = creationDate, - expanded = true - ) - } + val doDisplayHeader = displayHeader || postStatus == CreatePostService.Status.FAILED - content() - } - } else { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(Spacings.Post.innerSpacing) + ) { Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacings.Post.innerSpacing) ) { - val doDisplayHeader = displayHeader || postStatus == CreatePostService.Status.FAILED + if (!doDisplayHeader) { + return@Row + } HeadlineProfilePicture( userId = authorId, userName = authorName.orEmpty(), imageUrl = authorImageUrl, userRole = authorRole, - displayImage = doDisplayHeader ) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (postStatus == CreatePostService.Status.FAILED) { - Text( - text = stringResource(id = R.string.post_sending_failed), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error - ) - } - - if (doDisplayHeader) { - HeadlineAuthorInfo( - modifier = Modifier.fillMaxWidth(), - authorName = authorName, - authorRole = authorRole, - creationDate = creationDate, - expanded = false - ) - } else { - Box(modifier = Modifier.height(4.dp)) - } - - content() + if (postStatus == CreatePostService.Status.FAILED) { + Text( + text = stringResource(id = R.string.post_sending_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) } + + HeadlineAuthorInfo( + modifier = Modifier + .fillMaxWidth() + .height(postHeadlineHeight), + authorName = authorName, + authorRole = authorRole, + creationDate = creationDate, + expanded = expanded, + isAnswerPost = isAnswerPost + ) } + + content() } } @@ -340,58 +344,67 @@ private fun HeadlineAuthorInfo( authorName: String?, authorRole: UserRole?, creationDate: Instant?, - expanded: Boolean + expanded: Boolean, + isAnswerPost: Boolean ) { - val relativeTimeTo = remember(creationDate) { - creationDate ?: Clock.System.now() - } + Column(modifier = modifier) { + AuthorRoleAndTimeRow( + expanded = expanded, + authorRole = authorRole, + creationDate = creationDate, + isAnswerPost = isAnswerPost + ) - val creationDateContent: @Composable () -> Unit = { - val relativeTime = getRelativeTime(to = relativeTimeTo, showDate = false) + Spacer(modifier = Modifier.weight(1f)) Text( - modifier = Modifier.fillMaxWidth(), - text = remember(relativeTime) { relativeTime.toString() }, - style = MaterialTheme.typography.bodySmall + modifier = Modifier, + text = remember(authorName) { authorName ?: "Placeholder" }, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold ) } - - if (expanded) { - Column(modifier) { - AuthorRoleAndNameRow(authorRole, authorName) - creationDateContent() - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - AuthorRoleAndNameRow(authorRole, authorName) - creationDateContent() - } - } } @Composable -private fun AuthorRoleAndNameRow( +private fun AuthorRoleAndTimeRow( + expanded: Boolean, authorRole: UserRole?, - authorName: String? + creationDate: Instant?, + isAnswerPost: Boolean ) { Row( - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - UserRoleIcon(userRole = authorRole) + val relativeTimeTo = remember(creationDate) { + creationDate ?: Clock.System.now() + } - Spacer(modifier = Modifier.width(4.dp)) + val creationDateContent: @Composable () -> Unit = { - Text( - modifier = Modifier, - text = remember(authorName) { authorName ?: "Placeholder" }, - maxLines = 1, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + val relativeTime = if (expanded || isAnswerPost) { + getRelativeTime(to = relativeTimeTo, showDateAndTime = true) + } else { + getRelativeTime(to = relativeTimeTo, showDate = false) + } + + Text( + modifier = Modifier, + text = relativeTime.toString(), + style = MaterialTheme.typography.bodySmall + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HeadlineAuthorRoleBadge(authorRole) + Spacer(modifier = Modifier.weight(1f)) + creationDateContent() + } } } @@ -403,7 +416,7 @@ private fun HeadlineProfilePicture( userRole: UserRole?, displayImage: Boolean = true ) { - val size = 30.dp + val size = postHeadlineHeight Box(modifier = Modifier.size(size)) { if (!displayImage) { return @@ -419,7 +432,35 @@ private fun HeadlineProfilePicture( } } +@Composable +private fun HeadlineAuthorRoleBadge( + authorRole: UserRole?, +) { + /* + * remember is needed here to prevent the value from being reset after an update + * (the author role is not sent when updating a post) + */ + val initialAuthorRole = remember { mutableStateOf(authorRole) } + val (text, color) = when (initialAuthorRole.value) { + UserRole.INSTRUCTOR -> R.string.post_instructor to PostColors.Roles.instructor + UserRole.TUTOR -> R.string.post_tutor to PostColors.Roles.tutor + UserRole.USER -> R.string.post_student to PostColors.Roles.student + null -> R.string.post_student to PostColors.Roles.student + } + Box( + modifier = Modifier + .background(color, MaterialTheme.shapes.extraSmall) + ) { + Text( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp), + text = stringResource(id = text), + style = MaterialTheme.typography.bodySmall, + color = Color.White, + fontWeight = FontWeight.Medium + ) + } +} /** * Display the tags, the reactions and the action buttons like reply, view replies and react with emoji. @@ -430,7 +471,7 @@ private fun StandalonePostFooter( clientId: Long, reactions: List, postItemViewType: PostItemViewType, - onClickReaction: ((emojiId: String, create: Boolean) -> Unit)? + postActions: PostActions ) { val reactionCount: Map = remember(reactions, clientId) { reactions.groupBy { it.emojiId }.mapValues { groupedReactions -> @@ -440,13 +481,27 @@ private fun StandalonePostFooter( ) } } + val showEmojiDialog = remember { mutableStateOf(false) } + + if (showEmojiDialog.value) { + EmojiDialog( + onDismissRequest = { showEmojiDialog.value = false }, + onSelectEmoji = { emojiId -> + postActions.onClickReaction?.invoke(emojiId, true) + showEmojiDialog.value = false + } + ) + } - Column(modifier = modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { Row( modifier = Modifier .fillMaxSize() .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { reactionCount.forEach { (emoji, reactionData) -> EmojiChip( @@ -454,25 +509,56 @@ private fun StandalonePostFooter( emojiId = emoji, reactionCount = reactionData.reactionCount, onClick = { - onClickReaction?.invoke(emoji, !reactionData.hasClientReacted) + postActions.onClickReaction?.invoke(emoji, !reactionData.hasClientReacted) } ) } + if (reactionCount.isNotEmpty() || postItemViewType is PostItemViewType.ThreadContextPostItem) { + Box( + modifier = modifier + .background(color = PostColors.EmojiChipColors.background, CircleShape) + .clip(CircleShape) + .clickable(onClick = { + showEmojiDialog.value = true + }) + ) { + Icon( + modifier = Modifier + .size(emojiHeight) + .padding(5.dp), + imageVector = Icons.Default.InsertEmoticon, + contentDescription = null, + ) + } + } } if (postItemViewType is PostItemViewType.ChatListItem) { val replyCount = postItemViewType.answerPosts.size if (replyCount > 0) { - Text( - style = MaterialTheme.typography.bodyMedium, - text = pluralStringResource( - id = R.plurals.communication_standalone_post_view_replies_button, - count = replyCount, - replyCount - ), - color = MaterialTheme.colorScheme.secondary - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.replies), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + + Text( + style = MaterialTheme.typography.bodyMedium, + text = pluralStringResource( + id = R.plurals.communication_standalone_post_view_replies_button, + count = replyCount, + replyCount + ), + color = MaterialTheme.colorScheme.primary + ) + } } } } @@ -481,7 +567,7 @@ private fun StandalonePostFooter( private data class ReactionData(val reactionCount: Int, val hasClientReacted: Boolean) @Composable -private fun AnimatedCounter(currentCount: Int) { +private fun AnimatedCounter(currentCount: Int, selected: Boolean) { AnimatedContent( targetState = currentCount, transitionSpec = { @@ -497,7 +583,11 @@ private fun AnimatedCounter(currentCount: Int) { }, label = "Animate reaction count change" ) { targetCount -> - Text(text = "$targetCount") + Text( + text = "$targetCount", + fontSize = 12.sp, + color = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified + ) } } @@ -512,24 +602,34 @@ private fun EmojiChip( val shape = CircleShape val backgroundColor = - if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + if (selected) PostColors.EmojiChipColors.selectedBackgound else PostColors.EmojiChipColors.background Box( modifier = modifier .background(color = backgroundColor, shape) .clip(shape) + .heightIn(max = emojiHeight) .clickable(onClick = onClick) + .let { + if (selected) { + it.border(1.dp, MaterialTheme.colorScheme.primary, shape) + } else { + it + } + } ) { Row( - modifier = Modifier.padding(4.dp), + modifier = Modifier + .padding(2.dp) + .padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = getUnicodeForEmojiId(emojiId = emojiId), - fontSize = 14.sp + fontSize = 12.sp ) - AnimatedCounter(reactionCount) + AnimatedCounter(reactionCount, selected) } } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostUtil.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostUtil.kt index f375d2924..bda444625 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostUtil.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostUtil.kt @@ -12,6 +12,14 @@ internal enum class DisplayPostOrder { REGULAR } +internal enum class PostItemViewJoinedType { + SINGLE, + HEADER, + JOINED, + FOOTER, + PARENT +} + @Composable internal fun shouldDisplayHeader( index: Int, @@ -32,4 +40,40 @@ internal fun shouldDisplayHeader( postCreationDate - prefPostCreationDate > MaxDurationJoinMessages } else true } -} \ No newline at end of file +} + +@Composable +internal fun determinePostItemViewJoinedType( + index: Int, + post: T?, + postCount: Int, + order: DisplayPostOrder, + getPost: (index: Int) -> T? +): PostItemViewJoinedType { + val isHeader = shouldDisplayHeader(index, post, postCount, order, getPost) + + val nextPostIndex = when (order) { + DisplayPostOrder.REVERSED -> index - 1 + DisplayPostOrder.REGULAR -> index + 1 + } + val nextPost = if (nextPostIndex in 0 until postCount) getPost(nextPostIndex) else null + val isNextHeader = nextPost?.let { + shouldDisplayHeader(nextPostIndex, it, postCount, order, getPost) + } ?: true + + return remember(isHeader, postCount, nextPost, isNextHeader) { + if (isHeader) { + if (nextPost == null || isNextHeader) { + PostItemViewJoinedType.SINGLE + } else { + PostItemViewJoinedType.HEADER + } + } else { + if (nextPost == null || isNextHeader) { + PostItemViewJoinedType.FOOTER + } else { + PostItemViewJoinedType.JOINED + } + } + } +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt index 866d5b093..e21d35acf 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt @@ -1,14 +1,27 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme 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.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings +import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.PostColors import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActions import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostContextBottomSheet +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.DisplayPriority +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost /** * Wrapps [PostItem] and can display the bottom sheet for the post @@ -21,23 +34,71 @@ internal fun PostWithBottomSheet( postActions: PostActions, clientId: Long, displayHeader: Boolean, + joinedItemType: PostItemViewJoinedType, onClick: () -> Unit ) { var displayBottomSheet by remember(post, postItemViewType) { mutableStateOf(false) } - PostItem( - modifier = modifier, - post = post, - postItemViewType = postItemViewType, - clientId = clientId, - displayHeader = displayHeader, - onClickOnReaction = postActions.onClickReaction, - onClick = onClick, - onLongClick = { - displayBottomSheet = true - }, - onRequestRetrySend = postActions.onRequestRetrySend, - ) + val isPinned = post is IStandalonePost && post.displayPriority == DisplayPriority.PINNED + val isResolving = post is IAnswerPost && post.resolvesPost + val isParentPostInThread = joinedItemType == PostItemViewJoinedType.PARENT + + val cardColor = when { + isParentPostInThread -> MaterialTheme.colorScheme.background + isResolving -> PostColors.StatusBackground.resolving + isPinned -> PostColors.StatusBackground.pinned + else -> CardDefaults.cardColors().containerColor + } + + val cardShape: Shape = when (joinedItemType) { + PostItemViewJoinedType.HEADER -> MaterialTheme.shapes.small.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp) + ) + PostItemViewJoinedType.FOOTER -> MaterialTheme.shapes.small.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) + PostItemViewJoinedType.JOINED, PostItemViewJoinedType.PARENT -> MaterialTheme.shapes.small.copy(all = CornerSize(0.dp)) + PostItemViewJoinedType.SINGLE -> MaterialTheme.shapes.small + } + + val applyPaddingToModifier: @Composable (modifier: Modifier, paddingValue: Dp) -> Modifier = + { modifier, paddingValue -> + when (joinedItemType) { + PostItemViewJoinedType.HEADER -> modifier.padding(top = paddingValue, bottom = 0.dp) + PostItemViewJoinedType.FOOTER -> modifier.padding(top = 0.dp, bottom = paddingValue) + PostItemViewJoinedType.SINGLE -> modifier.padding( + vertical = paddingValue + ) + PostItemViewJoinedType.JOINED -> modifier.padding(vertical = 0.dp) + PostItemViewJoinedType.PARENT -> modifier.padding(0.dp) + } + } + + val cardModifier = applyPaddingToModifier(modifier, 4.dp) + val innerModifier = applyPaddingToModifier(modifier, Spacings.Post.innerSpacing) + + Card( + modifier = cardModifier, + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = cardColor) + ){ + PostItem( + modifier = innerModifier, + post = post, + postItemViewType = postItemViewType, + clientId = clientId, + displayHeader = displayHeader, + postItemViewJoinedType = joinedItemType, + postActions = postActions, + onClick = onClick, + onLongClick = { + displayBottomSheet = true + }, + onRequestRetrySend = postActions.onRequestRetrySend + ) + } if (displayBottomSheet && post != null) { PostContextBottomSheet( @@ -49,6 +110,4 @@ internal fun PostWithBottomSheet( } ) } - - } \ No newline at end of file diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/post_actions/PostContextBottomSheet.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/post_actions/PostContextBottomSheet.kt index 5ba8bc934..ea6354a2f 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/post_actions/PostContextBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/post_actions/PostContextBottomSheet.kt @@ -287,7 +287,7 @@ private fun ActionButton( } @Composable -private fun EmojiDialog( +fun EmojiDialog( onDismissRequest: () -> Unit, onSelectEmoji: (emojiId: String) -> Unit ) { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt index 0c72573c5..a4d1b376f 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt @@ -39,8 +39,10 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisPostListHandler import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.testTagForPost import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.DisplayPostOrder +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostItemViewJoinedType import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostItemViewType import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostWithBottomSheet +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.determinePostItemViewJoinedType import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActionBar import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActionFlags import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.post_actions.PostActions @@ -294,7 +296,6 @@ private fun PostAndRepliesList( LazyColumn( modifier = modifier, contentPadding = listContentPadding, - verticalArrangement = Arrangement.spacedBy(8.dp), state = state ) { item { @@ -306,12 +307,12 @@ private fun PostAndRepliesList( ) { PostWithBottomSheet( modifier = Modifier - .padding(top = 8.dp) .testTag(testTagForPost(post.standalonePostId)), post = post, postItemViewType = PostItemViewType.ThreadContextPostItem, postActions = postActions, displayHeader = true, + joinedItemType = PostItemViewJoinedType.PARENT, clientId = clientId, onClick = {} ) @@ -347,6 +348,13 @@ private fun PostAndRepliesList( order = DisplayPostOrder.REGULAR, getPost = post.orderedAnswerPostings::get ), + joinedItemType = determinePostItemViewJoinedType( + index = index, + post = answerPost, + postCount = post.orderedAnswerPostings.size, + order = DisplayPostOrder.REGULAR, + getPost = post.orderedAnswerPostings::get + ), onClick = {} ) } diff --git a/feature/metis/conversation/src/main/res/drawable/replies.xml b/feature/metis/conversation/src/main/res/drawable/replies.xml new file mode 100644 index 000000000..684d90530 --- /dev/null +++ b/feature/metis/conversation/src/main/res/drawable/replies.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/feature/metis/conversation/src/main/res/values/post_strings.xml b/feature/metis/conversation/src/main/res/values/post_strings.xml index 12bae2ebf..118c649e9 100644 --- a/feature/metis/conversation/src/main/res/values/post_strings.xml +++ b/feature/metis/conversation/src/main/res/values/post_strings.xml @@ -11,7 +11,7 @@ Resolved Pinned - (edited) + Edited (%1$s) %d Replies @@ -20,4 +20,8 @@ Sending post failed. Click to try again. + + Instructor + Student + Tutor \ No newline at end of file diff --git a/feature/metis/conversation/src/main/res/values/qna_strings.xml b/feature/metis/conversation/src/main/res/values/qna_strings.xml index 27384c212..326ef2f63 100644 --- a/feature/metis/conversation/src/main/res/values/qna_strings.xml +++ b/feature/metis/conversation/src/main/res/values/qna_strings.xml @@ -1,8 +1,8 @@ - Show 1 reply - Show %1$d replies + 1 reply + %1$d replies Create post