diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt index e377ed74b..014bfc711 100644 --- a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt @@ -1,14 +1,34 @@ package de.tum.informatics.www1.artemis.native_app.core.common.markdown -object ArtemisMarkdownTransformer { +abstract class ArtemisMarkdownTransformer { - private val customMarkdownPattern = "\\[(text|quiz|lecture|modeling|file-upload|programing)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex() + private val exerciseMarkdownPattern = + "\\[(text|quiz|lecture|modeling|file-upload|programing)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex() + private val userMarkdownPattern = "\\[user](.*?)\\((.*?)\\)\\[/user]".toRegex() - fun transformMarkdown(markdown: String, serverUrl: String): String { - return customMarkdownPattern.replace(markdown) { matchResult -> + fun transformMarkdown(markdown: String): String { + return exerciseMarkdownPattern.replace(markdown) { matchResult -> val title = matchResult.groups[2]?.value.orEmpty() val url = matchResult.groups[3]?.value.orEmpty() - "[$title]($serverUrl$url)" + transformExerciseMarkdown(title, url) + }.let { + userMarkdownPattern.replace(it) { matchResult -> + val fullName = matchResult.groups[1]?.value.orEmpty() + val userName = matchResult.groups[2]?.value.orEmpty() + transformUserMentionMarkdown( + text = matchResult.groups[0]?.value.orEmpty(), + fullName = fullName, + userName = userName + ) + } } } + + protected abstract fun transformExerciseMarkdown(title: String, url: String): String + + protected abstract fun transformUserMentionMarkdown( + text: String, + fullName: String, + userName: String + ): String } diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt new file mode 100644 index 000000000..778cca4b5 --- /dev/null +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt @@ -0,0 +1,9 @@ +package de.tum.informatics.www1.artemis.native_app.core.common.markdown + +class PostArtemisMarkdownTransformer(val serverUrl: String) : ArtemisMarkdownTransformer() { + override fun transformExerciseMarkdown(title: String, url: String): String { + return "[$title]($serverUrl$url)" + } + + override fun transformUserMentionMarkdown(text: String, fullName: String, userName: String): String = "|||@$fullName|||" +} diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt new file mode 100644 index 000000000..ef8d33806 --- /dev/null +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt @@ -0,0 +1,8 @@ +package de.tum.informatics.www1.artemis.native_app.core.common.markdown + +object PushNotificationArtemisMarkdownTransformer : ArtemisMarkdownTransformer() { + + override fun transformExerciseMarkdown(title: String, url: String): String = title + + override fun transformUserMentionMarkdown(text: String, fullName: String, userName: String): String = "@$fullName" +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 2b359287b..0fda6922a 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.noties.markwon.ext.strikethrough) implementation(libs.noties.markwon.ext.tables) implementation(libs.noties.markwon.html) + implementation(libs.noties.markwon.simple.ext) implementation(libs.noties.markwon.linkify) implementation(libs.noties.markwon.image.coil) } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt index c912cd449..672a6d899 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt @@ -2,6 +2,8 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.markdown import android.content.Context import android.text.method.LinkMovementMethod +import android.text.style.ForegroundColorSpan +import android.text.style.StrikethroughSpan import android.util.TypedValue import android.view.View import android.widget.TextView @@ -31,7 +33,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.ResourcesCompat import coil.ImageLoader -import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer +import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PostArtemisMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService import io.noties.markwon.Markwon import io.noties.markwon.ext.strikethrough.StrikethroughPlugin @@ -39,6 +41,7 @@ import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.image.coil.CoilImagesPlugin import io.noties.markwon.linkify.LinkifyPlugin +import io.noties.markwon.simple.ext.SimpleExtPlugin import org.koin.compose.koinInject // Copy from: https://github.com/jeziellago/compose-markdown @@ -100,15 +103,19 @@ fun MarkdownText( serverConfigurationService.serverUrl.collectAsState(initial = "").value } - val transformedMarkdown by remember(markdown, serverUrl) { - derivedStateOf { - val strippedServerUrl = - if (serverUrl.endsWith("/")) serverUrl.substring( - 0, - serverUrl.length - 1 - ) else serverUrl + val markdownTransformer = remember(serverUrl) { + val strippedServerUrl = + if (serverUrl.endsWith("/")) serverUrl.substring( + 0, + serverUrl.length - 1 + ) else serverUrl + + PostArtemisMarkdownTransformer(strippedServerUrl) + } - ArtemisMarkdownTransformer.transformMarkdown(markdown, strippedServerUrl) + val transformedMarkdown by remember(markdown, markdownTransformer) { + derivedStateOf { + markdownTransformer.transformMarkdown(markdown) } } @@ -200,10 +207,16 @@ fun createMarkdownRender(context: Context, imageLoader: ImageLoader?): Markwon { .usePlugin(StrikethroughPlugin.create()) .usePlugin(TablePlugin.create(context)) .usePlugin(LinkifyPlugin.create()) + // User mentions are transformed to |||@full name||| + .usePlugin(SimpleExtPlugin.create { p -> + p.addExtension(3, '|') { _, _ -> + arrayOf(ForegroundColorSpan(0xff3e8acc.toInt())) + } + }) .apply { if (imageLoader != null) { usePlugin(CoilImagesPlugin.create(context, imageLoader)) } } .build() -} \ No newline at end of file +} diff --git a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt index b02bec1ab..57f5fbc58 100644 --- a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt +++ b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt @@ -258,9 +258,7 @@ internal fun CourseUiScreen( } TAB_COMMUNICATION -> { - val metisModifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp) + val metisModifier = Modifier.fillMaxSize() if (course.courseInformationSharingConfiguration.supportsMessaging) { val initialConfiguration = remember(conversationId, postId) { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationDetailScreen.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationDetailScreen.kt index 6e3b18c39..d6e172cc3 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationDetailScreen.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -31,6 +32,7 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicHintTextFi import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisChatList import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisListViewModel +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.LocalReplyAutoCompleteHintProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.shared.isReplyEnabled import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import io.github.fornewid.placeholder.material3.placeholder @@ -134,15 +136,17 @@ fun ConversationScreen( val isReplyEnabled = isReplyEnabled(conversationDataState = conversationDataState) if (conversationDataState.isSuccess) { - MetisChatList( - modifier = Modifier - .fillMaxSize() - .padding(padding), - viewModel = viewModel, - listContentPadding = PaddingValues(), - onClickViewPost = onClickViewPost, - isReplyEnabled = isReplyEnabled - ) + CompositionLocalProvider(LocalReplyAutoCompleteHintProvider provides viewModel) { + MetisChatList( + modifier = Modifier + .fillMaxSize() + .padding(padding), + viewModel = viewModel, + listContentPadding = PaddingValues(), + onClickViewPost = onClickViewPost, + isReplyEnabled = isReplyEnabled + ) + } } } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisListViewModel.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisListViewModel.kt index ad964e275..11a839abe 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisListViewModel.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisListViewModel.kt @@ -186,7 +186,7 @@ internal class MetisListViewModel( fun createPost(): Deferred { return viewModelScope.async(coroutineContext) { - val postText = newMessageText.first() + val postText = newMessageText.first().text val conversation = loadConversation() ?: return@async MetisModificationFailure.CREATE_POST diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteCategory.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteCategory.kt new file mode 100644 index 000000000..8757634b5 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteCategory.kt @@ -0,0 +1,5 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.annotation.StringRes + +class AutoCompleteCategory(@StringRes val name: Int, val items: List) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteHint.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteHint.kt new file mode 100644 index 000000000..88ade8f6e --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/AutoCompleteHint.kt @@ -0,0 +1,3 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +data class AutoCompleteHint(val hint: String, val replacementText: String, val id: String) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/InitialReplyTextProvider.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/InitialReplyTextProvider.kt index 236385b32..2c5e400ab 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/InitialReplyTextProvider.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/InitialReplyTextProvider.kt @@ -1,10 +1,11 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply +import androidx.compose.ui.text.input.TextFieldValue import kotlinx.coroutines.flow.Flow interface InitialReplyTextProvider { - val newMessageText: Flow + val newMessageText: Flow - fun updateInitialReplyText(text: String) + fun updateInitialReplyText(text: TextFieldValue) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt index 9c32c082b..8a121d3a2 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.InputChip import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -22,6 +23,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.MarkdownText import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R @@ -32,13 +35,16 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R @Composable internal fun MarkdownTextField( modifier: Modifier, - text: String, + textFieldValue: TextFieldValue, focusRequester: FocusRequester = remember { FocusRequester() }, sendButton: @Composable () -> Unit = {}, topRightButton: @Composable RowScope.() -> Unit = {}, + onFocusAcquired: () -> Unit = {}, onFocusLost: () -> Unit = {}, - onTextChanged: (String) -> Unit + onTextChanged: (TextFieldValue) -> Unit ) { + val text = textFieldValue.text + var selectedType by remember { mutableStateOf(ViewType.TEXT) } var hadFocus by remember { mutableStateOf(false) } @@ -81,6 +87,7 @@ internal fun MarkdownTextField( .onFocusChanged { focusState -> if (focusState.hasFocus) { hadFocus = true + onFocusAcquired() } if (!focusState.hasFocus && hadFocus) { @@ -88,8 +95,9 @@ internal fun MarkdownTextField( hadFocus = false } }, - value = text, - onValueChange = onTextChanged + value = textFieldValue, + onValueChange = onTextChanged, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) ) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompleteHintProvider.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompleteHintProvider.kt new file mode 100644 index 000000000..2a73be275 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompleteHintProvider.kt @@ -0,0 +1,28 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import de.tum.informatics.www1.artemis.native_app.core.data.DataState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +internal val LocalReplyAutoCompleteHintProvider: ProvidableCompositionLocal = compositionLocalOf { + object : ReplyAutoCompleteHintProvider { + override val legalTagChars: List = emptyList() + + override fun produceAutoCompleteHints( + tagChar: Char, + query: String + ): Flow>> = flowOf(DataState.Success(emptyList())) + } +} + +internal interface ReplyAutoCompleteHintProvider { + + val legalTagChars: List + + fun produceAutoCompleteHints( + tagChar: Char, + query: String + ): Flow>> +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompletePopup.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompletePopup.kt new file mode 100644 index 000000000..4afda1abd --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompletePopup.kt @@ -0,0 +1,139 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R + +private val HintHorizontalPadding = 16.dp + +@Composable +internal fun ReplyAutoCompletePopup( + popupPositionProvider: PopupPositionProvider, + targetWidth: Dp, + maxHeight: Dp, + autoCompleteCategories: List, + performAutoComplete: (replacement: String) -> Unit, + onDismissRequest: () -> Unit +) { + Popup( + popupPositionProvider = popupPositionProvider, + properties = PopupProperties(dismissOnClickOutside = true), + onDismissRequest = onDismissRequest + ) { + ReplyAutoCompletePopupBody( + modifier = Modifier.heightIn(max = maxHeight).width(targetWidth), + autoCompleteCategories = autoCompleteCategories, + performAutoComplete = performAutoComplete + ) + } +} + +@Composable +private fun ReplyAutoCompletePopupBody( + modifier: Modifier, + autoCompleteCategories: List, + performAutoComplete: (replacement: String) -> Unit +) { + LazyColumn( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(topStart = 45f, topEnd = 45f) + ) + .padding(top = 8.dp) + ) { + autoCompleteCategories.forEach { category -> + item { + AutoCompleteCategoryComposable( + modifier = Modifier.fillMaxWidth(), + name = stringResource(id = category.name) + ) + } + + category.items.fastForEachIndexed { i, hint -> + item(key = "${category.name}_${hint.id}") { + AutoCompleteHintComposable( + modifier = Modifier.fillMaxWidth(), + hint = hint, + onClick = { performAutoComplete(hint.replacementText) } + ) + } + + if (i != category.items.lastIndex) { + item { Divider() } + } + } + } + } +} + +@Composable +private fun AutoCompleteCategoryComposable(modifier: Modifier, name: String) { + Box(modifier) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HintHorizontalPadding), + text = name, + style = MaterialTheme.typography.titleSmall + ) + } +} + +@Composable +private fun AutoCompleteHintComposable( + modifier: Modifier, + hint: AutoCompleteHint, + onClick: () -> Unit +) { + Box(modifier = modifier.clickable(onClick = onClick)) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HintHorizontalPadding, vertical = 8.dp), + text = hint.hint + ) + } +} + +@Preview +@Composable +private fun ReplyAutoCompletePopupBodyPreview() { + ReplyAutoCompletePopupBody( + modifier = Modifier.fillMaxSize(), + autoCompleteCategories = listOf( + AutoCompleteCategory( + name = R.string.markdown_textfield_autocomplete_category_users, + items = (0 until 10).map { + AutoCompleteHint( + hint = "Hint $it", + replacementText = "", + id = it.toString() + ) + } + ) + ), + performAutoComplete = {} + ) +} \ 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/reply/ReplyAutoCompletePopupPositionProvider.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompletePopupPositionProvider.kt new file mode 100644 index 000000000..334ee9f46 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyAutoCompletePopupPositionProvider.kt @@ -0,0 +1,16 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.PopupPositionProvider + +internal object ReplyAutoCompletePopupPositionProvider : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = anchorBounds.topLeft - IntOffset(0, popupContentSize.height) +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt index 3e6f39f90..b505b98d3 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt @@ -8,28 +8,29 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.shared.MetisModificationFailureDialog import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import kotlinx.coroutines.Deferred -internal sealed class ReplyMode { +internal sealed class ReplyMode() { + abstract val currentText: State - abstract val currentText: State - - abstract fun onUpdateText(newText: String) + abstract fun onUpdate(new: TextFieldValue) data class NewMessage( - override val currentText: State, - private val onUpdateTextUpstream: (String) -> Unit, - val onCreateNewMessage: () -> Deferred, + override val currentText: State, + private val onUpdateTextUpstream: (TextFieldValue) -> Unit, + private val onCreateNewMessageUpstream: () -> Deferred, ) : ReplyMode() { - - override fun onUpdateText(newText: String) { - onUpdateTextUpstream(newText) + override fun onUpdate(new: TextFieldValue) { + onUpdateTextUpstream(new) } + + fun onCreateNewMessage(): Deferred = onCreateNewMessageUpstream() } data class EditMessage( @@ -37,13 +38,13 @@ internal sealed class ReplyMode { private val onEditMessage: (String) -> Deferred, val onCancelEditMessage: () -> Unit ) : ReplyMode() { - override val currentText = mutableStateOf("") + override val currentText = mutableStateOf(TextFieldValue()) - override fun onUpdateText(newText: String) { - currentText.value = newText + override fun onUpdate(new: TextFieldValue) { + currentText.value = new } - fun onEditMessage(): Deferred = onEditMessage(currentText.value) + fun onEditMessage(): Deferred = onEditMessage(currentText.value.text) } } @@ -55,14 +56,14 @@ private fun rememberReplyMode( onCreatePost: () -> Deferred, onEditPost: (T, String) -> Deferred ): ReplyMode { - val newMessageText = initialReplyTextProvider.newMessageText.collectAsState(initial = "") + val newMessageText = initialReplyTextProvider.newMessageText.collectAsState(initial = TextFieldValue()) val replyModeNewMessage = remember(initialReplyTextProvider::updateInitialReplyText, onCreatePost) { ReplyMode.NewMessage( currentText = newMessageText, onUpdateTextUpstream = initialReplyTextProvider::updateInitialReplyText, - onCreateNewMessage = onCreatePost + onCreateNewMessageUpstream = onCreatePost ) } var editingPostJob: Deferred? by remember() { mutableStateOf(null) } @@ -147,4 +148,4 @@ internal fun MetisReplyHandler( metisFailure = null } } -} \ 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/reply/ReplyTextField.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt index b718c151f..8289458f5 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -25,23 +26,36 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.getTextBeforeSelection import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.ReplyState +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlin.time.Duration.Companion.seconds internal const val TEST_TAG_CAN_CREATE_REPLY = "TEST_TAG_CAN_CREATE_REPLY" @@ -144,28 +158,79 @@ private fun CreateReplyUi( var displayTextField: Boolean by remember { mutableStateOf(false) } var requestFocus: Boolean by remember { mutableStateOf(false) } - val currentText by replyMode.currentText + val currentTextFieldValue by replyMode.currentText - LaunchedEffect(displayTextField, currentText) { - if (!displayTextField && currentText.isNotBlank() && prevReplyContent.isBlank()) { + var mayShowAutoCompletePopup by remember { mutableStateOf(true) } + var requestDismissAutoCompletePopup by remember { mutableStateOf(false) } + + LaunchedEffect(displayTextField, currentTextFieldValue) { + if (!displayTextField && currentTextFieldValue.text.isNotBlank() && prevReplyContent.isBlank()) { displayTextField = true } - prevReplyContent = currentText + prevReplyContent = currentTextFieldValue.text + mayShowAutoCompletePopup = true + requestDismissAutoCompletePopup = false + } + + // Quite hacky! + /* + We do not want to dismiss the popup when the user pressed on their keyboard, so we wait + 100 ms before we actually dismiss the popup. If in the meantime, the user entered a key again + we keep showing the popup. + */ + LaunchedEffect(requestDismissAutoCompletePopup) { + if (requestDismissAutoCompletePopup) { + delay(100) + mayShowAutoCompletePopup = false + } } Box(modifier = modifier) { - if (displayTextField || currentText.isNotBlank()) { + if (displayTextField || currentTextFieldValue.text.isNotBlank()) { + val tagChars = LocalReplyAutoCompleteHintProvider.current.legalTagChars + val autoCompleteHints = manageAutoCompleteHints(currentTextFieldValue) + + var textFieldWidth by remember { mutableIntStateOf(0) } + var popupMaxHeight by remember { mutableIntStateOf(0) } + + if (autoCompleteHints.orEmpty().flatMap { it.items } + .isNotEmpty() && mayShowAutoCompletePopup) { + ReplyAutoCompletePopup( + autoCompleteCategories = autoCompleteHints.orEmpty(), + targetWidth = with(LocalDensity.current) { textFieldWidth.toDp() }, + maxHeight = with(LocalDensity.current) { popupMaxHeight.toDp() }, + popupPositionProvider = ReplyAutoCompletePopupPositionProvider, + performAutoComplete = { replacement -> + replyMode.onUpdate( + performAutoComplete( + currentTextFieldValue, + tagChars, + replacement + ) + ) + }, + onDismissRequest = { + requestDismissAutoCompletePopup = true + } + ) + } + MarkdownTextField( modifier = Modifier .fillMaxWidth() + .onSizeChanged { textFieldWidth = it.width } .padding(vertical = 8.dp, horizontal = 8.dp) + .onGloballyPositioned { coordinates -> + val textFieldWindowTopLeft = coordinates.localToWindow(Offset.Zero) + popupMaxHeight = textFieldWindowTopLeft.y.toInt() + } .testTag(TEST_TAG_REPLY_TEXT_FIELD), - text = currentText, - onTextChanged = replyMode::onUpdateText, + textFieldValue = currentTextFieldValue, + onTextChanged = replyMode::onUpdate, focusRequester = focusRequester, onFocusLost = { - if (displayTextField && currentText.isEmpty()) { + if (displayTextField && currentTextFieldValue.text.isEmpty()) { displayTextField = false } }, @@ -173,7 +238,7 @@ private fun CreateReplyUi( IconButton( modifier = Modifier.testTag(TEST_TAG_REPLY_SEND_BUTTON), onClick = onReply, - enabled = currentText.isNotBlank() + enabled = currentTextFieldValue.text.isNotBlank() ) { Icon( imageVector = when (replyMode) { @@ -200,33 +265,62 @@ private fun CreateReplyUi( } } } else { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - displayTextField = true - requestFocus = true - } - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.create_answer_click_to_write), - modifier = Modifier - .padding(vertical = 8.dp) - .weight(1f) - ) - - Icon( - imageVector = Icons.Default.Send, - contentDescription = null, - tint = LocalContentColor.current.copy(alpha = DisabledContentAlpha) - ) + UnfocusedPreviewReplyTextField { + displayTextField = true + requestFocus = true } } } } +@Composable +private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onRequestShowTextField) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.create_answer_click_to_write), + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) + + Icon( + imageVector = Icons.Default.Send, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = DisabledContentAlpha) + ) + } +} + +private fun performAutoComplete( + textFieldValue: TextFieldValue, + tagChars: List, + replacement: String +): TextFieldValue { + // Perform replace in text + val replacementStart = textFieldValue + .getTextBeforeSelection(Int.MAX_VALUE) + .indexOfLastWhileTag(tagChars) + ?: return textFieldValue + + val replacementEnd = textFieldValue.selection.min + + val newText = textFieldValue + .text + .replaceRange(replacementStart, replacementEnd, replacement) + + return textFieldValue.copy( + text = newText, + // Put cursor after replacement. + selection = TextRange(replacementStart + replacement.length) + ) +} + /** * Cycles through the reply state. When create reply is clicked, switches to sending reply. * If sending the reply was successful, shows has sent reply shortly. @@ -241,7 +335,7 @@ private fun rememberReplyState( AwaitDeferredCompletion(job = isCreatingReplyJob) { failure -> if (failure == null) { - replyMode.onUpdateText("") + replyMode.onUpdate(TextFieldValue(text = "")) hasSentReply = true } else { updateFailureState(failure) @@ -275,3 +369,125 @@ private fun rememberReplyState( } } } + +/** + * @return a list of auto complete hints that should be displayed, or null if no auto complete hints are to be displayed. + */ +@Composable +private fun manageAutoCompleteHints(textFieldValue: TextFieldValue): List? { + val replyAutoCompleteHintProvider = LocalReplyAutoCompleteHintProvider.current + var replyAutoCompleteHintProducer: Flow>>? by remember { + mutableStateOf( + null + ) + } + + val tagChars = replyAutoCompleteHintProvider.legalTagChars + LaunchedEffect(textFieldValue, replyAutoCompleteHintProvider) { + // Check that no text is selected, and instead we have a simple cursor + replyAutoCompleteHintProducer = + textFieldValue.getAutoCompleteReplacementTextFirstIndex(tagChars)?.let { tagIndex -> + val tagChar = textFieldValue.text[tagIndex] + val replacementWord = if (textFieldValue.text.length > tagIndex + 1) { + textFieldValue.text.substring(tagIndex + 1).takeWhileTag() + } else "" + + replyAutoCompleteHintProvider.produceAutoCompleteHints(tagChar, replacementWord) + } + } + + val replyAutoCompleteHints = replyAutoCompleteHintProducer + ?.collectAsState(DataState.Loading()) + ?.value + ?.orElse(emptyList()) + .orEmpty() + + var latestValidAutoCompleteHints: List? by remember { + mutableStateOf(null) + } + + LaunchedEffect(replyAutoCompleteHintProducer, replyAutoCompleteHints) { + if (replyAutoCompleteHintProducer == null) { + latestValidAutoCompleteHints = null + } else if (replyAutoCompleteHints.isNotEmpty()) { + latestValidAutoCompleteHints = replyAutoCompleteHints + } + } + + return latestValidAutoCompleteHints +} + +/** + * @return the index of the tagging char of the current replacement text, or null if the user is currently + * not entering a replaceable text. + */ +private fun TextFieldValue.getAutoCompleteReplacementTextFirstIndex(tagChars: List): Int? { + return if (selection.collapsed) { + // Gather the last word, meaning the characters from the last whitespace until the cursor. + val tagCharIndex = getTextBeforeSelection(Int.MAX_VALUE) + .indexOfLastWhileTag(tagChars) + ?: return null + + val tagChar = text[tagCharIndex] + + if (tagChar in tagChars) tagCharIndex else null + } else null +} + +@Composable +@Preview +private fun ReplyTextFieldPreview() { + val text = remember { mutableStateOf(TextFieldValue()) } + + Box(modifier = Modifier.fillMaxSize()) { + ReplyTextField( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + replyMode = ReplyMode.NewMessage( + text, + onUpdateTextUpstream = { text.value = it } + ) { + CompletableDeferred() + }, + updateFailureState = {} + ) + } +} + +private fun CharSequence.indexOfLastWhileTag(tagChars: List): Int? { + var foundWhitespace = false + + for (index in lastIndex downTo 0) { + val currentChar = this[index] + + if (currentChar.isWhitespace() && foundWhitespace) { + return index + 1 + } else if (currentChar in tagChars) { + return index + } else if (currentChar.isWhitespace()) { + foundWhitespace = true + } + } + + return if (isEmpty()) null else 0 +} + +/** + * Takes characters until the end of the string or a second whitespace has been found + */ +private fun String.takeWhileTag(): String { + var foundWhitespace = false + + for (index in indices) { + val currentChar = this[index] + + if (currentChar.isWhitespace() && foundWhitespace) { + return substring(0, index) + } else if (currentChar.isWhitespace()) { + foundWhitespace = true + } + } + + return this +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/shared/MetisContentViewModel.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/shared/MetisContentViewModel.kt index 9d3b2ffdf..2996cb29b 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/shared/MetisContentViewModel.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/shared/MetisContentViewModel.kt @@ -1,5 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.shared +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewModelScope import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest import de.tum.informatics.www1.artemis.native_app.core.data.DataState @@ -16,6 +17,7 @@ import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigura import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken import de.tum.informatics.www1.artemis.native_app.core.device.NetworkStatusProvider import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostAction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.ConversationWebsocketDto @@ -26,6 +28,9 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.n import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.MetisStorageService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.ReplyTextStorageService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.InitialReplyTextProvider +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.AutoCompleteCategory +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.AutoCompleteHint +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.ReplyAutoCompleteHintProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost 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 @@ -55,6 +60,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking @@ -71,8 +77,8 @@ internal abstract class MetisContentViewModel( private val metisStorageService: MetisStorageService, private val serverConfigurationService: ServerConfigurationService, private val accountService: AccountService, - accountDataService: AccountDataService, - networkStatusProvider: NetworkStatusProvider, + private val accountDataService: AccountDataService, + private val networkStatusProvider: NetworkStatusProvider, private val conversationService: ConversationService, private val replyTextStorageService: ReplyTextStorageService, private val coroutineContext: CoroutineContext @@ -83,7 +89,7 @@ internal abstract class MetisContentViewModel( networkStatusProvider, websocketProvider, coroutineContext -), InitialReplyTextProvider { +), InitialReplyTextProvider, ReplyAutoCompleteHintProvider { protected val metisContext = MutableStateFlow(initialMetisContext) val currentMetisContext: StateFlow = metisContext @@ -178,7 +184,9 @@ internal abstract class MetisContentViewModel( } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) - override val newMessageText: MutableStateFlow = MutableStateFlow("") + override val legalTagChars: List = listOf('@') + + override val newMessageText: MutableStateFlow = MutableStateFlow(TextFieldValue("")) init { viewModelScope.launch(coroutineContext) { @@ -186,7 +194,7 @@ internal abstract class MetisContentViewModel( newMessageText .debounce(500L) .collect { textToStore -> - storeNewMessageText(textToStore) + storeNewMessageText(textToStore.text) } } } @@ -341,6 +349,42 @@ internal abstract class MetisContentViewModel( } } + override fun produceAutoCompleteHints( + tagChar: Char, + query: String + ): Flow>> = flatMapLatest( + metisContext, + accountService.authToken, + serverConfigurationService.serverUrl + ) { metisContext, authToken, serverUrl -> + retryOnInternet(networkStatusProvider.currentNetworkStatus) { + conversationService + .searchForPotentialCommunicationParticipants( + courseId = metisContext.courseId, + query = query, + includeStudents = true, + includeTutors = true, + includeInstructors = true, + authToken = authToken, + serverUrl = serverUrl + ) + .bind { users -> + AutoCompleteCategory( + name = R.string.markdown_textfield_autocomplete_category_users, + items = users.map { + AutoCompleteHint( + it.name.orEmpty(), + replacementText = "[user]${it.name}(${it.username})[/user]", + id = it.username.orEmpty() + ) + } + ) + .let(::listOf) + } + + } + } + /** * Emits to onRequestReload. If the websocket is currently not connected, requests a reconnect to the websocket */ @@ -354,7 +398,7 @@ internal abstract class MetisContentViewModel( } } - override fun updateInitialReplyText(text: String) { + override fun updateInitialReplyText(text: TextFieldValue) { newMessageText.value = text } @@ -385,7 +429,7 @@ internal abstract class MetisContentViewModel( metisContext.value = newMetisContext viewModelScope.launch(coroutineContext) { - newMessageText.value = retrieveNewMessageText(newMetisContext, getPostId()) + newMessageText.value = TextFieldValue(text = retrieveNewMessageText(newMetisContext, getPostId())) } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisStandalonePostScreen.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisStandalonePostScreen.kt index 778bc4128..5291f210e 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisStandalonePostScreen.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisStandalonePostScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocal +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,6 +28,7 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navDeepLink import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.LocalReplyAutoCompleteHintProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.shared.MetisOutdatedBanner import kotlinx.serialization.encodeToString @@ -174,12 +177,14 @@ fun MetisStandalonePostScreen( requestRefresh = viewModel::requestReload ) - MetisThreadUi( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - viewModel = viewModel - ) + CompositionLocalProvider(LocalReplyAutoCompleteHintProvider provides viewModel) { + MetisThreadUi( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + viewModel = viewModel + ) + } } } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadViewModel.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadViewModel.kt index a9e507ab9..f99c9b8ca 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadViewModel.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadViewModel.kt @@ -1,5 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewModelScope import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest import de.tum.informatics.www1.artemis.native_app.core.data.DataState @@ -243,7 +244,7 @@ internal class MetisThreadViewModel( val replyPost = AnswerPost( creationDate = Clock.System.now(), - content = newMessageText.first(), + content = newMessageText.first().text, post = StandalonePost( id = when (val postId = postId.value) { is StandalonePostId.ClientSideId -> metisStorageService.getServerSidePostId( @@ -265,7 +266,7 @@ internal class MetisThreadViewModel( postId.value = newPostId viewModelScope.launch(coroutineContext) { - newMessageText.value = retrieveNewMessageText(metisContext.value, getPostId()) + newMessageText.value = TextFieldValue(retrieveNewMessageText(metisContext.value, getPostId())) } } } diff --git a/feature/metis/conversation/src/main/res/values/markdown_textfield_strings.xml b/feature/metis/conversation/src/main/res/values/markdown_textfield_strings.xml index 37c4bc643..41965dc93 100644 --- a/feature/metis/conversation/src/main/res/values/markdown_textfield_strings.xml +++ b/feature/metis/conversation/src/main/res/values/markdown_textfield_strings.xml @@ -2,4 +2,6 @@ Edit Preview + + Users \ No newline at end of file diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt index baa9484cc..cc7db7fd7 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt @@ -20,7 +20,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowRight +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Groups2 +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -338,6 +342,12 @@ private fun ConversationListItem( onDismissRequest = onDismissRequest ) { DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = if (conversation.isFavorite) Icons.Default.FavoriteBorder else Icons.Default.Favorite, + contentDescription = null + ) + }, text = { Text( text = stringResource( @@ -353,6 +363,12 @@ private fun ConversationListItem( ) DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = if (conversation.isHidden) Icons.Default.NotificationsActive else Icons.Default.NotificationsOff, + contentDescription = null + ) + }, text = { Text( text = stringResource( diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt index 1aa0d5771..502c2d21f 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt @@ -96,4 +96,4 @@ val ArtemisNotification.communicationType: Commun get() = when (type) { is StandalonePostCommunicationNotificationType, is ReplyPostCommunicationNotificationType -> CommunicationType.QNA_COURSE ConversationNotificationType.CONVERSATION_NEW_MESSAGE, ConversationNotificationType.CONVERSATION_NEW_REPLY_MESSAGE -> CommunicationType.CONVERSATION - } \ No newline at end of file + } diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt index 73f259517..0f0345e46 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt @@ -30,4 +30,4 @@ interface CommunicationNotificationManager { * Deletes a communication and clears the notification from the notification tray. */ suspend fun deleteCommunication(parentId: Long, type: CommunicationType) -} \ No newline at end of file +} diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/NotificationManager.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/NotificationManager.kt index 8691530ae..0d7167704 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/NotificationManager.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/NotificationManager.kt @@ -9,4 +9,4 @@ interface NotificationManager { context: Context, artemisNotification: ArtemisNotification<*> ) -} \ No newline at end of file +} diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt index f7109174f..30bc97733 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt @@ -9,6 +9,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.room.withTransaction +import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PushNotificationArtemisMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.PushCommunicationDatabaseProvider @@ -181,7 +182,7 @@ internal class CommunicationNotificationManagerImpl( messages.forEach { message -> style.addMessage( NotificationCompat.MessagingStyle.Message( - message.text, + PushNotificationArtemisMarkdownTransformer.transformMarkdown(message.text), message.date.toEpochMilliseconds(), Person.Builder() .setName(message.authorName) @@ -305,4 +306,4 @@ internal class CommunicationNotificationManagerImpl( .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) .build() } -} \ No newline at end of file +} diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt index 9df9edc61..5a3934465 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt @@ -56,4 +56,4 @@ internal class NotificationManagerImpl( } } } -} \ No newline at end of file +} diff --git a/playStoreScreenshots/smartphone/conversation.png b/playStoreScreenshots/smartphone/conversation.png new file mode 100644 index 000000000..b570b13da Binary files /dev/null and b/playStoreScreenshots/smartphone/conversation.png differ diff --git a/playStoreScreenshots/smartphone/conversationOverview.png b/playStoreScreenshots/smartphone/conversationOverview.png new file mode 100644 index 000000000..c97b49f34 Binary files /dev/null and b/playStoreScreenshots/smartphone/conversationOverview.png differ diff --git a/playStoreScreenshots/smartphone/dashboard.png b/playStoreScreenshots/smartphone/dashboard.png new file mode 100644 index 000000000..648c0c40c Binary files /dev/null and b/playStoreScreenshots/smartphone/dashboard.png differ diff --git a/playStoreScreenshots/smartphone/exerciseList.png b/playStoreScreenshots/smartphone/exerciseList.png new file mode 100644 index 000000000..cfa4dfa55 Binary files /dev/null and b/playStoreScreenshots/smartphone/exerciseList.png differ diff --git a/playStoreScreenshots/smartphone/lecture.png b/playStoreScreenshots/smartphone/lecture.png new file mode 100644 index 000000000..3c969139c Binary files /dev/null and b/playStoreScreenshots/smartphone/lecture.png differ diff --git a/playStoreScreenshots/smartphone/quiz.png b/playStoreScreenshots/smartphone/quiz.png new file mode 100644 index 000000000..5e5f13368 Binary files /dev/null and b/playStoreScreenshots/smartphone/quiz.png differ