From 600af6b572ee80b8976a6d2b61466100e6f0a2ec Mon Sep 17 00:00:00 2001 From: Asli Aykan Date: Fri, 20 Sep 2024 17:57:52 +0300 Subject: [PATCH 1/4] added new features & enhancements --- .../network/impl/ExerciseServiceImpl.kt | 24 +- .../core/model/exercise/Exercise.kt | 4 +- .../core/model/exercise/FileUploadExercise.kt | 4 +- .../core/model/exercise/ModelingExercise.kt | 4 +- .../model/exercise/ProgrammingExercise.kt | 4 +- .../core/model/exercise/QuizExercise.kt | 4 +- .../core/model/exercise/TextExercise.kt | 4 +- .../core/model/exercise/UnknownExercise.kt | 4 +- .../core/ui/exercise/ExerciseActionButtons.kt | 45 +- .../src/main/res/values/exercise_strings.xml | 2 + .../courseview/MessagingScreenshots.kt | 14 +- .../feature/exerciseview/ArtemisWebView.kt | 3 +- .../feature/exerciseview/ExerciseViewUi.kt | 1 + .../home/ExerciseScreenTopAppBar.kt | 88 ++-- .../home/overview/ExerciseOverviewTab.kt | 30 +- .../main/res/values/exercise_view_strings.xml | 1 + .../conversation/ui/chatlist/MetisChatList.kt | 23 +- .../ui/post/PostContextBottomSheet.kt | 4 +- .../conversation/ui/reply/ReplyTextField.kt | 393 +++++++++++++----- .../conversation/ui/thread/MetisThreadUi.kt | 12 +- .../ConversationCollections.kt | 23 +- .../storage/ConversationPreferenceService.kt | 5 +- ...onversationPreferenceStorageServiceImpl.kt | 15 +- .../browse_channels/BrowseChannelsScreen.kt | 85 ++-- .../BrowseChannelsViewModel.kt | 42 +- .../create_channel/CreateChannelScreen.kt | 133 +++--- .../create_channel/CreateChannelViewModel.kt | 12 +- .../conversation/overview/ConversationList.kt | 381 +++++++++-------- .../overview/ConversationOverviewBody.kt | 258 ++++++++---- .../overview/ConversationOverviewViewModel.kt | 59 ++- .../res/values/browse_channels_strings.xml | 2 + .../values/conversation_overview_strings.xml | 5 +- .../res/values/create_channel_strings.xml | 5 +- .../feature/metis/ui/ConversationFacadeUi.kt | 8 +- .../metis/ui/SinglePageConversationBody.kt | 94 ++++- 35 files changed, 1199 insertions(+), 596 deletions(-) diff --git a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt index b87ddd6a6..e671f2fd3 100644 --- a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt +++ b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt @@ -7,11 +7,13 @@ import de.tum.informatics.www1.artemis.native_app.core.data.service.network.Exer import de.tum.informatics.www1.artemis.native_app.core.data.service.KtorProvider import de.tum.informatics.www1.artemis.native_app.core.data.service.impl.JsonProvider import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise -import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.appendPathSegments import io.ktor.http.contentType +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject internal class ExerciseServiceImpl( private val ktorProvider: KtorProvider, @@ -23,14 +25,20 @@ internal class ExerciseServiceImpl( authToken: String ): NetworkResponse { return performNetworkCall { - ktorProvider.ktorClient.get(serverUrl) { - url { - appendPathSegments("api", "exercises", exerciseId.toString(), "details") + val response = ktorProvider.ktorClient.get(serverUrl) { + url { + appendPathSegments("api", "exercises", exerciseId.toString(), "details") + } + + contentType(ContentType.Application.Json) + cookieAuth(authToken) } - contentType(ContentType.Application.Json) - cookieAuth(authToken) - }.body() + val jsonElement = jsonProvider.applicationJsonConfiguration.parseToJsonElement(response.bodyAsText()) + val exercise = jsonProvider.applicationJsonConfiguration + .decodeFromJsonElement(jsonElement.jsonObject["exercise"]!!) + + exercise } } -} \ No newline at end of file +} diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt index 9e665cc41..f1cad1241 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt @@ -39,13 +39,15 @@ sealed class Exercise { abstract val mode: Mode abstract val categories: List abstract val visibleToStudents: Boolean? + abstract val secondCorrectionEnabled: Boolean? + abstract val presentationScoreEnabled: Boolean? abstract val teamMode: Boolean abstract val studentAssignedTeamId: Long? abstract val studentAssignedTeamIdComputed: Boolean abstract val problemStatement: String? abstract val assessmentType: AssessmentType? abstract val allowComplaintsForAutomaticAssessments: Boolean? - abstract val allowManualFeedbackRequests: Boolean? + abstract val allowFeedbackRequests: Boolean? abstract val includedInOverallScore: IncludedInOverallScore abstract val exampleSolutionPublicationDate: Instant? abstract val studentParticipations: List? diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt index 8f728d935..a3f0c6220 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt @@ -20,6 +20,8 @@ data class FileUploadExercise( override val assessmentDueDate: Instant? = null, override val difficulty: Difficulty? = null, override val mode: Mode = Mode.INDIVIDUAL, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val categories: List = emptyList(), override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, @@ -28,7 +30,7 @@ data class FileUploadExercise( override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt index de29e6863..1f5befe08 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt @@ -25,10 +25,12 @@ data class ModelingExercise( override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, override val studentAssignedTeamIdComputed: Boolean = false, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt index abd6923cc..87eb872f6 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt @@ -29,11 +29,13 @@ data class ProgrammingExercise( override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt index c7bdbaec2..b7875d1d2 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt @@ -30,11 +30,13 @@ data class QuizExercise( override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt index f7c3a799b..7a80f7a85 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt @@ -23,12 +23,14 @@ data class TextExercise( override val categories: List = emptyList(), override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamId: Long? = null, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt index 0ab6e8075..c0c2eb3f9 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt @@ -29,7 +29,9 @@ data class UnknownExercise( override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt index 1a1f41c5b..286ac4a67 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt @@ -1,11 +1,24 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.exercise +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.ProgrammingExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise @@ -116,6 +129,10 @@ fun ExerciseActionButtons( ) } } + } else { + Row(modifier=Modifier.padding(top=2.dp, bottom = 2.dp)) { + InfoMessageCard() + } } if (templateStatus is ResultTemplateStatus.WithResult) { @@ -187,4 +204,30 @@ class BoundExerciseActions( onClickViewResult = { onClickViewResult(exerciseId) }, onClickViewQuizResults = { onClickViewQuizResults(exerciseId) } ) -} \ No newline at end of file +} + + +@Composable +fun InfoMessageCard() { + Box( + modifier = Modifier + .border(width = 2.dp, color = Color.LightGray) + .background(Color(0xFFB3E5FC)) // Light sky blue background + .padding(10.dp) + .fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "Information", + modifier = Modifier.padding(end = 8.dp), + tint = Color(0xFF0288D1) + ) + Text( + text = stringResource(id = R.string.exercise_participation_not_possible), + fontSize = 16.sp, + color = Color.Black + ) + } + } +} diff --git a/core/ui/src/main/res/values/exercise_strings.xml b/core/ui/src/main/res/values/exercise_strings.xml index 5b518152b..1929bc041 100644 --- a/core/ui/src/main/res/values/exercise_strings.xml +++ b/core/ui/src/main/res/values/exercise_strings.xml @@ -39,6 +39,8 @@ View result + Participating this exercise is currently + not possible in the mobile app. Start exercise Open exercise View submission diff --git a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt index fe970f72c..7852856a9 100644 --- a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt +++ b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt @@ -106,10 +106,13 @@ fun `Metis - Conversation Overview`() { ): Flow = flowOf( ConversationPreferenceService.Preferences( favouritesExpanded = true, - channelsExpanded = true, + generalsExpanded = true, groupChatsExpanded = true, personalConversationsExpanded = true, - hiddenExpanded = false + hiddenExpanded = false, + examsExpanded = true, + exercisesExpanded = true, + lecturesExpanded = true, ) ) @@ -140,7 +143,9 @@ fun `Metis - Conversation Overview`() { viewModel = viewModel, onNavigateToConversation = {}, onRequestCreatePersonalConversation = {}, - onRequestAddChannel = {} + onRequestAddChannel = {}, + onRequestBrowseChannel = {}, + canCreateChannel = false, ) }, onNavigateBack = { }, @@ -249,7 +254,8 @@ fun `Metis - Conversation Channel`() { onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, bottomItem = null, onClickViewPost = {}, - onRequestRetrySend = {} + onRequestRetrySend = {}, + title = "" ) } ) diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt index 5990ab744..7da78614e 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt @@ -10,6 +10,7 @@ import android.webkit.WebSettings import android.webkit.WebView import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -116,7 +117,7 @@ internal fun ArtemisWebView( Box(modifier = modifier) { WebView( - modifier = Modifier, + modifier = Modifier.fillMaxSize(), client = remember(value) { ThemeClient(value) }, state = webViewState, onCreated = { diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt index 5815bb673..623108980 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt @@ -1,6 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt index 3914aac2b..31c6dba97 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt @@ -2,6 +2,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home import androidx.annotation.StringRes import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -9,11 +10,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -22,7 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -38,6 +41,7 @@ import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.accompanist.placeholder.material.placeholder @@ -134,8 +138,22 @@ internal fun TopBarExerciseInformation( ) { val dueDate = exercise.bind { it.dueDate }.orElse(null) val assessmentDueData = exercise.bind { it.assessmentDueDate }.orElse(null) - - // Prepare ui that is movable between long and short toolbars + val releaseData = exercise.bind { it.releaseDate }.orElse(null) + + var maxWidth: Int by remember { mutableIntStateOf(0) } + val updateMaxWidth = { new: Int -> maxWidth = new } + + val dueDateTopBarTextInformation = + @Composable { date: Instant, hintRes: @receiver:StringRes Int -> + TopBarTextInformation( + modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp), + hintColumnWidth = maxWidth, + hint = stringResource(id = hintRes), + dataText = getRelativeTime(to = date).toString(), + dataColor = getDueDateColor(date), + updateHintColumnWidth = updateMaxWidth + ) + } val exerciseInfoUi = @Composable { EmptyDataStateUi( @@ -178,31 +196,27 @@ internal fun TopBarExerciseInformation( } Text( - modifier = Modifier.placeholder(exercise !is DataState.Success), + modifier = Modifier + .placeholder(exercise !is DataState.Success) + .padding(bottom = 4.dp), text = pointsHintText, style = MaterialTheme.typography.bodyLarge ) + + releaseData?.let { + dueDateTopBarTextInformation( + it, + R.string.exercise_view_overview_hint_assessment_release_date + ) + } + } val dueDateColumnUi = @Composable { contentModifier: Modifier -> - var maxWidth: Int by remember { mutableStateOf(0) } - val updateMaxWidth = { new: Int -> maxWidth = new } Column( modifier = contentModifier, - verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val dueDateTopBarTextInformation = - @Composable { date: Instant, hintRes: @receiver:StringRes Int -> - TopBarTextInformation( - modifier = Modifier.fillMaxWidth(), - hintColumnWidth = maxWidth, - hint = stringResource(id = hintRes), - dataText = getRelativeTime(to = date).toString(), - dataColor = getDueDateColor(date), - updateHintColumnWidth = updateMaxWidth - ) - } dueDate?.let { dueDateTopBarTextInformation( @@ -217,21 +231,40 @@ internal fun TopBarExerciseInformation( R.string.exercise_view_overview_hint_assessment_due_date ) } + + val complaintPossible = exercise.bind { exercise -> + exercise.allowComplaintsForAutomaticAssessments + }.orElse(false) + + Text( + modifier = Modifier + .placeholder(exercise !is DataState.Success) + .padding(bottom = 4.dp), + text = "Complaint possible: " + if (complaintPossible == true) "Yes" else "No", + style = MaterialTheme.typography.bodyLarge + ) + } } // Actual UI Column( - modifier = modifier, + modifier = modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - TitleText( + + Text( + text = "Exercise Details", + style = MaterialTheme.typography.titleMedium, modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = titleTextAlpha }, - exerciseDataState = exercise, - style = MaterialTheme.typography.headlineLarge, - maxLines = 2 + .padding(bottom = 1.dp) + .fillMaxWidth(), + textAlign = TextAlign.Start + ) + Divider( + color = Color.Black, + thickness = 3.dp, + modifier = Modifier.padding(vertical = 0.dp) ) // Here we make the distinction in the layout between long toolbar and short toolbar @@ -370,7 +403,7 @@ internal fun StaticTopAppBar( Column(modifier = modifier) { TopAppBar( modifier = Modifier.fillMaxWidth(), - title = {}, + title = { TitleText(modifier = modifier, maxLines = 1, exerciseDataState = exerciseDataState) }, navigationIcon = { TopAppBarNavigationIcon(onNavigateBack = onNavigateBack) }, @@ -382,7 +415,8 @@ internal fun StaticTopAppBar( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .border(2.dp, Color.Black, RoundedCornerShape(4.dp)), titleTextAlpha = 1f, exercise = exerciseDataState, isLongToolbar = isLongToolbar diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt index 2caba8fa8..b351c303f 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt @@ -2,9 +2,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.ove import android.annotation.SuppressLint import android.webkit.WebView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.google.accompanist.web.WebViewState import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise @@ -15,7 +18,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWe @SuppressLint("SetJavaScriptEnabled") @Composable internal fun ExerciseOverviewTab( - modifier: Modifier, + modifier: Modifier = Modifier, exercise: Exercise, webViewState: WebViewState?, serverUrl: String, @@ -24,24 +27,41 @@ internal fun ExerciseOverviewTab( webView: WebView?, actions: ExerciseActions ) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Spacer(modifier = Modifier) + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ParticipationStatusUi( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), exercise = exercise, actions = actions ) if (exercise !is QuizExercise && webViewState != null) { ArtemisWebView( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .weight(1f), webViewState = webViewState, webView = webView, serverUrl = serverUrl, authToken = authToken, setWebView = setWebView ) + } else { + Text( + text = "No problem statement available.", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + color = Color.Gray + ) } } } diff --git a/feature/exercise-view/src/main/res/values/exercise_view_strings.xml b/feature/exercise-view/src/main/res/values/exercise_view_strings.xml index d7cc8a39f..8580ba5ff 100644 --- a/feature/exercise-view/src/main/res/values/exercise_view_strings.xml +++ b/feature/exercise-view/src/main/res/values/exercise_view_strings.xml @@ -13,6 +13,7 @@ No points Submission due: Assessment due: + Release date: Your exercise status: 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 6ce519471..64da38bce 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 @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -27,6 +28,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems +import de.tum.informatics.www1.artemis.native_app.core.data.DataState 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.ConversationViewModel @@ -42,6 +44,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.S 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.db.pojo.PostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.PagingStateError +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ReportVisibleMetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisiblePostList import kotlinx.coroutines.Deferred @@ -64,7 +67,8 @@ internal fun MetisChatList( listContentPadding: PaddingValues, state: LazyListState = rememberLazyListState(), isReplyEnabled: Boolean = true, - onClickViewPost: (StandalonePostId) -> Unit + onClickViewPost: (StandalonePostId) -> Unit, + title: String? = "Replying..." ) { ReportVisibleMetisContext(remember(viewModel.metisContext) { VisiblePostList(viewModel.metisContext) }) @@ -75,6 +79,14 @@ internal fun MetisChatList( val bottomItem: PostPojo? by viewModel.chatListUseCase.bottomPost.collectAsState() + val conversationDataState by viewModel.latestUpdatedConversation.collectAsState() + + val updatedTitle by remember(conversationDataState) { + derivedStateOf { + conversationDataState.bind { it.humanReadableName }.orElse("Conversation") + } + } + MetisChatList( modifier = modifier, initialReplyTextProvider = viewModel, @@ -92,7 +104,8 @@ internal fun MetisChatList( onDeletePost = viewModel::deletePost, onRequestReactWithEmoji = viewModel::createOrDeleteReaction, onClickViewPost = onClickViewPost, - onRequestRetrySend = viewModel::retryCreatePost + onRequestRetrySend = viewModel::retryCreatePost, + title = updatedTitle ) } @@ -114,7 +127,8 @@ fun MetisChatList( onDeletePost: (IStandalonePost) -> Deferred, onRequestReactWithEmoji: (IStandalonePost, emojiId: String, create: Boolean) -> Deferred, onClickViewPost: (StandalonePostId) -> Unit, - onRequestRetrySend: (StandalonePostId) -> Unit + onRequestRetrySend: (StandalonePostId) -> Unit, + title: String ) { MetisReplyHandler( initialReplyTextProvider = initialReplyTextProvider, @@ -181,7 +195,8 @@ fun MetisChatList( ReplyTextField( modifier = Modifier.fillMaxWidth(), replyMode = replyMode, - updateFailureState = updateFailureStateDelegate + updateFailureState = updateFailureStateDelegate, + title = title, ) } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt index c0c3538c3..2e09e64dd 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt @@ -16,10 +16,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddReaction import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Reply import androidx.compose.material3.Divider import androidx.compose.material3.Icon @@ -200,7 +200,7 @@ private fun EmojiReactionBar( disabled = false ) { Icon( - imageVector = Icons.Default.MoreHoriz, + imageVector = Icons.Default.AddReaction, contentDescription = null ) } 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 6412f1598..83cc9c20a 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 @@ -2,36 +2,15 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable -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 -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -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.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -40,16 +19,18 @@ 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.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight 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.text.style.TextDecoration 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 @@ -68,7 +49,8 @@ private const val DisabledContentAlpha = 0.75f internal fun ReplyTextField( modifier: Modifier, replyMode: ReplyMode, - updateFailureState: (MetisModificationFailure?) -> Unit + updateFailureState: (MetisModificationFailure?) -> Unit, + title: String ) { val replyState: ReplyState = rememberReplyState(replyMode, updateFailureState) @@ -96,7 +78,8 @@ internal fun ReplyTextField( .fillMaxWidth() .testTag(TEST_TAG_CAN_CREATE_REPLY), replyMode = replyMode, - onReply = { targetReplyState.onCreateReply() } + onReply = { targetReplyState.onCreateReply() }, + title = "Message $title" ) } @@ -115,7 +98,8 @@ internal fun ReplyTextField( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), - onCancel = targetReplyState.onCancelSendReply + onCancel = targetReplyState.onCancelSendReply, + title = title ) } } @@ -125,7 +109,7 @@ internal fun ReplyTextField( } @Composable -private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit) { +private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit, title: String?) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically @@ -136,7 +120,7 @@ private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit) { ) Text( - text = stringResource(id = R.string.create_answer_sending_reply), + text = title.toString(), textAlign = TextAlign.Center, modifier = Modifier.weight(1f) ) @@ -152,7 +136,8 @@ private fun CreateReplyUi( modifier: Modifier, replyMode: ReplyMode, focusRequester: FocusRequester = remember { FocusRequester() }, - onReply: () -> Unit + onReply: () -> Unit, + title: String? ) { var prevReplyContent by remember { mutableStateOf("") } var displayTextField: Boolean by remember { mutableStateOf(false) } @@ -173,12 +158,6 @@ private fun CreateReplyUi( 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) @@ -186,95 +165,280 @@ private fun CreateReplyUi( } } - Box(modifier = modifier) { - 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 + Column(modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth()) { + if (displayTextField || currentTextFieldValue.text.isNotBlank()) { + val tagChars = LocalReplyAutoCompleteHintProvider.current.legalTagChars + val autoCompleteHints = manageAutoCompleteHints(currentTextFieldValue) + + var textFieldWidth by remember { mutableIntStateOf(0) } + var popupMaxHeight by remember { mutableStateOf(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), + textFieldValue = currentTextFieldValue, + onTextChanged = replyMode::onUpdate, + focusRequester = focusRequester, + onFocusLost = { + if (displayTextField && currentTextFieldValue.text.isEmpty()) { + displayTextField = false + } + }, + sendButton = { + IconButton( + modifier = Modifier.testTag(TEST_TAG_REPLY_SEND_BUTTON), + onClick = onReply, + enabled = currentTextFieldValue.text.isNotBlank() + ) { + Icon( + imageVector = when (replyMode) { + is ReplyMode.EditMessage -> Icons.Default.Edit + is ReplyMode.NewMessage -> Icons.Default.Send + }, + contentDescription = null + ) + } }, - onDismissRequest = { - requestDismissAutoCompletePopup = true + topRightButton = { + if (replyMode is ReplyMode.EditMessage) { + IconButton(onClick = replyMode.onCancelEditMessage) { + Icon(imageVector = Icons.Default.Cancel, contentDescription = null) + } + } } ) - } - 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), - textFieldValue = currentTextFieldValue, - onTextChanged = replyMode::onUpdate, - focusRequester = focusRequester, - onFocusLost = { - if (displayTextField && currentTextFieldValue.text.isEmpty()) { - displayTextField = false - } - }, - sendButton = { - IconButton( - modifier = Modifier.testTag(TEST_TAG_REPLY_SEND_BUTTON), - onClick = onReply, - enabled = currentTextFieldValue.text.isNotBlank() - ) { - Icon( - imageVector = when (replyMode) { - is ReplyMode.EditMessage -> Icons.Default.Edit - is ReplyMode.NewMessage -> Icons.Default.Send - }, - contentDescription = null - ) - } - }, - topRightButton = { - if (replyMode is ReplyMode.EditMessage) { - IconButton(onClick = replyMode.onCancelEditMessage) { - Icon(imageVector = Icons.Default.Cancel, contentDescription = null) - } + LaunchedEffect(requestFocus) { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false } } + } else { + UnfocusedPreviewReplyTextField({ + displayTextField = true + requestFocus = true + }, title = title) + } + } + + if (displayTextField || currentTextFieldValue.text.isNotBlank()) { + FormattingOptions( + currentTextFieldValue = currentTextFieldValue, + onTextChanged = replyMode::onUpdate ) + } + } +} - LaunchedEffect(requestFocus) { - if (requestFocus) { - focusRequester.requestFocus() - requestFocus = false - } - } +enum class MarkdownStyle(val startTag: String, val endTag: String) { + Bold("**", "**"), + Italic("*", "*"), + Underline("", ""), + InlineCode("`", "`"), + CodeBlock("```", "```"), + Blockquote("> ", "") +} + +@Composable +private fun FormattingOptions( + currentTextFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start + ) { + // Bold Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Bold, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "B", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } + + // Italic Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Italic, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "I", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + ) + } + + // Underline Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Underline, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "U", + style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.Underline) + ) + } + + // Inline Code Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.InlineCode, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + + // Code Block Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.CodeBlock, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "{ }", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + + // Blockquote Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Blockquote, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "\"", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + ) + } + } +} + +private fun applyMarkdownStyle( + style: MarkdownStyle, + currentTextFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit +) { + val selection = currentTextFieldValue.selection + val text = currentTextFieldValue.text + + val startTag = style.startTag + val endTag = style.endTag + + if (selection.collapsed) { + // No text selected + if (style == MarkdownStyle.CodeBlock) { + // Insert code block with newlines + val newText = text.substring(0, selection.start) + + "$startTag\n\n$endTag" + + text.substring(selection.end) + val newCursorPosition = selection.start + startTag.length + 1 + onTextChanged( + TextFieldValue( + text = newText, + selection = TextRange(newCursorPosition, newCursorPosition) + ) + ) } else { - UnfocusedPreviewReplyTextField { - displayTextField = true - requestFocus = true - } + // Other styles + val newText = text.substring(0, selection.start) + startTag + endTag + text.substring(selection.end) + val newCursorPosition = selection.start + startTag.length + onTextChanged( + TextFieldValue( + text = newText, + selection = TextRange(newCursorPosition, newCursorPosition) + ) + ) + } + } else { + val selectedText = text.substring(selection.start, selection.end) + if (style == MarkdownStyle.CodeBlock) { + val newText = text.substring(0, selection.start) + + "$startTag\n$selectedText\n$endTag" + + text.substring(selection.end) + val newSelection = TextRange( + selection.start + startTag.length + 1, + selection.end + startTag.length + 1 + ) + onTextChanged( + TextFieldValue( + text = newText, + selection = newSelection + ) + ) + } else { + val newText = text.substring(0, selection.start) + + startTag + + selectedText + + endTag + + text.substring(selection.end) + val newSelection = TextRange(selection.end + startTag.length + endTag.length) + onTextChanged( + TextFieldValue( + text = newText, + selection = newSelection + ) + ) } } } + @Composable -private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit) { +private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit, title: String?) { Row( modifier = Modifier .fillMaxWidth() @@ -283,7 +447,7 @@ private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(id = R.string.create_answer_click_to_write), + text = title.toString(), modifier = Modifier .padding(vertical = 8.dp) .weight(1f) @@ -452,7 +616,8 @@ private fun ReplyTextFieldPreview() { ) { CompletableDeferred() }, - updateFailureState = {} + updateFailureState = {}, + title = "Replying.." ) } } 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 d4a02983e..1b84448f2 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -42,12 +43,12 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.MetisReplyHandler import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.ReplyTextField 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.content.StandalonePostId import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ReportVisibleMetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleStandalonePostDetails 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.db.pojo.AnswerPostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import kotlinx.coroutines.CompletableDeferred internal const val TEST_TAG_THREAD_LIST = "TEST_TAG_THREAD_LIST" @@ -84,6 +85,12 @@ internal fun MetisThreadUi( val listState = rememberLazyListState() + val title by remember(conversationDataState) { + derivedStateOf { + conversationDataState.bind { it.humanReadableName }.orElse("Conversation") + } + } + ProvideEmojis { MetisReplyHandler( initialReplyTextProvider = viewModel, @@ -149,7 +156,8 @@ internal fun MetisThreadUi( .fillMaxWidth() .heightIn(max = this@BoxWithConstraints.maxHeight * 0.6f), replyMode = replyMode, - updateFailureState = updateFailureStateDelegate + updateFailureState = updateFailureStateDelegate, + title = title ) } } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt index fd105bc07..c62ef87e5 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt @@ -10,9 +10,20 @@ data class ConversationCollections( val channels: ConversationCollection, val groupChats: ConversationCollection, val directChats: ConversationCollection, - val hidden: ConversationCollection + val hidden: ConversationCollection, + val exerciseChannels: ConversationCollection, + val lectureChannels: ConversationCollection, + val examChannels: ConversationCollection ) { - val conversations: List get() = favorites.conversations + channels.conversations + groupChats.conversations + directChats.conversations + hidden.conversations + val conversations: List + get() = favorites.conversations + + channels.conversations + + groupChats.conversations + + directChats.conversations + + hidden.conversations + + exerciseChannels.conversations + + lectureChannels.conversations + + examChannels.conversations fun filtered(query: String): ConversationCollections { return ConversationCollections( @@ -20,13 +31,17 @@ data class ConversationCollections( groupChats = groupChats.filter { it.filterPredicate(query) }, directChats = directChats.filter { it.filterPredicate(query) }, favorites = favorites.filter { it.filterPredicate(query) }, - hidden = hidden.filter { it.filterPredicate(query) } + hidden = hidden.filter { it.filterPredicate(query) }, + exerciseChannels = exerciseChannels.filter { it.filterPredicate(query) }, + lectureChannels = lectureChannels.filter { it.filterPredicate(query) }, + examChannels = examChannels.filter { it.filterPredicate(query) } ) } data class ConversationCollection( val conversations: List, - val isExpanded: Boolean + val isExpanded: Boolean, + val showPrefix: Boolean = true ) { fun filter(predicate: (Conversation) -> Boolean) = copy(conversations = conversations.filter(predicate)) diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt index cc0185922..e068ce5a2 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt @@ -10,7 +10,10 @@ interface ConversationPreferenceService { data class Preferences( val favouritesExpanded: Boolean, - val channelsExpanded: Boolean, + val generalsExpanded: Boolean, + val examsExpanded: Boolean, + val exercisesExpanded: Boolean, + val lecturesExpanded: Boolean, val groupChatsExpanded: Boolean, val personalConversationsExpanded: Boolean, val hiddenExpanded: Boolean diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt index d878cb4b9..6334f144d 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt @@ -18,6 +18,9 @@ internal class ConversationPreferenceStorageServiceImpl(private val context: Con private const val KEY_GROUP_CHATS_EXPANDED = "group_chats" private const val KEY_PERSONAL_CONVERSATIONS_EXPANDED = "personal_conv" private const val KEY_HIDDEN_EXPANDED = "hidden" + private const val KEY_EXAMS_EXPANDED = "exams" + private const val KEY_EXERCISES_EXPANDED = "exercises" + private const val KEY_LECTURES_EXPANDED = "lectures" } private val Context.dataStore by preferencesDataStore("conversation_preferences") @@ -28,20 +31,26 @@ internal class ConversationPreferenceStorageServiceImpl(private val context: Con ): Flow = context.dataStore.data.map { data -> ConversationPreferenceService.Preferences( favouritesExpanded = data[getKey(serverUrl, courseId, KEY_FAVOURITES_EXPANDED)] ?: true, - channelsExpanded = data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] ?: true, + generalsExpanded = data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] ?: true, groupChatsExpanded = data[getKey(serverUrl, courseId, KEY_GROUP_CHATS_EXPANDED)] ?: true, personalConversationsExpanded = data[getKey(serverUrl, courseId, KEY_PERSONAL_CONVERSATIONS_EXPANDED)] ?: true, - hiddenExpanded = data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] ?: false + hiddenExpanded = data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] ?: false, + examsExpanded = data[getKey(serverUrl, courseId, KEY_EXAMS_EXPANDED)] ?: true, + exercisesExpanded = data[getKey(serverUrl, courseId, KEY_EXERCISES_EXPANDED)] ?: true, + lecturesExpanded = data[getKey(serverUrl, courseId, KEY_LECTURES_EXPANDED)] ?: true, ) } override suspend fun updatePreferences(serverUrl: String, courseId: Long, preferences: ConversationPreferenceService.Preferences) { context.dataStore.edit { data -> data[getKey(serverUrl, courseId, KEY_FAVOURITES_EXPANDED)] = preferences.favouritesExpanded - data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] = preferences.channelsExpanded + data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] = preferences.generalsExpanded data[getKey(serverUrl, courseId, KEY_GROUP_CHATS_EXPANDED)] = preferences.groupChatsExpanded data[getKey(serverUrl, courseId, KEY_PERSONAL_CONVERSATIONS_EXPANDED)] = preferences.personalConversationsExpanded data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] = preferences.hiddenExpanded + data[getKey(serverUrl, courseId, KEY_EXAMS_EXPANDED)] = preferences.examsExpanded + data[getKey(serverUrl, courseId, KEY_EXERCISES_EXPANDED)] = preferences.exercisesExpanded + data[getKey(serverUrl, courseId, KEY_LECTURES_EXPANDED)] = preferences.lecturesExpanded } } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt index 900ac4c09..8f8e20ecb 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt @@ -1,21 +1,24 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.browse_channels -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background 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 +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Create -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,9 +30,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder +import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog @@ -37,7 +38,6 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateU import de.tum.informatics.www1.artemis.native_app.core.ui.compose.NavigationBackButton import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.common.ChannelIcons -import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.courseNavGraphBuilderExtensions import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import kotlinx.coroutines.Deferred import org.koin.androidx.compose.koinViewModel @@ -50,14 +50,12 @@ fun BrowseChannelsScreen( modifier: Modifier, courseId: Long, onNavigateToConversation: (conversationId: Long) -> Unit, - onNavigateToCreateChannel: () -> Unit, onNavigateBack: () -> Unit ) { BrowseChannelsScreen( modifier = modifier, viewModel = koinViewModel { parametersOf(courseId) }, onNavigateToConversation = onNavigateToConversation, - onNavigateToCreateChannel = onNavigateToCreateChannel, onNavigateBack = onNavigateBack ) } @@ -67,10 +65,12 @@ internal fun BrowseChannelsScreen( modifier: Modifier, viewModel: BrowseChannelsViewModel, onNavigateToConversation: (conversationId: Long) -> Unit, - onNavigateToCreateChannel: () -> Unit, onNavigateBack: () -> Unit ) { - val canCreateChannel: Boolean by viewModel.canCreateChannel.collectAsState() + + LaunchedEffect(Unit) { + viewModel.requestReload() + } val channelsDataState by viewModel.channels.collectAsState() @@ -98,13 +98,7 @@ internal fun BrowseChannelsScreen( navigationIcon = { NavigationBackButton(onNavigateBack) } ) }, - floatingActionButton = { - if (canCreateChannel) { - FloatingActionButton(onClick = onNavigateToCreateChannel) { - Icon(imageVector = Icons.Default.Create, contentDescription = null) - } - } - } + ) { padding -> BasicDataStateUi( modifier = Modifier @@ -159,20 +153,57 @@ private fun ChannelChatItem(channelChat: ChannelChat, onClick: () -> Unit) { ListItem( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) .testTag(testTagForBrowsedChannelItem(channelChat.id)), leadingContent = { ChannelIcons(channelChat) }, headlineContent = { Text(channelChat.name) }, supportingContent = { - Text( - text = pluralStringResource( - id = R.plurals.browse_channel_channel_item_member_count, - count = channelChat.numberOfMembers, - channelChat.numberOfMembers + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (channelChat.isMember) { + Text( + text = stringResource(id = R.string.joined_channel), + modifier = Modifier + .padding(end = 8.dp) + .background( + MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall + ) + } + + Text( + text = pluralStringResource( + id = R.plurals.browse_channel_channel_item_member_count, + count = channelChat.numberOfMembers, + channelChat.numberOfMembers + ) ) - ) + } + }, + trailingContent = { + if (!channelChat.isMember) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier + .wrapContentSize() + ) { + Text(text = stringResource(id = R.string.join_button_title)) + } + } } ) } + + diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt index 42d97faf3..18fb801cf 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel 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 +import de.tum.informatics.www1.artemis.native_app.core.data.onSuccess import de.tum.informatics.www1.artemis.native_app.core.data.retryOnInternet import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService import de.tum.informatics.www1.artemis.native_app.core.data.service.network.CourseService @@ -12,16 +13,15 @@ import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService 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.model.account.isAtLeastTutorInCourse +import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.service.network.ChannelService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -30,7 +30,7 @@ internal class BrowseChannelsViewModel( private val courseId: Long, private val accountService: AccountService, private val serverConfigurationService: ServerConfigurationService, - private val channelService: de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.service.network.ChannelService, + private val channelService: ChannelService, private val networkStatusProvider: NetworkStatusProvider, accountDataService: AccountDataService, courseService: CourseService, @@ -39,7 +39,7 @@ internal class BrowseChannelsViewModel( private val requestRefresh = MutableSharedFlow(extraBufferCapacity = 1) - val channels: StateFlow>> = flatMapLatest( + val channels: StateFlow>> = flatMapLatest( serverConfigurationService.serverUrl, accountService.authToken, requestRefresh.onStart { emit(Unit) } @@ -47,50 +47,42 @@ internal class BrowseChannelsViewModel( retryOnInternet(networkStatusProvider.currentNetworkStatus) { channelService .getChannels(courseId, serverUrl, authToken) - .bind { channels -> channels.filter { !it.isMember } } + .bind { channels -> channels } } } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) - val canCreateChannel: StateFlow = flatMapLatest( - serverConfigurationService.serverUrl, - accountService.authToken, - requestRefresh.onStart { emit(Unit) } - ) { serverUrl, authToken, _ -> - retryOnInternet(networkStatusProvider.currentNetworkStatus) { - courseService.getCourse(courseId, serverUrl, authToken) - .then { courseWithScore -> - accountDataService - .getAccountData(serverUrl, authToken) - .bind { it.isAtLeastTutorInCourse(courseWithScore.course) } - } - }.map { it.orElse(false) } - - } - .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, false) /** * Returns the id of the channel on registration success or null if any error occurred */ - fun registerInChannel(channelChat: de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat): Deferred { + fun registerInChannel(channelChat: ChannelChat): Deferred { return viewModelScope.async(coroutineContext) { val username = when (val authData = accountService.authenticationData.first()) { is AccountService.AuthenticationData.LoggedIn -> authData.username AccountService.AuthenticationData.NotLoggedIn -> return@async null } - channelService.registerInChannel( + val result = channelService.registerInChannel( courseId = courseId, conversationId = channelChat.id, username = username, serverUrl = serverConfigurationService.serverUrl.first(), authToken = accountService.authToken.first() ) - .bind { if (it) channelChat.id else null } + + result.onSuccess { successful -> + if (successful) { + requestReload() + } + } + + result.bind { if (it) channelChat.id else null } .orNull() } } + fun requestReload() { requestRefresh.tryEmit(Unit) } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt index bf67a6efb..5a1a495ed 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt @@ -4,18 +4,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Create -import androidx.compose.material3.Icon +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -26,15 +25,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog -import de.tum.informatics.www1.artemis.native_app.core.ui.compose.JobAnimatedFloatingActionButton import de.tum.informatics.www1.artemis.native_app.core.ui.compose.NavigationBackButton import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.PotentiallyIllegalTextField +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -76,7 +75,7 @@ internal fun CreateChannelScreen( val isNameIllegal by viewModel.isNameIllegal.collectAsState() val isDescriptionIllegal by viewModel.isDescriptionIllegal.collectAsState() - val isPublic by viewModel.isPublic.collectAsState() + val isPrivate by viewModel.isPrivate.collectAsState() val isAnnouncement by viewModel.isAnnouncement.collectAsState() val canCreate by viewModel.canCreate.collectAsState() @@ -93,22 +92,7 @@ internal fun CreateChannelScreen( } ) }, - floatingActionButton = { - JobAnimatedFloatingActionButton( - modifier = Modifier.testTag(TEST_TAG_CREATE_CHANNEL_BUTTON), - enabled = canCreate, - startJob = viewModel::createChannel, - onJobCompleted = { channel -> - if (channel != null) { - onConversationCreated(channel.id) - } else { - isDisplayingErrorDialog = true - } - } - ) { - Icon(imageVector = Icons.Default.Create, contentDescription = null) - } - } + ) { paddingValues -> Column( modifier = Modifier @@ -120,7 +104,8 @@ internal fun CreateChannelScreen( ) { Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.create_channel_description) + text = stringResource(id = R.string.create_channel_description), + style = MaterialTheme.typography.bodySmall ) PotentiallyIllegalTextField( @@ -155,27 +140,40 @@ internal fun CreateChannelScreen( modifier = Modifier.fillMaxWidth(), title = stringResource(id = R.string.create_channel_channel_accessibility_type), description = stringResource(id = R.string.create_channel_channel_accessibility_type_hint), - choiceOne = stringResource(id = R.string.create_channel_channel_accessibility_type_public), - choiceTwo = stringResource(id = R.string.create_channel_channel_accessibility_type_private), - choice = isPublic, - choiceOneButtonTestTag = TEST_TAG_SET_PUBLIC_BUTTON, - choiceTwoButtonTestTag = TEST_TAG_SET_PRIVATE_BUTTON, - updateChoice = viewModel::updatePublic + isChecked = isPrivate, + onCheckedChange = { viewModel.updatePublic(it) } ) BinarySelection( modifier = Modifier.fillMaxWidth(), title = stringResource(id = R.string.create_channel_channel_announcement_type), description = stringResource(id = R.string.create_channel_channel_announcement_type_hint), - choiceOne = stringResource(id = R.string.create_channel_channel_announcement_type_announcement), - choiceTwo = stringResource(id = R.string.create_channel_channel_announcement_type_unrestricted), - choice = isAnnouncement, - choiceOneButtonTestTag = TEST_TAG_SET_ANNOUNCEMENT_BUTTON, - choiceTwoButtonTestTag = TEST_TAG_SET_UNRESTRICTED_BUTTON, - updateChoice = viewModel::updateAnnouncement + isChecked = isAnnouncement, + onCheckedChange = { viewModel.updateAnnouncement(it) } ) - Box(modifier = Modifier.height(Spacings.FabContentBottomPadding)) + Button( + onClick = { + viewModel.viewModelScope.launch { + val channel = viewModel.createChannel().await() + if (channel != null) { + onConversationCreated(channel.id) + } else { + isDisplayingErrorDialog = true + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + enabled = viewModel.canCreate.collectAsState().value, + + ) { + Text(text = "Create Channel") + } + + Spacer(modifier = Modifier.height(16.dp)) + } } @@ -196,68 +194,33 @@ private fun BinarySelection( modifier: Modifier, title: String, description: String, - choiceOne: String, - choiceTwo: String, - choice: Boolean, - choiceOneButtonTestTag: String, - choiceTwoButtonTestTag: String, - updateChoice: (Boolean) -> Unit + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( + Row( modifier = Modifier.fillMaxWidth(), - text = title, - style = MaterialTheme.typography.titleSmall - ) - - Column { - RadioButtonWithText( - modifier = Modifier.fillMaxWidth(), - buttonTestTag = choiceOneButtonTestTag, - isChecked = choice, - onClick = { updateChoice(true) }, - text = choiceOne + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + style = MaterialTheme.typography.titleMedium ) - - RadioButtonWithText( - modifier = Modifier.fillMaxWidth(), - buttonTestTag = choiceTwoButtonTestTag, - isChecked = !choice, - onClick = { - updateChoice(false) - }, - text = choiceTwo + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange ) } Text( modifier = Modifier.fillMaxWidth(), text = description, + style = MaterialTheme.typography.bodySmall ) } } -@Composable -private fun RadioButtonWithText( - modifier: Modifier, - buttonTestTag: String, - isChecked: Boolean, - onClick: () -> Unit, - text: String -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - modifier = Modifier.testTag(buttonTestTag), - selected = isChecked, - onClick = onClick - ) - - Text(text = text) - } -} diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt index eaed389ef..95fa3e8b9 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt @@ -31,16 +31,16 @@ internal class CreateChannelViewModel( private companion object { private const val KEY_NAME = "name" private const val KEY_DESCRIPTION = "description" - private const val KEY_IS_PUBLIC = "is_public" + private const val KEY_IS_PRIVATE = "is_private" private const val KEY_IS_ANNOUNCEMENT = "announcement" } val name: StateFlow = savedStateHandle.getStateFlow(KEY_NAME, "") val description: StateFlow = savedStateHandle.getStateFlow(KEY_DESCRIPTION, "") - val isPublic: StateFlow = savedStateHandle.getStateFlow(KEY_IS_PUBLIC, true) + val isPrivate: StateFlow = savedStateHandle.getStateFlow(KEY_IS_PRIVATE, false) val isAnnouncement: StateFlow = - savedStateHandle.getStateFlow(KEY_IS_ANNOUNCEMENT, true) + savedStateHandle.getStateFlow(KEY_IS_ANNOUNCEMENT, false) val isNameIllegal: StateFlow = name .mapIsChannelNameIllegal() @@ -65,7 +65,7 @@ internal class CreateChannelViewModel( courseId = courseId, name = name.value, description = description.value, - isPublic = isPublic.value, + isPublic = !isPrivate.value, isAnnouncement = isAnnouncement.value, authToken = authToken, serverUrl = serverUrl @@ -81,8 +81,8 @@ internal class CreateChannelViewModel( savedStateHandle[KEY_DESCRIPTION] = description } - fun updatePublic(isPublic: Boolean) { - savedStateHandle[KEY_IS_PUBLIC] = isPublic + fun updatePublic(isPrivate: Boolean) { + savedStateHandle[KEY_IS_PRIVATE] = isPrivate } fun updateAnnouncement(isAnnouncement: Boolean) { 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 56a3101be..85db1562d 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 @@ -2,31 +2,14 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversat import androidx.annotation.StringRes import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape 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.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.* import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -36,15 +19,12 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -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.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ConversationCollections import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R @@ -54,20 +34,25 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.d import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.GroupChat import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.OneToOneChat -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableTitle +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName internal const val TEST_TAG_CONVERSATION_LIST = "conversation list" - internal const val TEST_TAG_HEADER_EXPAND_ICON = "expand icon" internal const val SECTION_FAVORITES_KEY = "favorites" internal const val SECTION_HIDDEN_KEY = "hidden" internal const val SECTION_CHANNELS_KEY = "channels" internal const val SECTION_GROUPS_KEY = "groups" +internal const val SECTION_EXERCISES_KEY = "exercises" +internal const val SECTION_EXAMS_KEY = "exams" +internal const val SECTION_LECTURES_KEY = "lectures" internal const val SECTION_DIRECT_MESSAGES_KEY = "direct-messages" internal const val KEY_SUFFIX_FAVORITES = "_f" internal const val KEY_SUFFIX_CHANNELS = "_c" +internal const val KEY_SUFFIX_EXAMS = "_exa" +internal const val KEY_SUFFIX_EXERCISES = "_exe" +internal const val KEY_SUFFIX_LECTURES = "_l" internal const val KEY_SUFFIX_GROUPS = "_g" internal const val KEY_SUFFIX_PERSONAL = "_p" internal const val KEY_SUFFIX_HIDDEN = "_h" @@ -87,19 +72,22 @@ internal fun ConversationList( onRequestAddChannel: () -> Unit, trailingContent: LazyListScope.() -> Unit ) { - val listWithHeader: LazyListScope.(ConversationCollections.ConversationCollection<*>, String, String, Int, ConversationSectionHeaderAction, () -> Unit) -> Unit = - { collection, key, suffix, textRes, action, toggleIsExpanded -> + + val listWithHeader: LazyListScope.(ConversationCollections.ConversationCollection<*>, String, String, Int, ConversationSectionHeaderAction, () -> Unit, @Composable () -> Unit) -> Unit = + { collection, key, suffix, textRes, action, toggleIsExpanded, icon -> conversationSectionHeader( key = key, text = textRes, onClickAddAction = action, isExpanded = collection.isExpanded, - toggleIsExpanded = toggleIsExpanded + toggleIsExpanded = toggleIsExpanded, + icon = icon ) conversationList( keySuffix = suffix, conversations = collection, + showPrefix = collection.showPrefix, onNavigateToConversation = onNavigateToConversation, onToggleMarkAsFavourite = onToggleMarkAsFavourite, onToggleHidden = onToggleHidden, @@ -115,7 +103,8 @@ internal fun ConversationList( KEY_SUFFIX_FAVORITES, R.string.conversation_overview_section_favorites, NoAction, - viewModel::toggleFavoritesExpanded + { viewModel.toggleFavoritesExpanded() }, + { Icon(imageVector = Icons.Default.Favorite, contentDescription = null) } ) } @@ -123,10 +112,43 @@ internal fun ConversationList( conversationCollections.channels, SECTION_CHANNELS_KEY, KEY_SUFFIX_CHANNELS, - R.string.conversation_overview_section_channels, + R.string.conversation_overview_section_general_channels, OnClickAction(onRequestAddChannel), - viewModel::toggleChannelsExpanded - ) + viewModel::toggleGeneralsExpanded + ) { Icon(imageVector = Icons.Default.ChatBubble, contentDescription = null) } + + if (conversationCollections.exerciseChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.exerciseChannels, + SECTION_EXERCISES_KEY, + KEY_SUFFIX_EXERCISES, + R.string.conversation_overview_section_exercise_channels, + NoAction, + viewModel::toggleExercisesExpanded + ) { Icon(imageVector = Icons.Default.List, contentDescription = null) } + } + + if (conversationCollections.lectureChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.lectureChannels, + SECTION_LECTURES_KEY, + KEY_SUFFIX_LECTURES, + R.string.conversation_overview_section_lecture_channels, + NoAction, + viewModel::toggleLecturesExpanded + ) { Icon(imageVector = Icons.Default.InsertDriveFile, contentDescription = null) } + } + + if (conversationCollections.examChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.examChannels, + SECTION_EXAMS_KEY, + KEY_SUFFIX_EXAMS, + R.string.conversation_overview_section_exam_channels, + NoAction, + viewModel::toggleExamsExpanded + ) { Icon(imageVector = Icons.Default.School, contentDescription = null) } + } listWithHeader( conversationCollections.groupChats, @@ -135,7 +157,7 @@ internal fun ConversationList( R.string.conversation_overview_section_groups, OnClickAction(onRequestCreatePersonalConversation), viewModel::toggleGroupChatsExpanded - ) + ) { Icon(imageVector = Icons.Default.Forum, contentDescription = null) } listWithHeader( conversationCollections.directChats, @@ -144,7 +166,7 @@ internal fun ConversationList( R.string.conversation_overview_section_direct_messages, OnClickAction(onRequestCreatePersonalConversation), viewModel::togglePersonalConversationsExpanded - ) + ) { Icon(imageVector = Icons.Default.Message, contentDescription = null) } if (conversationCollections.hidden.conversations.isNotEmpty()) { listWithHeader( @@ -154,7 +176,7 @@ internal fun ConversationList( R.string.conversation_overview_section_hidden, NoAction, viewModel::toggleHiddenExpanded - ) + ) { Icon(imageVector = Icons.Default.NotInterested, contentDescription = null) } } trailingContent() @@ -166,7 +188,8 @@ private fun LazyListScope.conversationSectionHeader( @StringRes text: Int, isExpanded: Boolean, onClickAddAction: ConversationSectionHeaderAction, - toggleIsExpanded: () -> Unit + toggleIsExpanded: () -> Unit, + icon: @Composable () -> Unit ) { item(key = key) { Column( @@ -177,41 +200,37 @@ private fun LazyListScope.conversationSectionHeader( Divider() Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .clickable { toggleIsExpanded() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + icon() + Text( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + text = stringResource(id = text), + style = MaterialTheme.typography.titleSmall + ) + } + IconButton( modifier = Modifier.testTag(TEST_TAG_HEADER_EXPAND_ICON), - onClick = { - toggleIsExpanded() - } + onClick = { toggleIsExpanded() } ) { Icon( imageVector = if (isExpanded) Icons.Default.ArrowDropDown else Icons.Default.ArrowRight, - contentDescription = null + contentDescription = null, + modifier = Modifier.size(32.dp) ) } - - Text( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - text = stringResource(id = text), - style = MaterialTheme.typography.titleSmall - ) - - if (onClickAddAction is OnClickAction) { - IconButton( - onClick = onClickAddAction.onClick - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null - ) - } - } else { - Box(modifier = Modifier.height(40.dp)) - } } Divider() @@ -222,6 +241,7 @@ private fun LazyListScope.conversationSectionHeader( private fun LazyListScope.conversationList( keySuffix: String, conversations: ConversationCollections.ConversationCollection, + showPrefix: Boolean, onNavigateToConversation: (conversationId: Long) -> Unit, onToggleMarkAsFavourite: (conversationId: Long, favorite: Boolean) -> Unit, onToggleHidden: (conversationId: Long, hidden: Boolean) -> Unit, @@ -230,12 +250,14 @@ private fun LazyListScope.conversationList( if (!conversations.isExpanded) return items( conversations.conversations, - key = { tagForConversation(it.id, keySuffix) }) { conversation -> + key = { tagForConversation(it.id, keySuffix) } + ) { conversation -> ConversationListItem( modifier = Modifier .fillMaxWidth() .testTag(tagForConversation(conversation.id, keySuffix)), conversation = conversation, + showPrefix = showPrefix, onNavigateToConversation = { onNavigateToConversation(conversation.id) }, onToggleMarkAsFavourite = { onToggleMarkAsFavourite( @@ -245,122 +267,124 @@ private fun LazyListScope.conversationList( }, onToggleHidden = { onToggleHidden(conversation.id, !conversation.isHidden) }, onToggleMuted = { onToggleMuted(conversation.id, !conversation.isMuted) }, - content = { contentModifier -> - val unreadMessagesCount = conversation.unreadMessagesCount ?: 0 - - val headlineColor = - LocalContentColor.current.copy(alpha = if (conversation.isMuted) 0.6f else 1f) - - when (conversation) { - is ChannelChat -> { - val channelName = if (conversation.isArchived) { - stringResource( - id = R.string.conversation_overview_archived_channel_name, - conversation.name - ) - } else conversation.name - - ListItem( - modifier = contentModifier, - leadingContent = { - PrimaryChannelIcon(channelChat = conversation) - }, - headlineContent = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text(text = channelName, maxLines = 1, color = headlineColor) - - ExtraChannelIcons(channelChat = conversation) - } - }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - - is GroupChat -> { - ListItem( - modifier = contentModifier, - headlineContent = { - Text( - conversation.humanReadableTitle, - color = headlineColor - ) - }, - leadingContent = { - Icon(imageVector = Icons.Default.Groups2, contentDescription = null) - }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - - is OneToOneChat -> { - ListItem( - modifier = contentModifier, - headlineContent = { - Text( - conversation.humanReadableTitle, - color = headlineColor - ) - }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - } - } ) } } -@Composable -private fun UnreadMessages(modifier: Modifier = Modifier, unreadMessagesCount: Long) { - if (unreadMessagesCount > 0) { - Box( - modifier = modifier - .size(24.dp) - .aspectRatio(1f) - .background( - MaterialTheme.colorScheme.primaryContainer, - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = unreadMessagesCount.toString(), - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } -} - @Composable private fun ConversationListItem( modifier: Modifier = Modifier, conversation: Conversation, + showPrefix: Boolean, onNavigateToConversation: () -> Unit, onToggleMarkAsFavourite: () -> Unit, onToggleHidden: () -> Unit, onToggleMuted: () -> Unit, - content: @Composable (Modifier) -> Unit ) { var isContextDialogShown by remember { mutableStateOf(false) } val onDismissRequest = { isContextDialogShown = false } + val unreadMessagesCount = conversation.unreadMessagesCount ?: 0 + + val headlineColor = + LocalContentColor.current.copy(alpha = if (conversation.isMuted) 0.6f else 1f) + + val displayName = when (conversation) { + is ChannelChat -> { + val channelName = if (conversation.isArchived) { + stringResource( + id = R.string.conversation_overview_archived_channel_name, + conversation.name + ) + } else conversation.name + + if (showPrefix) { + channelName + } else { + channelName.removeSectionPrefix() + } + } + is GroupChat, is OneToOneChat -> { + val humanReadableTitle = conversation.humanReadableName + if (showPrefix) { + humanReadableTitle + } else { + humanReadableTitle.removeSectionPrefix() + } + } + else -> conversation.humanReadableName + } + Box(modifier = modifier) { - content( - Modifier.combinedClickable( - onClick = onNavigateToConversation, - onLongClick = { isContextDialogShown = true } - ) - ) + when (conversation) { + is ChannelChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + leadingContent = { + PrimaryChannelIcon(channelChat = conversation) + }, + headlineContent = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text(text = displayName, maxLines = 1, color = headlineColor) + + ExtraChannelIcons(channelChat = conversation) + } + }, + trailingContent = { + UnreadMessages( + modifier = Modifier.padding(end = 24.dp), + unreadMessagesCount = unreadMessagesCount + ) + } + ) + } + + is GroupChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + headlineContent = { + Text( + displayName, + color = headlineColor + ) + }, + leadingContent = { + Icon(imageVector = Icons.Default.Groups2, contentDescription = null) + }, + trailingContent = { + UnreadMessages(unreadMessagesCount = unreadMessagesCount) + } + ) + } + + is OneToOneChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + headlineContent = { + Text( + displayName, + color = headlineColor + ) + }, + trailingContent = { + UnreadMessages(unreadMessagesCount = unreadMessagesCount) + } + ) + } + } + + IconButton( + onClick = { isContextDialogShown = true }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null) + } DropdownMenu( expanded = isContextDialogShown, - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, + modifier = Modifier.align(Alignment.TopEnd), + offset = DpOffset(x = (-10).dp, y = 0.dp), ) { DropdownMenuItem( leadingIcon = { @@ -428,8 +452,41 @@ private fun ConversationListItem( } } +@Composable +private fun UnreadMessages(modifier: Modifier = Modifier, unreadMessagesCount: Long) { + if (unreadMessagesCount > 0) { + Box( + modifier = modifier + .size(24.dp) + .aspectRatio(1f) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = unreadMessagesCount.toString(), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + private sealed interface ConversationSectionHeaderAction private data class OnClickAction(val onClick: () -> Unit) : ConversationSectionHeaderAction private object NoAction : ConversationSectionHeaderAction + +private fun String.removeSectionPrefix(): String { + val prefixes = listOf("exercise-", "lecture-", "exam-") + var result = this + for (prefix in prefixes) { + if (result.startsWith(prefix, ignoreCase = true)) { + result = result.removePrefix(prefix) + break + } + } + return result.trim() +} diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt index f6399278c..1e1babddc 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt @@ -9,14 +9,22 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddComment +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material3.Button import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -32,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset 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.common.BasicDataStateUi @@ -50,14 +59,18 @@ fun ConversationOverviewBody( courseId: Long, onNavigateToConversation: (conversationId: Long) -> Unit, onRequestCreatePersonalConversation: () -> Unit, - onRequestAddChannel: () -> Unit + onRequestAddChannel: () -> Unit, + onRequestBrowseChannel: () -> Unit, + canCreateChannel: Boolean ) { ConversationOverviewBody( modifier = modifier, viewModel = koinViewModel { parametersOf(courseId) }, onNavigateToConversation = onNavigateToConversation, onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, - onRequestAddChannel = onRequestAddChannel + onRequestAddChannel = onRequestAddChannel, + onRequestBrowseChannel = onRequestBrowseChannel, + canCreateChannel = canCreateChannel ) } @@ -67,7 +80,9 @@ fun ConversationOverviewBody( viewModel: ConversationOverviewViewModel, onNavigateToConversation: (conversationId: Long) -> Unit, onRequestCreatePersonalConversation: () -> Unit, - onRequestAddChannel: () -> Unit + onRequestAddChannel: () -> Unit, + onRequestBrowseChannel: () -> Unit, + canCreateChannel: Boolean ) { var showCodeOfConduct by rememberSaveable { mutableStateOf(false) } val conversationCollectionsDataState: DataState by viewModel.conversations.collectAsState() @@ -80,76 +95,85 @@ fun ConversationOverviewBody( viewModel.requestReload() } - BasicDataStateUi( - modifier = modifier, - dataState = conversationCollectionsDataState, - loadingText = stringResource(id = R.string.conversation_overview_loading), - failureText = stringResource(id = R.string.conversation_overview_loading_failed), - retryButtonText = stringResource(id = R.string.conversation_overview_loading_try_again), - onClickRetry = viewModel::requestReload - ) { conversationCollection -> - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - AnimatedVisibility(modifier = Modifier.fillMaxWidth(), visible = !isConnected) { - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.align(Alignment.Center), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Default.WifiOff, contentDescription = null) - - Text( - text = stringResource(id = R.string.conversation_overview_not_connected_banner), - style = MaterialTheme.typography.bodyMedium - ) + Box(modifier = Modifier.fillMaxSize()) { + BasicDataStateUi( + modifier = modifier, + dataState = conversationCollectionsDataState, + loadingText = stringResource(id = R.string.conversation_overview_loading), + failureText = stringResource(id = R.string.conversation_overview_loading_failed), + retryButtonText = stringResource(id = R.string.conversation_overview_loading_try_again), + onClickRetry = viewModel::requestReload + ) { conversationCollection -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AnimatedVisibility(modifier = Modifier.fillMaxWidth(), visible = !isConnected) { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.WifiOff, contentDescription = null) + + Text( + text = stringResource(id = R.string.conversation_overview_not_connected_banner), + style = MaterialTheme.typography.bodyMedium + ) + } } } - } - ConversationSearch( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - query = query, - updateQuery = viewModel::onUpdateQuery - ) + ConversationSearch( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + query = query, + updateQuery = viewModel::onUpdateQuery + ) - ConversationList( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel, - conversationCollections = conversationCollection, - onNavigateToConversation = { conversationId -> - viewModel.setConversationMessagesRead(conversationId) - onNavigateToConversation(conversationId) - }, - onToggleMarkAsFavourite = viewModel::markConversationAsFavorite, - onToggleHidden = viewModel::markConversationAsHidden, - onToggleMuted = viewModel::markConversationAsMuted, - onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, - onRequestAddChannel = onRequestAddChannel, - trailingContent = { - item { Divider() } - - item(key = KEY_BUTTON_SHOW_COC) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - OutlinedButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showCodeOfConduct = true } + ConversationList( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + conversationCollections = conversationCollection, + onNavigateToConversation = { conversationId -> + viewModel.setConversationMessagesRead(conversationId) + onNavigateToConversation(conversationId) + }, + onToggleMarkAsFavourite = viewModel::markConversationAsFavorite, + onToggleHidden = viewModel::markConversationAsHidden, + onToggleMuted = viewModel::markConversationAsMuted, + onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, + onRequestAddChannel = onRequestAddChannel, + trailingContent = { + item { Divider() } + + item(key = KEY_BUTTON_SHOW_COC) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) ) { - Text(text = stringResource(id = R.string.conversation_overview_button_show_code_of_conduct)) + OutlinedButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showCodeOfConduct = true } + ) { + Text(text = stringResource(id = R.string.conversation_overview_button_show_code_of_conduct)) + } } } } - } - ) + ) + } } + + ConversationFabMenu( + onCreateChat = onRequestCreatePersonalConversation, + onBrowseChannels = onRequestBrowseChannel, + onCreateChannel = onRequestAddChannel, + canCreateChannel = canCreateChannel + ) } if (showCodeOfConduct) { @@ -168,6 +192,73 @@ fun ConversationOverviewBody( } } +@Composable +fun ConversationFabMenu( + onCreateChat: () -> Unit, + onBrowseChannels: () -> Unit, + onCreateChannel: () -> Unit, + canCreateChannel: Boolean +) { + var expanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.BottomEnd + ) { + Box { + FloatingActionButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(56.dp) + ) { + Icon(imageVector = Icons.Default.AddComment, contentDescription = "Add conversation") + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = (12).dp) + ) { + DropdownMenuItem( + onClick = { + expanded = false + onCreateChat() + }, + text = { Text(stringResource(id = R.string.create_chat_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.ChatBubble, contentDescription = null) + } + ) + DropdownMenuItem( + onClick = { + expanded = false + onBrowseChannels() + }, + text = { Text(stringResource(id = R.string.browse_channels_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.Tag, contentDescription = null) + } + ) + if (canCreateChannel) { + DropdownMenuItem( + onClick = { + expanded = false + onCreateChannel() + }, + text = { Text(stringResource(id = R.string.create_channel_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.AddComment, contentDescription = null) + } + ) + } + } + } + } +} + + + @Composable private fun ConversationSearch( modifier: Modifier, @@ -181,14 +272,33 @@ private fun ConversationSearch( shape = RoundedCornerShape(10) ) ) { - BasicHintTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), - hint = stringResource(id = R.string.conversation_overview_search_hint), - value = query, - onValueChange = updateQuery, - maxLines = 1 - ) + Row(modifier = Modifier.fillMaxWidth()) { + BasicHintTextField( + modifier = Modifier + .weight(1f) + .padding(8.dp), + hint = stringResource(id = R.string.conversation_overview_search_hint), + value = query, + onValueChange = updateQuery, + maxLines = 1 + ) + + if (query.isNotEmpty()) { + IconButton( + onClick = { updateQuery("") }, + modifier = Modifier + .align(Alignment.CenterVertically) + .size(24.dp) + .padding(end = 5.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } + } } } + diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt index 035155270..83bf39bb8 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt @@ -183,25 +183,51 @@ class ConversationOverviewViewModel( private val conversationsAsCollections: StateFlow> = combine( updatedConversations, - currentPreferences - ) { conversationsDataState, preferences -> + currentPreferences, + query + ) { conversationsDataState, preferences, query -> conversationsDataState.bind { conversations -> + val isFiltering = query.isNotBlank() + de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ConversationCollections( channels = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.channelsExpanded), + .filter { !it.filterPredicate("exercise") && !it.filterPredicate("lecture") && !it.filterPredicate("exam") } + .asCollection(isFiltering || preferences.generalsExpanded), + groupChats = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.groupChatsExpanded), + .asCollection(isFiltering || preferences.groupChatsExpanded), + directChats = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.personalConversationsExpanded), + .asCollection(isFiltering || preferences.personalConversationsExpanded), + favorites = conversations.filter { it.isFavorite } .asCollection(preferences.favouritesExpanded), + hidden = conversations.filter { it.isHidden } - .asCollection(preferences.hiddenExpanded) + .asCollection(preferences.hiddenExpanded), + + exerciseChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("exercise") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.exercisesExpanded, showPrefix = false), + + lectureChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("lecture") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.lecturesExpanded, showPrefix = false), + + examChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("exam") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.examsExpanded, showPrefix = false) ) } } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) + + + /** * Holds the latest conversations we could successfully load. */ @@ -359,8 +385,20 @@ class ConversationOverviewViewModel( expandOrCollapseSection { copy(favouritesExpanded = !favouritesExpanded) } } - fun toggleChannelsExpanded() { - expandOrCollapseSection { copy(channelsExpanded = !channelsExpanded) } + fun toggleGeneralsExpanded() { + expandOrCollapseSection { copy(generalsExpanded = !generalsExpanded) } + } + + fun toggleExamsExpanded() { + expandOrCollapseSection { copy(examsExpanded = !examsExpanded) } + } + + fun toggleExercisesExpanded() { + expandOrCollapseSection { copy(exercisesExpanded = !exercisesExpanded) } + } + + fun toggleLecturesExpanded() { + expandOrCollapseSection { copy(lecturesExpanded = !lecturesExpanded) } } fun toggleGroupChatsExpanded() { @@ -391,6 +429,7 @@ class ConversationOverviewViewModel( } private fun List.asCollection( - isExpanded: Boolean - ) = ConversationCollection(this, isExpanded) + isExpanded: Boolean, + showPrefix: Boolean = true + ) = ConversationCollection(this, isExpanded, showPrefix) } diff --git a/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml b/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml index c0a744ce5..333e738dc 100644 --- a/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml @@ -6,6 +6,8 @@ Loading channels… Something went wrong while loading the channels. Try again + Joined + Join There are no channels in this course you have not already joined. diff --git a/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml b/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml index c6b57c801..e11c842a7 100644 --- a/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml @@ -12,7 +12,10 @@ Unmute Favorites - Channels + General + Exercises + Lectures + Exams Group Chats Direct Messages Hidden diff --git a/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml b/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml index e054fc9ad..cd6e1d2c2 100644 --- a/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml @@ -1,6 +1,7 @@ Create channel + Create chat A channel is a way to group people together around a project, a topic, or just for fun. You can create as many channels as you want. You will become the first channel moderator. You will not be able to leave the channel. Name @@ -10,12 +11,12 @@ Description (optional) What\'s this channel about? - Private Channel / Public Channel + Private Channel? Every user except instructors will need an invitation to join a private channel. Everybody can join a public channel. Public Private - Announcement Channel + Announcement Channel? Only instructors and channel moderators can create new messages in an announcement channel. Students can only read the messages and answer to them. Announcement Channel Unrestricted Channel diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt index 21fbc2cd2..973363b8f 100644 --- a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import de.tum.informatics.www1.artemis.native_app.feature.metis.codeofconduct.ui.CodeOfConductFacadeUi +import org.koin.compose.koinInject /** * Displays the conversation ui. If the code of conduct has not yet been accepted, displays a code @@ -22,7 +23,12 @@ fun ConversationFacadeUi( SinglePageConversationBody( modifier = Modifier.fillMaxSize(), courseId = courseId, - initialConfiguration = initialConfiguration + initialConfiguration = initialConfiguration, + accountService = koinInject(), + accountDataService = koinInject(), + courseService = koinInject(), + networkStatusProvider = koinInject(), + serverConfigurationService = koinInject() ) } ) diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt index 4feabe428..cfe7a811b 100644 --- a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt @@ -4,12 +4,23 @@ import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest +import de.tum.informatics.www1.artemis.native_app.core.data.retryOnInternet +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.CourseService +import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService +import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService +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.model.account.isAtLeastTutorInCourse import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ConversationScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.browse_channels.BrowseChannelsScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.create_channel.CreateChannelScreen @@ -19,13 +30,20 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversati import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.members.ConversationMembersScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.overview.ConversationSettingsScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.StandalonePostId +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @Composable internal fun SinglePageConversationBody( modifier: Modifier, courseId: Long, - initialConfiguration: ConversationConfiguration = NothingOpened + initialConfiguration: ConversationConfiguration = NothingOpened, + accountService: AccountService, + serverConfigurationService: ServerConfigurationService, + courseService: CourseService, + accountDataService: AccountDataService, + networkStatusProvider: NetworkStatusProvider ) { var configuration: ConversationConfiguration by rememberSaveable(initialConfiguration) { mutableStateOf(initialConfiguration) @@ -38,10 +56,36 @@ internal fun SinglePageConversationBody( } } + var canCreateChannel by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(courseId) { + coroutineScope.launch { + val flow = flatMapLatest( + serverConfigurationService.serverUrl, + accountService.authToken + ) { serverUrl, authToken -> + retryOnInternet(networkStatusProvider.currentNetworkStatus) { + courseService.getCourse(courseId, serverUrl, authToken) + .then { courseWithScore -> + accountDataService + .getAccountData(serverUrl, authToken) + .bind { it.isAtLeastTutorInCourse(courseWithScore.course) } + } + }.map { it.orElse(false) } + } + + flow.collect { value -> + canCreateChannel = value + } + } + } + BackHandler(configuration != NothingOpened) { when (val config = configuration) { is ConversationSettings -> configuration = config.prevConfiguration is AddChannelConfiguration -> configuration = config.prevConfiguration + is BrowseChannelConfiguration -> configuration = config.prevConfiguration is CreatePersonalConversation -> configuration = config.prevConfiguration is OpenedConversation -> configuration = if (config.openedThread != null) config.copy(openedThread = null) else NothingOpened @@ -51,7 +95,7 @@ internal fun SinglePageConversationBody( } } - val ConversationOverview: @Composable (Modifier) -> Unit = { m -> + val conversationOverview: @Composable (Modifier) -> Unit = { m -> ConversationOverviewBody( modifier = m.padding(top = 16.dp), courseId = courseId, @@ -60,14 +104,20 @@ internal fun SinglePageConversationBody( configuration = CreatePersonalConversation(configuration) }, onRequestAddChannel = { - configuration = AddChannelConfiguration(false, configuration) - } + if (canCreateChannel) { + configuration = AddChannelConfiguration(configuration) + } + }, + onRequestBrowseChannel = { + configuration = BrowseChannelConfiguration(configuration) + }, + canCreateChannel = canCreateChannel ) } when (val config = configuration) { NothingOpened -> { - ConversationOverview(modifier) + conversationOverview(modifier) } is OpenedConversation -> { @@ -94,28 +144,26 @@ internal fun SinglePageConversationBody( prevConfiguration = config ) }, - conversationsOverview = { mod -> ConversationOverview(mod) } + conversationsOverview = { mod -> conversationOverview(mod) } + ) + } + + is BrowseChannelConfiguration -> { + BrowseChannelsScreen( + modifier = modifier, + courseId = courseId, + onNavigateToConversation = openConversation, + //onNavigateToCreateChannel = {}, + onNavigateBack = { configuration = config.prevConfiguration } ) } is AddChannelConfiguration -> { - if (config.isCreatingChannel) { + if (canCreateChannel) { CreateChannelScreen( modifier = modifier, courseId = courseId, onConversationCreated = openConversation, - onNavigateBack = { - configuration = AddChannelConfiguration(false, config.prevConfiguration) - } - ) - } else { - BrowseChannelsScreen( - modifier = modifier, - courseId = courseId, - onNavigateToConversation = openConversation, - onNavigateToCreateChannel = { - configuration = AddChannelConfiguration(true, config.prevConfiguration) - }, onNavigateBack = { configuration = config.prevConfiguration } ) } @@ -189,6 +237,7 @@ internal fun SinglePageConversationBody( } } + @Parcelize sealed interface ConversationConfiguration : Parcelable @@ -211,7 +260,12 @@ data class NavigateToUserConversation(val username: String) : ConversationConfig @Parcelize private data class AddChannelConfiguration( - val isCreatingChannel: Boolean, + val prevConfiguration: ConversationConfiguration +) : + ConversationConfiguration + +@Parcelize +private data class BrowseChannelConfiguration( val prevConfiguration: ConversationConfiguration ) : ConversationConfiguration From ad81f6c662deb11ec25fb678fe376ca36acd9a05 Mon Sep 17 00:00:00 2001 From: Asli Aykan Date: Sat, 21 Sep 2024 15:31:59 +0300 Subject: [PATCH 2/4] test updated --- .../manageconversations/overview/BrowseChannelsE2eTest.kt | 1 - .../overview/ConversationOverviewE2eTest.kt | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt index d7b5fd502..a40d62c6d 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt @@ -114,7 +114,6 @@ class BrowseChannelsE2eTest : ConversationBaseTest() { modifier = Modifier.fillMaxSize(), viewModel = viewModel, onNavigateToConversation = onNavigateToConversation, - onNavigateToCreateChannel = { }, onNavigateBack = {} ) } diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt index 2d60a2764..ceab5a47b 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt @@ -395,12 +395,14 @@ class ConversationOverviewE2eTest : ConversationBaseTest() { viewModel = viewModel, onNavigateToConversation = {}, onRequestCreatePersonalConversation = { }, - onRequestAddChannel = {} + onRequestAddChannel = {}, + onRequestBrowseChannel = {}, + canCreateChannel = false ) } composeTestRule.waitUntilAtLeastOneExists( - hasText(context.getString(R.string.conversation_overview_section_channels)), + hasText(context.getString(R.string.conversation_overview_section_general_channels)), DefaultTimeoutMillis ) From bb694dcfec6696cdafe92c9db5a905badaf11052 Mon Sep 17 00:00:00 2001 From: Asli Aykan Date: Fri, 27 Sep 2024 13:56:28 +0300 Subject: [PATCH 3/4] minor fix --- .../conversation/overview/ConversationList.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 85db1562d..f0e97b241 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 @@ -150,23 +150,27 @@ internal fun ConversationList( ) { Icon(imageVector = Icons.Default.School, contentDescription = null) } } - listWithHeader( - conversationCollections.groupChats, - SECTION_GROUPS_KEY, - KEY_SUFFIX_GROUPS, - R.string.conversation_overview_section_groups, - OnClickAction(onRequestCreatePersonalConversation), - viewModel::toggleGroupChatsExpanded - ) { Icon(imageVector = Icons.Default.Forum, contentDescription = null) } + if (conversationCollections.groupChats.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.groupChats, + SECTION_GROUPS_KEY, + KEY_SUFFIX_GROUPS, + R.string.conversation_overview_section_groups, + OnClickAction(onRequestCreatePersonalConversation), + viewModel::toggleGroupChatsExpanded + ) { Icon(imageVector = Icons.Default.Forum, contentDescription = null) } + } - listWithHeader( - conversationCollections.directChats, - SECTION_DIRECT_MESSAGES_KEY, - KEY_SUFFIX_PERSONAL, - R.string.conversation_overview_section_direct_messages, - OnClickAction(onRequestCreatePersonalConversation), - viewModel::togglePersonalConversationsExpanded - ) { Icon(imageVector = Icons.Default.Message, contentDescription = null) } + if (conversationCollections.directChats.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.directChats, + SECTION_DIRECT_MESSAGES_KEY, + KEY_SUFFIX_PERSONAL, + R.string.conversation_overview_section_direct_messages, + OnClickAction(onRequestCreatePersonalConversation), + viewModel::togglePersonalConversationsExpanded + ) { Icon(imageVector = Icons.Default.Message, contentDescription = null) } + } if (conversationCollections.hidden.conversations.isNotEmpty()) { listWithHeader( From 976bc7c85a3bb0daa897dbe8589922a451a092df Mon Sep 17 00:00:00 2001 From: Asli Aykan Date: Tue, 1 Oct 2024 02:50:08 +0300 Subject: [PATCH 4/4] refactor --- .../core/ui/common/BasicHintTextField.kt | 16 ++++++++++++++-- .../exerciseview/home/ExerciseScreenBody.kt | 3 --- .../content/dto/conversation/ChannelChat.kt | 4 +++- .../shared/content/dto/conversation/GroupChat.kt | 7 +++++-- .../content/dto/conversation/OneToOneChat.kt | 4 +++- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt index 6a8ada4e9..f189deaca 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt @@ -1,6 +1,8 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.common import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable @@ -11,8 +13,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText @@ -27,7 +31,7 @@ fun BasicHintTextField( hintStyle: TextStyle = LocalTextStyle.current ) { var hasFocus by remember { mutableStateOf(false) } - + val keyboardController = LocalSoftwareKeyboardController.current val isValueDisplayed = value.isNotBlank() || (hasFocus && hideHintOnFocus) val currentValue = if (isValueDisplayed) value else hint @@ -48,6 +52,14 @@ fun BasicHintTextField( AnnotatedString(text = currentValue, spanStyle = hintStyle.toSpanStyle()), OffsetMapping.Identity ) - } + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + } + ) ) } \ No newline at end of file diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt index 268a0c190..1bd9ba8fd 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.HelpCenter @@ -74,7 +72,6 @@ internal fun ExerciseScreenBody( exerciseOverviewTab( Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) .padding(horizontal = 8.dp) ) diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt index da9397a0e..bc3736712 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt @@ -35,5 +35,7 @@ data class ChannelChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = query in name + override fun filterPredicate(query: String): Boolean { + return name.contains(query, ignoreCase = true) + } } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt index 454b128fd..a1337e044 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt @@ -29,6 +29,9 @@ data class GroupChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = - if (name != null) query in name else query in humanReadableName + override fun filterPredicate(query: String): Boolean { + return (name!=null && name.contains(query, ignoreCase = true)) || + (humanReadableName.contains(query, ignoreCase = true)) + + } } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt index 12a05c1b8..a756463eb 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt @@ -27,5 +27,7 @@ data class OneToOneChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = query in humanReadableTitle + override fun filterPredicate(query: String): Boolean { + return humanReadableTitle.contains(query, ignoreCase = true) + } }