From 371a4380e8dba86c4c36f0196f150bdccf5917cb Mon Sep 17 00:00:00 2001 From: Martin Felber <45291671+FelberMartin@users.noreply.github.com> Date: Sat, 30 Nov 2024 17:21:11 +0100 Subject: [PATCH] `Release`: New release 1.0.7 (#173) Signed-off-by: dependabot[bot] Co-authored-by: Julian Waluschyk <37155504+julian-wls@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tim Ortel <100865202+TimOrtel@users.noreply.github.com> Co-authored-by: Paul Rangger <48455539+PaRangger@users.noreply.github.com> Co-authored-by: Paul Rangger --- .github/ISSUE_TEMPLATE/bug_report.md | 27 + .github/ISSUE_TEMPLATE/feature_request.md | 17 + .github/pull_request_template.md | 13 + .github/workflows/e2e-test.yml | 5 + .github/workflows/unit-test.yml | 5 + app/build.gradle.kts | 2 +- .../native_app/android/db/AppDatabase.kt | 2 +- .../native_app/android/ui/MainActivity.kt | 28 +- .../kotlin/AndroidFeatureConventionPlugin.kt | 2 + buildSrc/build.gradle.kts | 2 +- .../markdown/ArtemisMarkdownTransformer.kt | 50 ++ .../PostArtemisMarkdownTransformer.kt | 22 + ...hNotificationArtemisMarkdownTransformer.kt | 4 + .../native_app/core/test/BaseComposeTest.kt | 13 +- .../core/test/TestWebsocketProvider.kt | 4 +- .../native_app/core/ui/CourseImageProvider.kt | 61 -- .../core/ui/common/course/CourseListUi.kt | 16 +- .../core/ui/exercise/ExerciseActionButtons.kt | 94 ++-- .../core/ui/exercise/ExerciseListItem.kt | 10 +- .../core/ui/markdown/LocalMarkwon.kt | 3 +- .../core/ui/markdown/MarkdownText.kt | 50 +- .../ui/material/colors/DifficultyColors.kt | 13 + ...ipationNotPossibleInfoMessageCardColors.kt | 14 + .../ui/material/{ => colors}/text_colors.kt | 2 +- .../core/ui/material/difficulty_colors.kt | 11 - .../ui/navigation/KSerializableNavType.kt | 30 + .../ui/remote_images/BaseImageProvider.kt | 17 + .../ui/remote_images/CourseImageProvider.kt | 36 ++ .../ui/remote_images/DefaultImageProvider.kt | 47 ++ .../src/main/res/values/exercise_strings.xml | 4 +- .../core/websocket/WebsocketProviderStub.kt | 4 +- .../core/websocket/WebsocketProvider.kt | 4 +- .../websocket/impl/WebsocketProviderImpl.kt | 14 +- .../core/websocket/impl/WebsocketTopic.kt | 26 + .../courseregistration/RegisterForCourseUi.kt | 16 +- .../CourseRegistrationE2eTest.kt | 23 +- .../feature/courseview/ui/WeeklyItemsUi.kt | 12 +- .../ui/course_overview/CourseUiScreen.kt | 40 +- .../feature/courseview/BaseCourseTest.kt | 28 +- .../courseview/ExerciseListOverviewE2eTest.kt | 10 +- feature/dashboard/build.gradle.kts | 1 - .../feature/dashboard/DashboardScreenshots.kt | 6 +- .../feature/dashboard/CoursesOverview.kt | 519 ------------------ .../feature/dashboard/dashboard_module.kt | 4 + .../dashboard/service/SurveyHintService.kt | 10 + .../service/impl/BetaHintServiceImpl.kt | 9 +- .../service/impl/SurveyHintServiceImpl.kt | 41 ++ .../feature/dashboard/ui/BetaHintDialog.kt | 79 +++ .../feature/dashboard/ui/CourseList.kt | 256 +++++++++ .../{ => ui}/CourseOverviewViewModel.kt | 3 +- .../feature/dashboard/ui/CoursesOverview.kt | 255 +++++++++ .../feature/dashboard/ui/SurveyHint.kt | 186 +++++++ .../main/res/values/survey_hint_strings.xml | 9 + .../feature/dashboard/DashboardE2eTest.kt | 11 +- .../feature/exerciseview/ExerciseViewUi.kt | 123 ++--- .../exerciseview/home/ExerciseScreen.kt | 160 ++---- .../exerciseview/home/ExerciseScreenBody.kt | 3 + .../home/ExerciseScreenTopAppBar.kt | 407 ++------------ .../home/overview/ExerciseOverviewTab.kt | 258 ++++++++- .../home/overview/ParticipationStatus.kt | 2 + .../main/res/values/exercise_view_strings.xml | 8 + .../text_exercise/BaseExerciseTest.kt | 39 +- .../text_exercise/ExerciseOverviewE2eTest.kt | 12 +- .../TextExerciseParticipationE2eTest.kt | 76 ++- .../feature/lectureview/AttachmentsTab.kt | 11 +- .../feature/lectureview/LectureScreenUi.kt | 24 +- .../feature/lectureview/OverviewTab.kt | 10 +- .../feature/lecture_view/LectureE2eTest.kt | 37 +- .../native_app/feature/login/AccountUi.kt | 136 +++-- .../feature/login/NotificationSettingsUi.kt | 6 +- .../InstanceSelectionScreen.kt | 8 +- .../native_app/feature/login/login/LoginUi.kt | 1 + .../feature/login/register/RegisterUi.kt | 3 +- .../feature/login/RegisterEndToEndTest.kt | 2 + feature/metis-test/build.gradle.kts | 1 + .../feature/metistest/MetisServiceStub.kt | 5 +- .../feature/metistest/paging_source_util.kt | 15 + .../codeofconduct/ui/AcceptCodeOfConductUi.kt | 9 +- feature/metis/conversation/build.gradle.kts | 8 +- .../service/network/MetisService.kt | 3 +- .../service/network/impl/MetisServiceImpl.kt | 19 +- .../storage/impl/MetisStorageServiceImpl.kt | 19 +- .../ui/ConversationChatListScreen.kt | 16 +- .../ui/ConversationThreadScreen.kt | 9 +- .../conversation/ui/ConversationViewModel.kt | 29 +- .../ui/ConversationWebSocketUpdateUseCase.kt | 58 +- .../conversation/ui/chatlist/MetisChatList.kt | 61 +- .../ui/chatlist/MetisPostListHandler.kt | 13 +- .../metis/conversation/ui/post/PostActions.kt | 37 +- .../ui/post/PostContextBottomSheet.kt | 8 +- .../conversation/ui/thread/MetisThreadUi.kt | 188 ++++--- .../metis/conversation/BaseChatUItest.kt | 135 +++++ .../ConversationAnswerMessagesUITest.kt | 119 +--- .../ConversationMessagesBaseTest.kt | 27 +- .../storage/impl/MetisStorageBaseTest.kt | 87 +++ ...geServiceImplTestUpgradeLocalAnswerPost.kt | 132 +++++ .../MetisStorageServiceTestLiveCreation.kt | 62 +++ .../ui/post/ConversationBottomSheetUiTest.kt | 119 ++++ .../reply/ReplyTextFieldVisibilityUITest.kt | 45 ++ .../browse_channels/BrowseChannelsScreen.kt | 6 +- .../create_channel/CreateChannelScreen.kt | 2 + .../CreatePersonalConversationScreen.kt | 2 + .../conversation/overview/ConversationList.kt | 8 +- .../overview/ConversationOverviewBody.kt | 25 +- .../overview/ConversationOverviewViewModel.kt | 10 +- .../members/ConversationMembersScreen.kt | 5 +- .../overview/ConversationSettingsBody.kt | 6 +- .../overview/ConversationSettingsScreen.kt | 8 +- .../ConversationMemberSettingsE2eTest.kt | 45 +- .../overview/BrowseChannelsE2eTest.kt | 38 +- .../overview/ConversationOverviewE2eTest.kt | 13 +- .../settings/ChannelSettingsE2eTest.kt | 138 +++-- .../ConversationSettingsBaseE2eTest.kt | 26 +- .../settings/GroupChatSettingsE2eTest.kt | 25 +- feature/metis/shared/build.gradle.kts | 1 + ...{MetisPostAction.kt => MetisCrudAction.kt} | 4 +- .../metis/shared/content/MetisPostDTO.kt | 2 +- .../content/dto/ConversationWebsocketDto.kt | 4 +- .../feature/metis/shared/db/MetisDao.kt | 2 +- .../shared/db/entities/PostReactionEntity.kt | 13 + .../ConversationWebsocketExtensions.kt | 3 +- .../feature/metis/shared/db/MetisDaoTest.kt | 168 ++++++ .../feature/metis/communication_module.kt | 12 + .../feature/metis/ui/ConversationFacadeUi.kt | 9 +- .../metis/ui/SinglePageConversationBody.kt | 47 +- .../ui/SinglePageConversationBodyViewModel.kt | 44 ++ .../push/ui/PushNotificationSettingsUi.kt | 2 +- .../push/PushNotificationSettingsE2eTest.kt | 30 +- .../participation/QuizParticipationScreen.kt | 54 +- .../body/work_area/DragAndDropWorkArea.kt | 16 +- .../quiz/view_result/ViewQuizResultScreen.kt | 38 +- .../quiz/QuizParticipationBaseE2eTest.kt | 8 +- .../feature/quiz/QuizWaitingScreenE2eTest.kt | 28 +- .../PushNotificationSettingsScreen.kt | 8 +- .../feature/settings/SettingsScreen.kt | 32 +- .../src/main/res/values/settings_strings.xml | 1 + gradle/libs.versions.toml | 38 +- 137 files changed, 3490 insertions(+), 2151 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md delete mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/CourseImageProvider.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/DifficultyColors.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/ParticipationNotPossibleInfoMessageCardColors.kt rename core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/{ => colors}/text_colors.kt (96%) delete mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/difficulty_colors.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/navigation/KSerializableNavType.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/BaseImageProvider.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/CourseImageProvider.kt create mode 100644 core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/DefaultImageProvider.kt create mode 100644 core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketTopic.kt delete mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt rename feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/{ => ui}/CourseOverviewViewModel.kt (98%) create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt create mode 100644 feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt create mode 100644 feature/dashboard/src/main/res/values/survey_hint_strings.xml create mode 100644 feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/paging_source_util.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/BaseChatUItest.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageBaseTest.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImplTestUpgradeLocalAnswerPost.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceTestLiveCreation.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/ConversationBottomSheetUiTest.kt create mode 100644 feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldVisibilityUITest.kt rename feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/{MetisPostAction.kt => MetisCrudAction.kt} (58%) create mode 100644 feature/metis/shared/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDaoTest.kt create mode 100644 feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBodyViewModel.kt diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..e52a0b2c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +### Describe the bug +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +### Screenshots +If applicable, add screenshots to help explain your problem. + +### Additional context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..46f9dd12d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e06b7b0f9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + +### Problem Description + + +### Changes + + + +### Steps for testing + + +### Screenshots diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 7194207da..0277a2045 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -8,6 +8,11 @@ on: pull_request: workflow_dispatch: +permissions: + id-token: write + contents: read + checks: write + jobs: end-to-end-tests: name: E2E Tests diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index f560eef1f..5a444809f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -8,6 +8,11 @@ on: pull_request: workflow_dispatch: +permissions: + id-token: write + contents: read + checks: write + jobs: jUnit: name: JUnit Tests diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ffa6f4f8..9d3563d9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("com.google.gms.google-services") id("com.google.firebase.appdistribution") id("com.google.android.gms.oss-licenses-plugin") - id("io.sentry.android.gradle") version "3.9.0" + id("io.sentry.android.gradle") version "4.14.0" id("artemis.android.room") } diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/db/AppDatabase.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/db/AppDatabase.kt index 5d0ab9c1a..8961e79ee 100644 --- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/db/AppDatabase.kt +++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/db/AppDatabase.kt @@ -29,7 +29,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.push.communication_not CommunicationMessageEntity::class ], exportSchema = true, - version = 10, + version = 11, ) @TypeConverters(RoomTypeConverters::class) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt index ce578fb53..7954206ce 100644 --- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt +++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt @@ -21,9 +21,9 @@ import androidx.compose.ui.res.stringResource import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController -import androidx.navigation.NavOptions import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import de.tum.informatics.www1.artemis.native_app.android.BuildConfig import de.tum.informatics.www1.artemis.native_app.android.R @@ -40,16 +40,17 @@ import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.cou import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.navigateToCourseRegistration import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.course import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.navigateToCourse -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.DASHBOARD_DESTINATION -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.dashboard -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.navigateToDashboard +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.DashboardScreen +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.dashboard +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.navigateToDashboard import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewDestination import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewMode +import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewUi import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.exercise import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.navigateToExercise import de.tum.informatics.www1.artemis.native_app.feature.lectureview.lecture import de.tum.informatics.www1.artemis.native_app.feature.lectureview.navigateToLecture -import de.tum.informatics.www1.artemis.native_app.feature.login.LOGIN_DESTINATION +import de.tum.informatics.www1.artemis.native_app.feature.login.LoginScreen import de.tum.informatics.www1.artemis.native_app.feature.login.loginScreen import de.tum.informatics.www1.artemis.native_app.feature.login.navigateToLogin import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ProvideLocalVisibleMetisContextManager @@ -91,12 +92,13 @@ class MainActivity : AppCompatActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdge() // When the user is logged in, immediately display the course overview. val startDestination = runBlocking { when (accountService.authenticationData.first()) { - is AccountService.AuthenticationData.LoggedIn -> DASHBOARD_DESTINATION - AccountService.AuthenticationData.NotLoggedIn -> LOGIN_DESTINATION + is AccountService.AuthenticationData.LoggedIn -> DashboardScreen + AccountService.AuthenticationData.NotLoggedIn -> LoginScreen(null) } } @@ -189,7 +191,7 @@ class MainActivity : AppCompatActivity(), } @Composable - private fun MainActivityComposeUi(startDestination: String, navController: NavHostController) { + private fun MainActivityComposeUi(startDestination: Any, navController: NavHostController) { // Listen for when the user get logged out (e.g. because their token has expired) // This only happens when the user has the app running for multiple days or the user logged out manually LaunchedEffect(Unit) { @@ -198,7 +200,7 @@ class MainActivity : AppCompatActivity(), .collect { (wasLoggedIn, isLoggedIn) -> if (wasLoggedIn == true && !isLoggedIn) { navController.navigateToLogin { - popUpTo(DASHBOARD_DESTINATION) { + popUpTo(DashboardScreen) { inclusive = true } } @@ -258,7 +260,7 @@ class MainActivity : AppCompatActivity(), if (deepLink == null) { // Navigate to the course overview and remove the login screen from the navigation stack. navController.navigateToDashboard { - popUpTo(LOGIN_DESTINATION) { + popUpTo { inclusive = true } } @@ -266,7 +268,9 @@ class MainActivity : AppCompatActivity(), try { navController.navigate( Uri.parse(deepLink), - NavOptions.Builder().setPopUpTo(LOGIN_DESTINATION, true).build() + navOptions { + popUpTo() + } ) } catch (_: IllegalArgumentException) { navController.navigateToDashboard { @@ -346,7 +350,7 @@ class MainActivity : AppCompatActivity(), quizParticipation( onLeaveQuiz = { val previousBackStackEntry = navController.previousBackStackEntry - if (previousBackStackEntry?.destination?.route == ExerciseViewDestination.EXERCISE_VIEW_ROUTE) { + if (previousBackStackEntry?.destination?.route == ExerciseViewUi::class.qualifiedName.orEmpty()) { previousBackStackEntry.savedStateHandle[ExerciseViewDestination.REQUIRE_RELOAD_KEY] = true } diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index ddc610388..e12a57f74 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -7,6 +7,7 @@ import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.kotlin import kotlin.Suppress import kotlin.apply import kotlin.with @@ -18,6 +19,7 @@ class AndroidFeatureConventionPlugin : Plugin { pluginManager.apply { apply("artemis.android.library") apply("org.gradle.jacoco") + apply("org.jetbrains.kotlin.plugin.serialization") } extensions.configure { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e3f9e9cd6..0b1e201ba 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,7 +1,7 @@ plugins { `kotlin-dsl` kotlin("jvm") version "1.8.10" - kotlin("plugin.serialization") version "1.8.10" + kotlin("plugin.serialization") version "2.0.21" // id("java-gradle-plugin") } diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt index d44563500..8fa6df7c3 100644 --- a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/ArtemisMarkdownTransformer.kt @@ -18,12 +18,26 @@ abstract class ArtemisMarkdownTransformer { channelName: String, conversationId: Long ): String = "" + + override fun transformLectureContentMarkdown( + type: String, + fileName: String, + url: String + ) : String = "" + + override fun transformFileUploadMessageMarkdown( + isImage: Boolean, + fileName: String, + filePath: String, + ) : String = "" } private val exerciseMarkdownPattern = "\\[(text|quiz|lecture|modeling|file-upload|programming)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex() private val userMarkdownPattern = "\\[user](.*?)\\((.*?)\\)\\[/user]".toRegex() private val channelMarkdownPattern = "\\[channel](.*?)\\((\\d+?)\\)\\[/channel]".toRegex() + private val lectureContentMarkdownPattern = "\\[(attachment|lecture-unit|slide)](.*?)\\(([/\\w\\d\\-_\\.]+)\\)\\[/\\1]".toRegex() + private val fileUploadMessagePattern = "(\\!?)\\[(.*?)]\\((/api/files/[\\w\\d/\\-_.]+)\\)".toRegex() fun transformMarkdown(markdown: String): String { return exerciseMarkdownPattern.replace(markdown) { matchResult -> @@ -49,6 +63,30 @@ abstract class ArtemisMarkdownTransformer { conversationId = conversationId ) } + }.let { + lectureContentMarkdownPattern.replace(it) { matchResult -> + val type = matchResult.groups[1]?.value.orEmpty() + val fileName = matchResult.groups[2]?.value.orEmpty() + val url = matchResult.groups[3]?.value.orEmpty() + transformLectureContentMarkdown( + type = type, + fileName = fileName, + url = url + ) + } + }.let { + fileUploadMessagePattern.replace(it) { matchResult -> + // file uploads can be images or other files represented by a link: + // image: ![fileName](url), file: [fileName](url) + val isImage = matchResult.groups[1]?.value.orEmpty() == "!" + val fileName = matchResult.groups[2]?.value.orEmpty() + val filePath = matchResult.groups[3]?.value.orEmpty() + transformFileUploadMessageMarkdown( + isImage = isImage, + fileName = fileName, + filePath = filePath + ) + } } } @@ -64,4 +102,16 @@ abstract class ArtemisMarkdownTransformer { channelName: String, conversationId: Long ): String + + protected abstract fun transformLectureContentMarkdown( + type: String, + fileName: String, + url: String + ): String + + protected abstract fun transformFileUploadMessageMarkdown( + isImage: Boolean, + fileName: String, + filePath: String + ): String } diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt index d17e58434..63da25b90 100644 --- a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PostArtemisMarkdownTransformer.kt @@ -11,4 +11,26 @@ class PostArtemisMarkdownTransformer(val serverUrl: String, val courseId: Long) channelName: String, conversationId: Long ): String = "[#$channelName](artemis://courses/$courseId/messages?conversationId=$conversationId)" + + override fun transformLectureContentMarkdown( + type: String, + fileName: String, + url: String + ): String { + return when (type) { + "attachment" -> "[$fileName](artemis:/$url)" + "lecture-unit" -> "[$fileName]($serverUrl/api/files/attachments/$url)" // TODO: fix authentication or redirect to lecture unit (https://github.com/ls1intum/artemis-android/issues/117) + "slide" -> "[$fileName]($serverUrl/api/files/attachments/$url)" // TODO: fix authentication or redirect to lecture unit (https://github.com/ls1intum/artemis-android/issues/117) + else -> fileName + } + } + + override fun transformFileUploadMessageMarkdown( + isImage: Boolean, + fileName: String, + filePath: String + ): String { + // TODO: fix authentication or redirect for all non-image uploads (https://github.com/ls1intum/artemis-android/issues/117) + return if (isImage) "![$fileName]($serverUrl$filePath)" else "[$fileName]($serverUrl$filePath)" + } } diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt index 362db3548..abb4fa550 100644 --- a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/markdown/PushNotificationArtemisMarkdownTransformer.kt @@ -10,4 +10,8 @@ object PushNotificationArtemisMarkdownTransformer : ArtemisMarkdownTransformer() channelName: String, conversationId: Long ): String = "#$channelName" + + override fun transformLectureContentMarkdown(type: String, fileName: String, url: String): String = fileName + + override fun transformFileUploadMessageMarkdown(isImage: Boolean, fileName: String, filePath: String) = fileName } diff --git a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/BaseComposeTest.kt b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/BaseComposeTest.kt index a71a648ad..5bbeab22f 100644 --- a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/BaseComposeTest.kt +++ b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/BaseComposeTest.kt @@ -21,9 +21,18 @@ abstract class BaseComposeTest : KoinTest { protected val context: Context get() = InstrumentationRegistry.getInstrumentation().context - fun runBlockingWithTestTimeout(block: suspend () -> T): T { + /** + * Run a block of code with a timeout. The default test timeout is multiplied by the + * given [timeoutMultiplier]. + * @param timeoutMultiplier The multiplier for the default test timeout. Can be used to increase + * the timeout for flaky tests + */ + fun runBlockingWithTestTimeout( + timeoutMultiplier: Int = 1, + block: suspend () -> T + ): T { return runBlocking { - withTimeout(DefaultTimeoutMillis) { + withTimeout(DefaultTimeoutMillis * timeoutMultiplier) { block() } } diff --git a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt index f294651cb..5053b60fb 100644 --- a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt +++ b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt @@ -21,12 +21,12 @@ class TestWebsocketProvider : WebsocketProvider { override val isConnected: Flow = flowOf(true) override fun subscribe( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow> = flowOf(WebsocketProvider.WebsocketData.Subscribe()) override fun subscribeMessage( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow = emptyFlow() diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/CourseImageProvider.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/CourseImageProvider.kt deleted file mode 100644 index 4542f3b14..000000000 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/CourseImageProvider.kt +++ /dev/null @@ -1,61 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.core.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalContext -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest -import io.ktor.http.HttpHeaders -import io.ktor.http.URLBuilder -import io.ktor.http.appendPathSegments - -val LocalCourseImageProvider = compositionLocalOf { CourseImageProviderImpl } - -interface CourseImageProvider { - @Composable - fun rememberCourseImagePainter( - courseIconPath: String, - serverUrl: String, - authorizationToken: String - ): Painter -} - -private object CourseImageProviderImpl : CourseImageProvider { - @Composable - override fun rememberCourseImagePainter( - courseIconPath: String, - serverUrl: String, - authorizationToken: String - ): Painter = rememberAsyncImagePainter( - model = getCourseIconRequest( - serverUrl = serverUrl, - courseIconPath = courseIconPath, - authorizationToken = authorizationToken - ) - ) - - @Composable - private fun getCourseIconRequest( - serverUrl: String, - courseIconPath: String, - authorizationToken: String - ): ImageRequest { - val courseIconUrl = remember(serverUrl, courseIconPath) { - URLBuilder(serverUrl) - .appendPathSegments(courseIconPath) - .buildString() - } - - val context = LocalContext.current - - //Authorization needed - return remember { - ImageRequest.Builder(context) - .addHeader(HttpHeaders.Cookie, "jwt=$authorizationToken") - .data(courseIconUrl) - .build() - } - } -} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/course/CourseListUi.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/course/CourseListUi.kt index 661e46bde..4bb14fe0c 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/course/CourseListUi.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/course/CourseListUi.kt @@ -9,12 +9,22 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridItemScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -46,7 +56,7 @@ import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt import de.tum.informatics.www1.artemis.native_app.core.model.Course import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore -import de.tum.informatics.www1.artemis.native_app.core.ui.LocalCourseImageProvider +import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalCourseImageProvider import de.tum.informatics.www1.artemis.native_app.core.ui.R import de.tum.informatics.www1.artemis.native_app.core.ui.common.AutoResizeText import de.tum.informatics.www1.artemis.native_app.core.ui.common.FontSizeRange @@ -71,7 +81,9 @@ fun CourseItemGrid( columns = GridCells.Fixed(columnCount), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 90.dp) + contentPadding = PaddingValues( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) ) { items(courses, key = { it.course.id ?: 0L }) { course -> courseItem(course, courseItemModifier, isCompact) 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 67580b3b1..0049d6e2e 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 @@ -6,8 +6,9 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -15,7 +16,6 @@ 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 @@ -29,6 +29,7 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.latestPart import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.Participation import de.tum.informatics.www1.artemis.native_app.core.ui.R import de.tum.informatics.www1.artemis.native_app.core.ui.date.hasPassed +import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.ParticipationNotPossibleInfoMessageCardColors /** * This composable composes up to two buttons. The modifier parameter is applied to every button @@ -96,43 +97,50 @@ fun ExerciseActionButtons( } if (templateStatus != null) { - if (exercise is TextExercise) { - if (latestParticipation?.initializationState == Participation.InitializationState.INITIALIZED) { - Button( - modifier = modifier, - onClick = { - actions.onClickOpenTextExercise( - latestParticipation.id ?: return@Button + when (exercise) { + is TextExercise -> { + if (latestParticipation?.initializationState == Participation.InitializationState.INITIALIZED) { + Button( + modifier = modifier, + onClick = { + actions.onClickOpenTextExercise( + latestParticipation.id ?: return@Button + ) + }, + enabled = !exercise.teamMode + ) { + Text( + text = stringResource(id = R.string.exercise_actions_open_exercise_button) ) - }, - enabled = !exercise.teamMode - ) { - Text( - text = stringResource(id = R.string.exercise_actions_open_exercise_button) - ) + } } - } - if (latestParticipation?.initializationState == Participation.InitializationState.FINISHED && - (latestParticipation.results.isNullOrEmpty() || !showResult) - ) { - Button( - modifier = modifier, - onClick = { - actions.onClickOpenTextExercise( - latestParticipation.id ?: return@Button - ) - }, - enabled = !exercise.teamMode + if (latestParticipation?.initializationState == Participation.InitializationState.FINISHED && + (latestParticipation.results.isNullOrEmpty() || !showResult) ) { - Text( - text = stringResource(id = R.string.exercise_actions_view_submission_button) - ) + Button( + modifier = modifier, + onClick = { + actions.onClickOpenTextExercise( + latestParticipation.id ?: return@Button + ) + }, + enabled = !exercise.teamMode + ) { + Text( + text = stringResource(id = R.string.exercise_actions_view_submission_button) + ) + } } } - } else { - Row(modifier=Modifier.padding(top=2.dp, bottom = 2.dp)) { - InfoMessageCard() + // TODO: The following code is temporarily disabled. See https://github.com/ls1intum/artemis-android/issues/107 + //is QuizExercise -> { + // Do not show participation not possible info card for quiz exercises + //} + else -> { + Row(modifier=Modifier.padding(top=2.dp, bottom = 2.dp)) { + ParticipationNotPossibleInfoMessageCard() + } } } @@ -209,25 +217,29 @@ class BoundExerciseActions( @Composable -fun InfoMessageCard() { +fun ParticipationNotPossibleInfoMessageCard() { Box( modifier = Modifier - .border(width = 2.dp, color = Color.LightGray) - .background(Color(0xFFB3E5FC)) // Light sky blue background - .padding(10.dp) + .border( + width = 1.dp, + color = ParticipationNotPossibleInfoMessageCardColors.border, + shape = RoundedCornerShape(4.dp) + ) + .background(ParticipationNotPossibleInfoMessageCardColors.background) + .padding(8.dp) .fillMaxWidth() ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - imageVector = Icons.Filled.Info, - contentDescription = "Information", + imageVector = Icons.Outlined.Info, + contentDescription = null, modifier = Modifier.padding(end = 8.dp), - tint = Color(0xFF0288D1) + tint = ParticipationNotPossibleInfoMessageCardColors.text ) Text( text = stringResource(id = R.string.exercise_participation_not_possible), fontSize = 16.sp, - color = Color.Black + color = ParticipationNotPossibleInfoMessageCardColors.text ) } } diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseListItem.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseListItem.kt index f7279e9c9..45fdccc46 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseListItem.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseListItem.kt @@ -29,9 +29,7 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.ui.R import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime import de.tum.informatics.www1.artemis.native_app.core.ui.getWindowSizeClass -import de.tum.informatics.www1.artemis.native_app.core.ui.material.easyColor -import de.tum.informatics.www1.artemis.native_app.core.ui.material.hardColor -import de.tum.informatics.www1.artemis.native_app.core.ui.material.mediumColor +import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.DifficultyColors /** * Display a single exercise. @@ -113,13 +111,13 @@ private fun DifficultyRectangle(modifier: Modifier, difficulty: Exercise.Difficu .background( color = when (difficulty) { Exercise.Difficulty.EASY -> - easyColor + DifficultyColors.easy Exercise.Difficulty.MEDIUM -> - mediumColor + DifficultyColors.medium Exercise.Difficulty.HARD -> - hardColor + DifficultyColors.hard } ) ) diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt index 4690753db..ec5e2efd3 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/LocalMarkwon.kt @@ -16,8 +16,9 @@ val LocalMarkwon: ProvidableCompositionLocal = fun ProvideMarkwon(imageLoader: ImageLoader? = null, content: @Composable () -> Unit) { val context = LocalContext.current + val imageWith = context.resources.displayMetrics.widthPixels val markdownRender: Markwon = remember(imageLoader) { - createMarkdownRender(context, imageLoader) + createMarkdownRender(context, imageLoader, imageWith) } CompositionLocalProvider(LocalMarkwon provides markdownRender) { diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt index 6ce05535e..412e5f516 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/markdown/MarkdownText.kt @@ -2,7 +2,6 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.markdown import android.content.Context import android.text.method.LinkMovementMethod -import android.text.style.ForegroundColorSpan import android.util.TypedValue import android.view.View import android.widget.TextView @@ -31,11 +30,15 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.ResourcesCompat import coil.ImageLoader +import coil.request.Disposable +import coil.request.ImageRequest +import coil.size.Scale import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer import io.noties.markwon.Markwon import io.noties.markwon.ext.strikethrough.StrikethroughPlugin import io.noties.markwon.ext.tables.TablePlugin import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.AsyncDrawable import io.noties.markwon.image.coil.CoilImagesPlugin import io.noties.markwon.linkify.LinkifyPlugin @@ -87,8 +90,9 @@ fun MarkdownText( val context: Context = LocalContext.current val localMarkwon = LocalMarkwon.current + val imageWith = context.resources.displayMetrics.widthPixels val markdownRender: Markwon = localMarkwon ?: remember(imageLoader) { - createMarkdownRender(context, imageLoader) + createMarkdownRender(context, imageLoader, imageWith) } val markdownTransformer = LocalMarkdownTransformer.current @@ -101,11 +105,12 @@ fun MarkdownText( AndroidView( // Added semantics for ui testing. - modifier = modifier.semantics { - text = AnnotatedString(markdown) - onClick?.let { this.onClick(action = { onClick(); true }) } - onLongClick?.let { this.onLongClick(action = { onLongClick(); true }) } - }, + modifier = modifier + .semantics { + text = AnnotatedString(markdown) + onClick?.let { this.onClick(action = { onClick(); true }) } + onLongClick?.let { this.onLongClick(action = { onLongClick(); true }) } + }, factory = { ctx -> createTextView( context = ctx, @@ -204,16 +209,39 @@ private fun TextView.applyStyleAndColor( } } -fun createMarkdownRender(context: Context, imageLoader: ImageLoader?): Markwon { +fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWith: Int): Markwon { + // Setting the size of the output image is important to avoid jittering UIs. + val imagePlugin: CoilImagesPlugin? = + if (imageLoader != null) { + CoilImagesPlugin.create( + object : CoilImagesPlugin.CoilStore { + override fun load(drawable: AsyncDrawable): ImageRequest { + return ImageRequest.Builder(context) + .defaults(imageLoader.defaults) + .data(drawable.destination) + .crossfade(true) + .size(imageWith, 800) // We set a fixed height and set the width of the image to the screen width. + .scale(Scale.FIT) + .build() + } + + override fun cancel(disposable: Disposable) { + disposable.dispose() + } + }, + imageLoader + ) + } else null + return Markwon.builder(context) .usePlugin(HtmlPlugin.create()) .usePlugin(StrikethroughPlugin.create()) .usePlugin(TablePlugin.create(context)) .usePlugin(LinkifyPlugin.create()) .apply { - if (imageLoader != null) { - usePlugin(CoilImagesPlugin.create(context, imageLoader)) + if (imagePlugin != null) { + usePlugin(imagePlugin) } } .build() -} +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/DifficultyColors.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/DifficultyColors.kt new file mode 100644 index 000000000..c9be88a03 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/DifficultyColors.kt @@ -0,0 +1,13 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.material.colors + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +object DifficultyColors { + val hard: Color + @Composable get() = Color(0xffdc3545) + val medium: Color + @Composable get() = Color(0xffffc107) + val easy: Color + @Composable get() = Color(0xff28a745) +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/ParticipationNotPossibleInfoMessageCardColors.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/ParticipationNotPossibleInfoMessageCardColors.kt new file mode 100644 index 000000000..847655e58 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/ParticipationNotPossibleInfoMessageCardColors.kt @@ -0,0 +1,14 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.material.colors + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +object ParticipationNotPossibleInfoMessageCardColors { + val background: Color + @Composable get() = if(isSystemInDarkTheme()) Color(0xFF062A30) else Color(0xFFD1ECF1) + val border: Color + @Composable get() = if(isSystemInDarkTheme()) Color(0xFF148EA1) else Color(0xFFA2DAE3) + val text: Color + @Composable get() = if(isSystemInDarkTheme()) Color(0xFF36CEE6) else Color(0xFF09414A) +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/text_colors.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/text_colors.kt similarity index 96% rename from core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/text_colors.kt rename to core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/text_colors.kt index 50c755297..2ccad4b89 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/text_colors.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/colors/text_colors.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.core.ui.material +package de.tum.informatics.www1.artemis.native_app.core.ui.material.colors import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/difficulty_colors.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/difficulty_colors.kt deleted file mode 100644 index 52f862fc0..000000000 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/material/difficulty_colors.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.core.ui.material - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val hardColor: Color - @Composable get() = Color(0xffdc3545) -val mediumColor: Color - @Composable get() = Color(0xffffc107) -val easyColor: Color - @Composable get() = Color(0xff28a745) \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/navigation/KSerializableNavType.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/navigation/KSerializableNavType.kt new file mode 100644 index 000000000..ab98f7664 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/navigation/KSerializableNavType.kt @@ -0,0 +1,30 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.navigation + +import android.os.Bundle +import androidx.navigation.NavType +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json + +class KSerializableNavType( + isNullableAllowed: Boolean, + private val serializer: KSerializer +) : NavType(isNullableAllowed) { + + companion object { + private val json = Json { + coerceInputValues = true + } + } + + override fun get(bundle: Bundle, key: String): T? { + return parseValue(bundle.getString(key) ?: return null) + } + + override fun parseValue(value: String): T { + return json.decodeFromString(serializer, value) + } + + override fun put(bundle: Bundle, key: String, value: T) { + bundle.putString(key, json.encodeToString(serializer, value)) + } +} diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/BaseImageProvider.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/BaseImageProvider.kt new file mode 100644 index 000000000..65ee05731 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/BaseImageProvider.kt @@ -0,0 +1,17 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images + +import android.content.Context +import coil.ImageLoader +import coil.request.ImageRequest + +interface BaseImageProvider { + fun createImageRequest( + context: Context, + imagePath: String, + serverUrl: String, + authorizationToken: String, + memoryCacheKey: String? = null + ): ImageRequest + + fun createImageLoader(context: Context, authorizationToken: String): ImageLoader +} diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/CourseImageProvider.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/CourseImageProvider.kt new file mode 100644 index 000000000..6f7951dc3 --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/CourseImageProvider.kt @@ -0,0 +1,36 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import coil.compose.rememberAsyncImagePainter + +val LocalCourseImageProvider = compositionLocalOf { DefaultCourseImageProvider } + +interface CourseImageProvider { + @Composable + fun rememberCourseImagePainter( + courseIconPath: String, + serverUrl: String, + authorizationToken: String + ): Painter +} + +private object DefaultCourseImageProvider : CourseImageProvider { + private val imageProvider = DefaultImageProvider() + + @Composable + override fun rememberCourseImagePainter( + courseIconPath: String, + serverUrl: String, + authorizationToken: String + ): Painter { + val context = LocalContext.current + val imageRequest = remember { + imageProvider.createImageRequest(context, courseIconPath, serverUrl, authorizationToken) + } + return rememberAsyncImagePainter(model = imageRequest) + } +} diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/DefaultImageProvider.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/DefaultImageProvider.kt new file mode 100644 index 000000000..c581e33ae --- /dev/null +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/remote_images/DefaultImageProvider.kt @@ -0,0 +1,47 @@ +package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images + +import android.content.Context +import coil.ImageLoader +import coil.request.ImageRequest +import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments + +class DefaultImageProvider : BaseImageProvider { + override fun createImageRequest( + context: Context, + imagePath: String, + serverUrl: String, + authorizationToken: String, + memoryCacheKey: String? + ): ImageRequest { + val imageUrl = URLBuilder(serverUrl).appendPathSegments(imagePath).buildString() + + val builder = ImageRequest.Builder(context) + .addHeader(HttpHeaders.Cookie, "jwt=$authorizationToken") + .data(imageUrl) + + memoryCacheKey?.let { + builder.memoryCacheKey(it) + } + return builder.build() + } + + override fun createImageLoader( + context: Context, + authorizationToken: String + ): ImageLoader { + return ImageLoader.Builder(context) + .okHttpClient { + okhttp3.OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .addHeader(HttpHeaders.Cookie, "jwt=$authorizationToken") + .build() + chain.proceed(request) + } + .build() + } + .build() + } +} diff --git a/core/ui/src/main/res/values/exercise_strings.xml b/core/ui/src/main/res/values/exercise_strings.xml index 540edd2d4..c391b8652 100644 --- a/core/ui/src/main/res/values/exercise_strings.xml +++ b/core/ui/src/main/res/values/exercise_strings.xml @@ -39,8 +39,8 @@ View result - Participating this exercise is currently - not possible in the mobile app. + Participating in this exercise is currently + not possible in the mobile app Start exercise Open exercise View submission diff --git a/core/websocket/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProviderStub.kt b/core/websocket/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProviderStub.kt index a97fe2f2e..89f33dc61 100644 --- a/core/websocket/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProviderStub.kt +++ b/core/websocket/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProviderStub.kt @@ -15,12 +15,12 @@ class WebsocketProviderStub : WebsocketProvider { override val isConnected: Flow = flowOf(true) override fun subscribe( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow> = emptyFlow() override fun subscribeMessage( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow = emptyFlow() diff --git a/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProvider.kt b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProvider.kt index 08664d037..1b9dbbefe 100644 --- a/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProvider.kt +++ b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/WebsocketProvider.kt @@ -19,12 +19,12 @@ interface WebsocketProvider { * Performs automatic reconnects. */ fun subscribe( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow> fun subscribeMessage( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow diff --git a/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketProviderImpl.kt b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketProviderImpl.kt index 4b7582409..d3b95821a 100644 --- a/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketProviderImpl.kt +++ b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketProviderImpl.kt @@ -199,7 +199,7 @@ class WebsocketProviderImpl( * The given flow can only be subscribed to once. */ override fun subscribe( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow> { return session @@ -207,20 +207,20 @@ class WebsocketProviderImpl( val flow: Flow> = flow { emitAll( currentSession.subscribe( - StompSubscribeHeaders(destination = channel), + StompSubscribeHeaders(destination = topic), deserializer ) ) } .onStart { - Log.d(TAG, "subscribe! $channel") + Log.d(TAG, "subscribe! $topic") emit(WebsocketProvider.WebsocketData.Subscribe()) } .onCompletion { - Log.d(TAG, "unsubscribe! $channel") + Log.d(TAG, "unsubscribe! $topic") } .catch { e -> - Log.d(TAG, "Subscription $channel reported error: ${e.localizedMessage}") + Log.d(TAG, "Subscription $topic reported error: ${e.localizedMessage}") } .map { WebsocketProvider.WebsocketData.Message(it) @@ -247,10 +247,10 @@ class WebsocketProviderImpl( } override fun subscribeMessage( - channel: String, + topic: String, deserializer: DeserializationStrategy ): Flow { - return subscribe(channel, deserializer).mapNotNull { + return subscribe(topic, deserializer).mapNotNull { when (it) { is WebsocketProvider.WebsocketData.Message -> it.message else -> null diff --git a/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketTopic.kt b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketTopic.kt new file mode 100644 index 000000000..1f4110635 --- /dev/null +++ b/core/websocket/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/websocket/impl/WebsocketTopic.kt @@ -0,0 +1,26 @@ +package de.tum.informatics.www1.artemis.native_app.core.websocket.impl + +object WebsocketTopic { + + /** + * Returns the topic for conversation updates for a course-wide conversation. + */ + fun getCourseWideConversationUpdateTopic(courseId: Long): String { + return "/topic/metis/courses/$courseId" + } + + /** + * Returns the topic for conversation updates for a non-course-wide conversation. + */ + fun getNormalConversationUpdateTopic(userId: Long): String { + return "/topic/user/$userId/notifications/conversations" + } + + /** + * Returns the topic for conversation meta updates. This includes channel creation, deletion, + * and updates (like changing the channel name). + */ + fun getConversationMetaUpdateTopic(courseId: Long, userId: Long): String { + return "/user/topic/metis/courses/$courseId/conversations/user/$userId" + } +} \ No newline at end of file diff --git a/feature/course-registration/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/RegisterForCourseUi.kt b/feature/course-registration/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/RegisterForCourseUi.kt index c3c8d6fa2..d789f7f70 100644 --- a/feature/course-registration/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/RegisterForCourseUi.kt +++ b/feature/course-registration/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/RegisterForCourseUi.kt @@ -6,9 +6,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -60,23 +64,25 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.computeC import de.tum.informatics.www1.artemis.native_app.core.ui.getWindowSizeClass import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.MarkdownText import kotlinx.coroutines.Deferred +import kotlinx.serialization.Serializable import org.koin.androidx.compose.getViewModel internal const val TEST_TAG_REGISTRABLE_COURSE_LIST = "registrable course list" internal fun testTagForRegistrableCourse(courseId: Long) = "registrableCourse$courseId" -private const val COURSE_REGISTRATION_DESTINATION = "courseRegistration" +@Serializable +private data object CourseRegistrationScreen fun NavController.navigateToCourseRegistration(builder: NavOptionsBuilder.() -> Unit) { - navigate(COURSE_REGISTRATION_DESTINATION, builder) + navigate(CourseRegistrationScreen, builder) } fun NavGraphBuilder.courseRegistration( onNavigateUp: () -> Unit, onRegisteredInCourse: (courseId: Long) -> Unit ) { - composable(COURSE_REGISTRATION_DESTINATION) { + composable { RegisterForCourseScreen( modifier = Modifier.fillMaxSize(), viewModel = getViewModel(), @@ -140,7 +146,8 @@ internal fun RegisterForCourseScreen( RegisterForCourseContent( modifier = Modifier .fillMaxSize() - .padding(padding) + .padding(top = padding.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars) .padding(horizontal = 8.dp), courses = courses, serverUrl = properServerUrl, @@ -235,6 +242,7 @@ private fun RegisterForCourseContent( .fillMaxSize() .testTag(TEST_TAG_REGISTRABLE_COURSE_LIST), columns = GridCells.Fixed(columnCount), + contentPadding = WindowInsets.systemBars.asPaddingValues(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/feature/course-registration/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/CourseRegistrationE2eTest.kt b/feature/course-registration/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/CourseRegistrationE2eTest.kt index 9973ba91b..1e34cda5f 100644 --- a/feature/course-registration/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/CourseRegistrationE2eTest.kt +++ b/feature/course-registration/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseregistration/CourseRegistrationE2eTest.kt @@ -1,37 +1,33 @@ package de.tum.informatics.www1.artemis.native_app.feature.courseregistration -import android.content.Context import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToKey import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith import org.koin.android.ext.koin.androidContext -import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.koin.test.get import org.robolectric.RobolectricTestRunner @@ -39,12 +35,7 @@ import kotlin.test.assertEquals @Category(EndToEndTest::class) @RunWith(RobolectricTestRunner::class) -class CourseRegistrationE2eTest : KoinTest { - - private val context: Context get() = InstrumentationRegistry.getInstrumentation().context - - @get:Rule - val composeTestRule = createComposeRule() +class CourseRegistrationE2eTest : BaseComposeTest() { @get:Rule val koinTestRule = KoinTestRule.create { @@ -58,11 +49,9 @@ class CourseRegistrationE2eTest : KoinTest { @Before fun setup() { - runBlocking { - withTimeout(DefaultTimeoutMillis) { - performTestLogin() - course = createCourse(getAdminAccessToken(), forceSelfRegistration = true) - } + runBlockingWithTestTimeout { + performTestLogin() + course = createCourse(getAdminAccessToken(), forceSelfRegistration = true) } } diff --git a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/WeeklyItemsUi.kt b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/WeeklyItemsUi.kt index 9f641b192..40f93d56b 100644 --- a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/WeeklyItemsUi.kt +++ b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/WeeklyItemsUi.kt @@ -3,9 +3,13 @@ package de.tum.informatics.www1.artemis.native_app.feature.courseview.ui import android.os.Parcelable import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -71,7 +75,13 @@ internal fun WeeklyItemsLazyColumn( } } - LazyColumn(modifier = modifier, verticalArrangement = verticalArrangement) { + LazyColumn( + modifier = modifier, + verticalArrangement = verticalArrangement, + contentPadding = PaddingValues( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { weeklyItemGroups.forEachIndexed { index, weeklyItems -> item { WeeklyItemsSectionHeader( diff --git a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt index c2eedf643..29d957e69 100644 --- a/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt +++ b/feature/course-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ui/course_overview/CourseUiScreen.kt @@ -6,8 +6,11 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -34,6 +37,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.model.Course import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise @@ -53,6 +57,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.NavigateToUse import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.NothingOpened import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.OpenedConversation import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.OpenedThread +import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -63,8 +68,16 @@ internal const val TAB_COMMUNICATION = 2 internal const val DEFAULT_CONVERSATION_ID = -1L internal const val DEFAULT_POST_ID = -1L +@Serializable +private data class CourseUiScreen( + val courseId: Long, + val conversationId: Long = DEFAULT_CONVERSATION_ID, + val postId: Long = DEFAULT_POST_ID, + val username: String = "" +) + fun NavController.navigateToCourse(courseId: Long, builder: NavOptionsBuilder.() -> Unit) { - navigate("course/$courseId", builder) + navigate(CourseUiScreen(courseId), builder) } fun NavGraphBuilder.course( @@ -85,25 +98,15 @@ fun NavGraphBuilder.course( generateLinks("courses/{courseId}/exercises") + generateLinks("courses/{courseId}/messages?conversationId={conversationId}") + generateLinks("courses/{courseId}/messages?username={username}") - composable( - route = "course/{courseId}", - arguments = listOf( - navArgument("courseId") { type = NavType.LongType; nullable = false }, - navArgument("conversationId") { - type = NavType.LongType; defaultValue = DEFAULT_CONVERSATION_ID - }, - navArgument("postId") { type = NavType.LongType; defaultValue = DEFAULT_POST_ID }, - navArgument("username") { type = NavType.StringType; defaultValue = "" } - ), + composable( deepLinks = deepLinks ) { backStackEntry -> - val courseId = backStackEntry.arguments?.getLong("courseId") - checkNotNull(courseId) + val route: CourseUiScreen = backStackEntry.toRoute() + val courseId = route.courseId - val conversationId = - backStackEntry.arguments?.getLong("conversationId") ?: DEFAULT_CONVERSATION_ID - val postId = backStackEntry.arguments?.getLong("postId") ?: DEFAULT_POST_ID - val username = backStackEntry.arguments?.getString("username").orEmpty() + val conversationId = route.conversationId + val postId = route.postId + val username = route.username CourseUiScreen( modifier = Modifier.fillMaxSize(), @@ -331,7 +334,8 @@ internal fun CourseUiScreen( BasicDataStateUi( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(top = padding.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars), dataState = courseDataState, loadingText = stringResource(id = R.string.course_ui_loading_course_loading), failureText = stringResource(id = R.string.course_ui_loading_course_failed), diff --git a/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/BaseCourseTest.kt b/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/BaseCourseTest.kt index e575ee01b..31c810471 100644 --- a/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/BaseCourseTest.kt +++ b/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/BaseCourseTest.kt @@ -1,16 +1,14 @@ package de.tum.informatics.www1.artemis.native_app.feature.courseview -import android.content.Context import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.platform.app.InstrumentationRegistry import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse import de.tum.informatics.www1.artemis.native_app.core.test.testWebsocketModule +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.CourseViewModel import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.CourseUiScreen import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.DEFAULT_CONVERSATION_ID @@ -19,9 +17,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Rule import org.koin.android.ext.koin.androidContext @@ -29,16 +24,10 @@ import org.koin.compose.LocalKoinApplication import org.koin.compose.LocalKoinScope import org.koin.core.annotation.KoinInternalApi import org.koin.mp.KoinPlatformTools -import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.koin.test.get -abstract class BaseCourseTest : KoinTest { - - protected val testDispatcher = UnconfinedTestDispatcher() - - @get:Rule - val composeTestRule = createComposeRule() +abstract class BaseCourseTest : BaseComposeTest() { @get:Rule val koinTestRule = KoinTestRule.create { @@ -50,16 +39,11 @@ abstract class BaseCourseTest : KoinTest { lateinit var course: Course - val context: Context get() = InstrumentationRegistry.getInstrumentation().context - @Before fun setup() { - runBlocking { - withTimeout(DefaultTimeoutMillis) { - performTestLogin() - - course = createCourse(getAdminAccessToken()) - } + runBlockingWithTestTimeout { + performTestLogin() + course = createCourse(getAdminAccessToken()) } } diff --git a/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ExerciseListOverviewE2eTest.kt b/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ExerciseListOverviewE2eTest.kt index 335079cfe..2b9baf652 100644 --- a/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ExerciseListOverviewE2eTest.kt +++ b/feature/course-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/ExerciseListOverviewE2eTest.kt @@ -5,9 +5,9 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToKey +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createExercise import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createModelingExercise @@ -15,8 +15,6 @@ import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_cr import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createTextExercise import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.exercise_list.TEST_TAG_EXERCISE_LIST_LAZY_COLUMN import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Ignore import org.junit.Test import org.junit.experimental.categories.Category @@ -80,10 +78,8 @@ class ExerciseListOverviewE2eTest : BaseCourseTest() { private fun displayExerciseTypeTestImpl(createExercise: suspend () -> Exercise) { - val exercise = runBlocking { - withTimeout(DefaultTimeoutMillis) { - createExercise() - } + val exercise = runBlockingWithTestTimeout { + createExercise() } setupAndDisplayCourseUi() diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts index f4e3c194a..28440dc30 100644 --- a/feature/dashboard/build.gradle.kts +++ b/feature/dashboard/build.gradle.kts @@ -1,7 +1,6 @@ plugins { id("artemis.android.feature") id("artemis.android.library.compose") - kotlin("plugin.serialization") } android { diff --git a/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt b/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt index f5d28492f..8897d1892 100644 --- a/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt +++ b/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt @@ -16,11 +16,13 @@ import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore import de.tum.informatics.www1.artemis.native_app.core.model.Dashboard import de.tum.informatics.www1.artemis.native_app.core.model.exercise.TextExercise import de.tum.informatics.www1.artemis.native_app.core.model.lecture.Lecture -import de.tum.informatics.www1.artemis.native_app.core.ui.CourseImageProvider -import de.tum.informatics.www1.artemis.native_app.core.ui.LocalCourseImageProvider +import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.CourseImageProvider +import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalCourseImageProvider import de.tum.informatics.www1.artemis.native_app.core.ui.PlayStoreScreenshots import de.tum.informatics.www1.artemis.native_app.core.ui.ScreenshotFrame import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.DashboardService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CoursesOverview private const val IMAGE_MARS = "mars" private const val IMAGE_SATURN_5 = "saturn5" diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt deleted file mode 100644 index 1436d7c7b..000000000 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt +++ /dev/null @@ -1,519 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.feature.dashboard - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -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 -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable -import de.tum.informatics.www1.artemis.native_app.core.model.Course -import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore -import de.tum.informatics.www1.artemis.native_app.core.model.Dashboard -import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseHeaderViewMode -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseItemHeader -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseExerciseAndLectureCount -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseItemGrid -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.ExpandedCourseItemHeader -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.CoursePointsDecimalFormat -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService -import kotlinx.coroutines.launch -import org.koin.androidx.compose.getViewModel -import org.koin.compose.koinInject -import java.text.DecimalFormat - -const val DASHBOARD_DESTINATION = "dashboard" -internal const val TEST_TAG_COURSE_LIST = "TEST_TAG_COURSE_LIST" - -internal fun testTagForCourse(courseId: Long) = "Course$courseId" - -fun NavController.navigateToDashboard(builder: NavOptionsBuilder.() -> Unit) { - navigate(DASHBOARD_DESTINATION, builder) -} - -fun NavGraphBuilder.dashboard( - onOpenSettings: () -> Unit, - onClickRegisterForCourse: () -> Unit, - onViewCourse: (courseId: Long) -> Unit -) { - composable(DASHBOARD_DESTINATION) { - CoursesOverview( - modifier = Modifier.fillMaxSize(), - viewModel = getViewModel(), - onOpenSettings = onOpenSettings, - onClickRegisterForCourse = onClickRegisterForCourse, - onViewCourse = onViewCourse - ) - } -} - -/** - * Displays the Course Overview screen. - * Uses Scaffold to display a Material Design TopAppBar. - */ -@Composable -internal fun CoursesOverview( - modifier: Modifier, - viewModel: CourseOverviewViewModel, - onOpenSettings: () -> Unit, - onClickRegisterForCourse: () -> Unit, - onViewCourse: (courseId: Long) -> Unit, - isBeta: Boolean = BuildConfig.isBeta, - betaHintService: BetaHintService = koinInject() -) { - val coursesDataState by viewModel.dashboard.collectAsState() - - //The course composable needs the serverUrl to build the correct url to fetch the course icon from. - val serverUrl by viewModel.serverUrl.collectAsState() - //The server wants an authorization token to send the course icon. - val authToken by viewModel.authToken.collectAsState() - - val topAppBarState = rememberTopAppBarState() - - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( - topAppBarState - ) - - val shouldDisplayBetaDialog by betaHintService.shouldShowBetaHint.collectAsState(initial = false) - var displayBetaDialog by rememberSaveable { mutableStateOf(false) } - - // Trigger the dialog if service sets value to true - LaunchedEffect(shouldDisplayBetaDialog) { - if (shouldDisplayBetaDialog) displayBetaDialog = true - } - - Scaffold( - modifier = modifier.then(Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)), - topBar = { - TopAppBar( - title = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = stringResource(id = R.string.course_overview_title), - maxLines = 1 - ) - - if (isBeta) { - Text( - modifier = Modifier - .border( - 1.dp, - color = MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(percent = 50) - ) - .padding(horizontal = 8.dp), - text = stringResource(id = R.string.dashboard_title_beta), - color = MaterialTheme.colorScheme.outline, - maxLines = 1 - ) - } - } - }, - actions = { - IconButton(onClick = viewModel::requestReloadDashboard) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null - ) - } - - IconButton(onClick = onClickRegisterForCourse) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.course_overview_action_register) - ) - } - - IconButton(onClick = onOpenSettings) { - Icon(imageVector = Icons.Default.Settings, contentDescription = null) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { padding -> - BasicDataStateUi( - modifier = Modifier - .fillMaxSize() - .padding(padding), - dataState = coursesDataState, - loadingText = stringResource(id = R.string.courses_loading_loading), - failureText = stringResource(id = R.string.courses_loading_failure), - retryButtonText = stringResource(id = R.string.courses_loading_try_again), - onClickRetry = viewModel::requestReloadDashboard - ) { dashboard: Dashboard -> - if (dashboard.courses.isEmpty()) { - DashboardEmpty( - modifier = Modifier.fillMaxSize(), - onClickSignup = onClickRegisterForCourse - ) - } else { - CourseList( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp) - .testTag(TEST_TAG_COURSE_LIST), - courses = dashboard.courses, - serverUrl = serverUrl, - authorizationToken = authToken, - onClickOnCourse = { course -> onViewCourse(course.id ?: 0L) } - ) - } - } - } - - if (displayBetaDialog) { - val scope = rememberCoroutineScope() - - BetaHintDialog { dismissPermanently -> - if (dismissPermanently) { - scope.launch { - betaHintService.dismissBetaHintPermanently() - - displayBetaDialog = false - } - } else { - displayBetaDialog = false - } - } - } -} - -@Composable -private fun BetaHintDialog( - dismiss: (dismissPermanently: Boolean) -> Unit -) { - var isDismissPersistentlyChecked by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = { dismiss(false) }, - title = { Text(text = stringResource(id = R.string.dashboard_dialog_beta_title)) }, - text = { - Column( - modifier = Modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(text = stringResource(id = R.string.dashboard_dialog_beta_message)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - role = Role.Checkbox, - onClick = { isDismissPersistentlyChecked = !isDismissPersistentlyChecked } - ), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - modifier = Modifier, - checked = isDismissPersistentlyChecked, - onCheckedChange = { isDismissPersistentlyChecked = it } - ) - - Text(text = stringResource(id = R.string.dashboard_dialog_beta_do_not_show_again)) - } - } - }, - confirmButton = { - TextButton( - onClick = { dismiss(isDismissPersistentlyChecked) } - ) { - Text(text = stringResource(id = R.string.dashboard_dialog_beta_positive)) - } - } - ) -} - -/** - * Displays a lazy list of all the courses supplied. - */ -@Composable -private fun CourseList( - modifier: Modifier, - courses: List, - serverUrl: String, - authorizationToken: String, - onClickOnCourse: (Course) -> Unit -) { - CourseItemGrid( - modifier = modifier, - courses = courses, - ) { dashboardCourse, courseItemModifier, isCompact -> - CourseItem( - modifier = courseItemModifier.testTag(testTagForCourse(dashboardCourse.course.id!!)), - courseWithScore = dashboardCourse, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = { onClickOnCourse(dashboardCourse.course) }, - isCompact = isCompact - ) - } -} - -/** - * Displays course icon, title and description in a Material Design Card. - */ -@Composable -fun CourseItem( - modifier: Modifier, - isCompact: Boolean, - courseWithScore: CourseWithScore, - serverUrl: String, - authorizationToken: String, - onClick: () -> Unit -) { - val currentPoints = courseWithScore.totalScores.studentScores.absoluteScore - val maxPoints = courseWithScore.totalScores.maxPoints - - val currentPointsFormatted = remember(currentPoints) { - CoursePointsDecimalFormat.format(currentPoints) - } - val maxPointsFormatted = remember(maxPoints) { - CoursePointsDecimalFormat.format(maxPoints) - } - - val progress = if (maxPoints == 0f) 0f else currentPoints / maxPoints - - val progressPercentFormatted = remember(progress) { - DecimalFormat.getPercentInstance().format(progress) - } - - if (isCompact) { - CompactCourseItemHeader( - modifier = modifier, - course = courseWithScore.course, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = onClick, - compactCourseHeaderViewMode = CompactCourseHeaderViewMode.EXERCISE_AND_LECTURE_COUNT, - content = { - Divider() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LinearProgressIndicator( - modifier = Modifier.weight(1f), - progress = progress, - trackColor = MaterialTheme.colorScheme.onPrimary - ) - - CourseProgressText( - modifier = Modifier, - currentPointsFormatted = currentPointsFormatted, - maxPointsFormatted = maxPointsFormatted, - progressPercentFormatted = progressPercentFormatted - ) - } - } - ) - } else { - ExpandedCourseItemHeader( - modifier = modifier, - course = courseWithScore.course, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = onClick, - content = { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) - ) { - CircularCourseProgress( - modifier = Modifier - .fillMaxSize(0.8f) - .align(Alignment.Center), - progress = progress, - currentPointsFormatted = currentPointsFormatted, - maxPointsFormatted = maxPointsFormatted, - progressPercentFormatted = progressPercentFormatted - ) - } - - CourseExerciseAndLectureCount( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 8.dp), - exerciseCount = courseWithScore.course.exercises.size, - lectureCount = courseWithScore.course.lectures.size, - textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), - alignment = Alignment.CenterHorizontally - ) - }, - rightHeaderContent = { } - ) - } -} - -@Composable -private fun CircularCourseProgress( - modifier: Modifier, - progress: Float, - currentPointsFormatted: String, - maxPointsFormatted: String, - progressPercentFormatted: String -) { - BoxWithConstraints(modifier = modifier) { - val progressBarWidthDp = min(24.dp, maxWidth * 0.1f) - val progressBarWidth = with(LocalDensity.current) { progressBarWidthDp.toPx() } - - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(progressBarWidthDp) - ) { - drawArc( - color = Color.Green, - startAngle = 180f, - sweepAngle = 360f * progress, - useCenter = false, - style = Stroke(width = progressBarWidth) - ) - - drawArc( - color = Color.Red, - startAngle = 180f + 360f * progress, - sweepAngle = 360f * (1f - progress), - useCenter = false, - style = Stroke(width = progressBarWidth) - ) - } - - val (percentFontSize, ptsFontSize) = with(LocalDensity.current) { - val availableSpace = maxHeight - progressBarWidthDp * 2 - (availableSpace * 0.2f).toSp() to (availableSpace * 0.1f).toSp() - } - - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource( - id = R.string.course_overview_course_progress_percentage, - progressPercentFormatted - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - fontSize = percentFontSize, - fontWeight = FontWeight.Normal - ) - - Text( - text = stringResource( - id = R.string.course_overview_course_progress_pts, - currentPointsFormatted, - maxPointsFormatted - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - fontSize = ptsFontSize, - fontWeight = FontWeight.Bold - ) - } - } -} - -@Composable -private fun CourseProgressText( - modifier: Modifier, - currentPointsFormatted: String, - maxPointsFormatted: String, - progressPercentFormatted: String -) { - Text( - modifier = modifier, - text = stringResource( - id = R.string.course_overview_course_progress, - currentPointsFormatted, - maxPointsFormatted, - progressPercentFormatted - ), - fontSize = 14.sp - ) -} - -@Composable -private fun DashboardEmpty(modifier: Modifier, onClickSignup: () -> Unit) { - Box(modifier = modifier) { - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.courses_empty_text), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Button(onClick = onClickSignup) { - Text(text = stringResource(id = R.string.courses_empty_register_now_button)) - } - } - } -} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt index 1a229c804..82e9061d2 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt @@ -2,8 +2,11 @@ package de.tum.informatics.www1.artemis.native_app.feature.dashboard import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.DashboardService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.BetaHintServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.DashboardServiceImpl +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.SurveyHintServiceImpl +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -11,4 +14,5 @@ val dashboardModule = module { viewModel { CourseOverviewViewModel(get(), get(), get(), get()) } single { DashboardServiceImpl(get()) } single { BetaHintServiceImpl(get()) } + single { SurveyHintServiceImpl(get()) } } \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt new file mode 100644 index 000000000..ef8f2ebb4 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt @@ -0,0 +1,10 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service + +import kotlinx.coroutines.flow.Flow + +interface SurveyHintService { + + val shouldShowSurveyHint: Flow + + suspend fun dismissSurveyHintPermanently() +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt index f812625c2..87935d6c9 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt @@ -1,11 +1,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl import android.content.Context -import androidx.datastore.core.DataMigration -import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import de.tum.informatics.www1.artemis.native_app.feature.dashboard.BuildConfig import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService @@ -22,7 +19,11 @@ class BetaHintServiceImpl(private val context: Context) : BetaHintService { private val Context.storage by preferencesDataStore(DATA_STORE_KEY) - override val shouldShowBetaHint: Flow = context.storage.data.map { it[KEY_DISMISSED] ?: false }.map { !it } + private val isBetaHintDismissed: Flow = context.storage.data.map { it[KEY_DISMISSED] ?: false } + + override val shouldShowBetaHint: Flow = isBetaHintDismissed.map { dismissed -> + BuildConfig.isBeta && !dismissed + } override suspend fun dismissBetaHintPermanently() { context.storage.edit { data -> diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt new file mode 100644 index 000000000..07c9b9aa3 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt @@ -0,0 +1,41 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.LocalDate + + +private val SURVEY_START_DATE = LocalDate.of(2024, 11, 28) +private val SURVEY_END_DATE = LocalDate.of(2024, 12, 16) + +class SurveyHintServiceImpl( + private val context: Context, +) : SurveyHintService { + + private companion object { + private const val DATA_STORE_KEY = "survey_hint_store" + + private val KEY_SHOW_SURVEY = booleanPreferencesKey("showSurvey1") // Change this to "showSurvey2" for the second survey + } + + private val Context.storage by preferencesDataStore(DATA_STORE_KEY) + + override val shouldShowSurveyHint: Flow = context.storage.data + .map { it[KEY_SHOW_SURVEY] ?: isSurveyActive() } + + private fun isSurveyActive(): Boolean { + val currentDate = LocalDate.now() + return currentDate.isAfter(SURVEY_START_DATE) && currentDate.isBefore(SURVEY_END_DATE) + } + + override suspend fun dismissSurveyHintPermanently() { + context.storage.edit { data -> + data[KEY_SHOW_SURVEY] = false + } + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt new file mode 100644 index 000000000..af47ab7b7 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt @@ -0,0 +1,79 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R + +@Composable +fun BetaHintDialog( + dismiss: (dismissPermanently: Boolean) -> Unit +) { + var isDismissPersistentlyChecked by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { dismiss(false) }, + title = { Text(text = stringResource(id = R.string.dashboard_dialog_beta_title)) }, + text = { + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = stringResource(id = R.string.dashboard_dialog_beta_message)) + + DoNotShowAgainCheckBox( + isChecked = isDismissPersistentlyChecked, + onCheckedChange = { isDismissPersistentlyChecked = it } + ) + } + }, + confirmButton = { + TextButton( + onClick = { dismiss(isDismissPersistentlyChecked) } + ) { + Text(text = stringResource(id = R.string.dashboard_dialog_beta_positive)) + } + } + ) +} + +@Composable +fun DoNotShowAgainCheckBox( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + role = Role.Checkbox, + onClick = { onCheckedChange(!isChecked) } + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier, + checked = isChecked, + onCheckedChange = onCheckedChange + ) + + Text(text = stringResource(id = R.string.dashboard_dialog_beta_do_not_show_again)) + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt new file mode 100644 index 000000000..4314375fd --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt @@ -0,0 +1,256 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Divider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseHeaderViewMode +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseItemHeader +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseExerciseAndLectureCount +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseItemGrid +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.ExpandedCourseItemHeader +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.CoursePointsDecimalFormat +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import java.text.DecimalFormat + +/** + * Displays a lazy list of all the courses supplied. + */ +@Composable +fun CourseList( + modifier: Modifier, + courses: List, + serverUrl: String, + authorizationToken: String, + onClickOnCourse: (Course) -> Unit +) { + CourseItemGrid( + modifier = modifier, + courses = courses, + ) { dashboardCourse, courseItemModifier, isCompact -> + CourseItem( + modifier = courseItemModifier.testTag(testTagForCourse(dashboardCourse.course.id!!)), + courseWithScore = dashboardCourse, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = { onClickOnCourse(dashboardCourse.course) }, + isCompact = isCompact + ) + } +} + +/** + * Displays course icon, title and description in a Material Design Card. + */ +@Composable +fun CourseItem( + modifier: Modifier, + isCompact: Boolean, + courseWithScore: CourseWithScore, + serverUrl: String, + authorizationToken: String, + onClick: () -> Unit +) { + val currentPoints = courseWithScore.totalScores.studentScores.absoluteScore + val maxPoints = courseWithScore.totalScores.maxPoints + + val currentPointsFormatted = remember(currentPoints) { + CoursePointsDecimalFormat.format(currentPoints) + } + val maxPointsFormatted = remember(maxPoints) { + CoursePointsDecimalFormat.format(maxPoints) + } + + val progress = if (maxPoints == 0f) 0f else currentPoints / maxPoints + + val progressPercentFormatted = remember(progress) { + DecimalFormat.getPercentInstance().format(progress) + } + + if (isCompact) { + CompactCourseItemHeader( + modifier = modifier, + course = courseWithScore.course, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = onClick, + compactCourseHeaderViewMode = CompactCourseHeaderViewMode.EXERCISE_AND_LECTURE_COUNT, + content = { + Divider() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier.weight(1f), + progress = progress, + trackColor = MaterialTheme.colorScheme.onPrimary + ) + + CourseProgressText( + modifier = Modifier, + currentPointsFormatted = currentPointsFormatted, + maxPointsFormatted = maxPointsFormatted, + progressPercentFormatted = progressPercentFormatted + ) + } + } + ) + } else { + ExpandedCourseItemHeader( + modifier = modifier, + course = courseWithScore.course, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = onClick, + content = { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + ) { + CircularCourseProgress( + modifier = Modifier + .fillMaxSize(0.8f) + .align(Alignment.Center), + progress = progress, + currentPointsFormatted = currentPointsFormatted, + maxPointsFormatted = maxPointsFormatted, + progressPercentFormatted = progressPercentFormatted + ) + } + + CourseExerciseAndLectureCount( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + exerciseCount = courseWithScore.course.exercises.size, + lectureCount = courseWithScore.course.lectures.size, + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), + alignment = Alignment.CenterHorizontally + ) + }, + rightHeaderContent = { } + ) + } +} + + +@Composable +private fun CircularCourseProgress( + modifier: Modifier, + progress: Float, + currentPointsFormatted: String, + maxPointsFormatted: String, + progressPercentFormatted: String +) { + BoxWithConstraints(modifier = modifier) { + val progressBarWidthDp = min(24.dp, maxWidth * 0.1f) + val progressBarWidth = with(LocalDensity.current) { progressBarWidthDp.toPx() } + + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(progressBarWidthDp) + ) { + drawArc( + color = Color.Green, + startAngle = 180f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = progressBarWidth) + ) + + drawArc( + color = Color.Red, + startAngle = 180f + 360f * progress, + sweepAngle = 360f * (1f - progress), + useCenter = false, + style = Stroke(width = progressBarWidth) + ) + } + + val (percentFontSize, ptsFontSize) = with(LocalDensity.current) { + val availableSpace = maxHeight - progressBarWidthDp * 2 + (availableSpace * 0.2f).toSp() to (availableSpace * 0.1f).toSp() + } + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource( + id = R.string.course_overview_course_progress_percentage, + progressPercentFormatted + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + fontSize = percentFontSize, + fontWeight = FontWeight.Normal + ) + + Text( + text = stringResource( + id = R.string.course_overview_course_progress_pts, + currentPointsFormatted, + maxPointsFormatted + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + fontSize = ptsFontSize, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun CourseProgressText( + modifier: Modifier, + currentPointsFormatted: String, + maxPointsFormatted: String, + progressPercentFormatted: String +) { + Text( + modifier = modifier, + text = stringResource( + id = R.string.course_overview_course_progress, + currentPointsFormatted, + maxPointsFormatted, + progressPercentFormatted + ), + fontSize = 14.sp + ) +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt similarity index 98% rename from feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt rename to feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt index 64997b67b..9d0948f6e 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.feature.dashboard +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,7 +15,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.Dash import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt new file mode 100644 index 000000000..8bd4bfa86 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt @@ -0,0 +1,255 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.border +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.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import de.tum.informatics.www1.artemis.native_app.core.model.Dashboard +import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.BuildConfig +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.getViewModel +import org.koin.compose.koinInject + +internal const val TEST_TAG_COURSE_LIST = "TEST_TAG_COURSE_LIST" + +internal fun testTagForCourse(courseId: Long) = "Course$courseId" + +@Serializable +data object DashboardScreen + +fun NavController.navigateToDashboard(builder: NavOptionsBuilder.() -> Unit) { + navigate(DashboardScreen, builder) +} + +fun NavGraphBuilder.dashboard( + onOpenSettings: () -> Unit, + onClickRegisterForCourse: () -> Unit, + onViewCourse: (courseId: Long) -> Unit +) { + composable { + CoursesOverview( + modifier = Modifier.fillMaxSize(), + viewModel = getViewModel(), + onOpenSettings = onOpenSettings, + onClickRegisterForCourse = onClickRegisterForCourse, + onViewCourse = onViewCourse + ) + } +} + +/** + * Displays the Course Overview screen. + * Uses Scaffold to display a Material Design TopAppBar. + */ +@Composable +internal fun CoursesOverview( + modifier: Modifier, + viewModel: CourseOverviewViewModel, + onOpenSettings: () -> Unit, + onClickRegisterForCourse: () -> Unit, + onViewCourse: (courseId: Long) -> Unit, + isBeta: Boolean = BuildConfig.isBeta, + betaHintService: BetaHintService = koinInject(), + surveyHintService: SurveyHintService = koinInject() +) { + val coursesDataState by viewModel.dashboard.collectAsState() + + //The course composable needs the serverUrl to build the correct url to fetch the course icon from. + val serverUrl by viewModel.serverUrl.collectAsState() + //The server wants an authorization token to send the course icon. + val authToken by viewModel.authToken.collectAsState() + + val topAppBarState = rememberTopAppBarState() + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( + topAppBarState + ) + + val shouldDisplayBetaDialog by betaHintService.shouldShowBetaHint.collectAsState(initial = false) + var displayBetaDialog by rememberSaveable { mutableStateOf(false) } + + // Trigger the dialog if service sets value to true + LaunchedEffect(shouldDisplayBetaDialog) { + if (shouldDisplayBetaDialog) displayBetaDialog = true + } + + Scaffold( + modifier = modifier.then(Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)), + topBar = { + TopAppBar( + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = stringResource(id = R.string.course_overview_title), + maxLines = 1 + ) + + if (isBeta) { + Text( + modifier = Modifier + .border( + 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(percent = 50) + ) + .padding(horizontal = 8.dp), + text = stringResource(id = R.string.dashboard_title_beta), + color = MaterialTheme.colorScheme.outline, + maxLines = 1 + ) + } + } + }, + actions = { + IconButton(onClick = viewModel::requestReloadDashboard) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + } + + IconButton(onClick = onClickRegisterForCourse) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.course_overview_action_register) + ) + } + + IconButton(onClick = onOpenSettings) { + Icon(imageVector = Icons.Default.Settings, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(top = padding.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars) + ) { + SurveyHint( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + surveyHintService = surveyHintService + ) + + BasicDataStateUi( + modifier = Modifier.fillMaxSize(), + dataState = coursesDataState, + loadingText = stringResource(id = R.string.courses_loading_loading), + failureText = stringResource(id = R.string.courses_loading_failure), + retryButtonText = stringResource(id = R.string.courses_loading_try_again), + onClickRetry = viewModel::requestReloadDashboard + ) { dashboard: Dashboard -> + if (dashboard.courses.isEmpty()) { + DashboardEmpty( + modifier = Modifier.fillMaxSize(), + onClickSignup = onClickRegisterForCourse + ) + } else { + CourseList( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .testTag(TEST_TAG_COURSE_LIST), + courses = dashboard.courses, + serverUrl = serverUrl, + authorizationToken = authToken, + onClickOnCourse = { course -> onViewCourse(course.id ?: 0L) } + ) + } + } + } + } + + if (displayBetaDialog) { + val scope = rememberCoroutineScope() + + BetaHintDialog { dismissPermanently -> + if (dismissPermanently) { + scope.launch { + betaHintService.dismissBetaHintPermanently() + + displayBetaDialog = false + } + } else { + displayBetaDialog = false + } + } + } +} + + + +@Composable +private fun DashboardEmpty(modifier: Modifier, onClickSignup: () -> Unit) { + Box(modifier = modifier) { + Column( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.courses_empty_text), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Button(onClick = onClickSignup) { + Text(text = stringResource(id = R.string.courses_empty_register_now_button)) + } + } + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt new file mode 100644 index 000000000..246f48113 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt @@ -0,0 +1,186 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val SURVEY_URL = "https://survey.ase.in.tum.de/index.php/767298?lang=en" + +@Composable +fun SurveyHint( + modifier: Modifier = Modifier, + surveyHintService: SurveyHintService +) { + val shouldDisplaySurveyHint by surveyHintService.shouldShowSurveyHint.collectAsState(initial = false) + var displaySurveyHint by rememberSaveable { mutableStateOf(false) } + var showSurveyDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(shouldDisplaySurveyHint) { + if (shouldDisplaySurveyHint) displaySurveyHint = true + } + + AnimatedVisibility(displaySurveyHint) { + SurveyHintImpl( + modifier = modifier, + onClick = { showSurveyDialog = true } + ) + } + + val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() + if (showSurveyDialog) { + SurveyDialog( + onClose = { participate -> + if (participate) { + scope.launch { + uriHandler.openUri(SURVEY_URL) + surveyHintService.dismissSurveyHintPermanently() + + delay(2000) // Wait for the survey to open before hiding the hint + displaySurveyHint = false + } + } + + showSurveyDialog = false + } + ) + } +} + +@Composable +private fun SurveyHintImpl( + modifier: Modifier, + onClick: () -> Unit +) { + Card( + modifier = modifier, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.survey_hint_text), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + + +@Composable +private fun SurveyDialog( + modifier: Modifier = Modifier, + onClose: (participate: Boolean) -> Unit +) { + + AlertDialog( + onDismissRequest = { onClose(false) }, + title = { Text(text = stringResource(R.string.survey_dialog_title)) }, + text = { + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Text(text = stringResource(R.string.survey_dialog_do_you_have_time)) + + Icon( + modifier = Modifier + .size(72.dp) + .align(Alignment.CenterHorizontally) + , + imageVector = Icons.Outlined.Feedback, + contentDescription = null, + ) + + Text(stringResource(R.string.survey_dialog_thank_you)) + } + }, + confirmButton = { + Button( + onClick = { onClose(true) } + ) { + Text(text = stringResource(R.string.survey_dialog_button_participate)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp) + ) + } + }, + dismissButton = { + TextButton( + onClick = { onClose(false) } + ) { + Text(text = stringResource(R.string.survey_dialog_button_not_now)) + } + } + ) +} + +@Preview +@Composable +fun SurveyHintPreview() { + SurveyHintImpl( + modifier = Modifier, + onClick = {} + ) +} + +@Preview +@Composable +fun SurveyDialogPreview() { + SurveyDialog(onClose = {}) +} \ No newline at end of file diff --git a/feature/dashboard/src/main/res/values/survey_hint_strings.xml b/feature/dashboard/src/main/res/values/survey_hint_strings.xml new file mode 100644 index 000000000..b57eceac7 --- /dev/null +++ b/feature/dashboard/src/main/res/values/survey_hint_strings.xml @@ -0,0 +1,9 @@ + + + Survey available + We need your help! + Do you have 5–10 minutes to take a short survey and help us improve the app? + Thank you for your consideration! + Participate + Not now + \ No newline at end of file diff --git a/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt b/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt index 487d3d919..14580a78a 100644 --- a/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt +++ b/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt @@ -10,17 +10,22 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToKey import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CoursesOverview +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.TEST_TAG_COURSE_LIST +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.testTagForCourse import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.experimental.categories.Category @@ -30,6 +35,10 @@ import org.koin.test.KoinTestRule import org.koin.test.get import org.robolectric.RobolectricTestRunner +@Ignore("There seems to be a problem related to the docker files, where the mysql database is not " + + "reset properly. This causes the newly created courses by this E2e test to pile up and " + + "causes the server to take very long to return all the courses. This results in a timeout." + + "Issue: https://github.com/ls1intum/artemis-android/issues/169") @OptIn(ExperimentalTestApi::class) @Category(EndToEndTest::class) @RunWith(RobolectricTestRunner::class) 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 623108980..bd5798234 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,7 +1,6 @@ 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 @@ -14,28 +13,26 @@ import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.data.orNull import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi import de.tum.informatics.www1.artemis.native_app.core.ui.common.EmptyDataStateUi import de.tum.informatics.www1.artemis.native_app.core.ui.generateLinks +import de.tum.informatics.www1.artemis.native_app.core.ui.navigation.KSerializableNavType import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.ExerciseScreen import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.participate.textexercise.TextExerciseParticipationScreen import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.viewresult.ViewResultScreen import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf -import java.net.URLDecoder -import java.net.URLEncoder +import kotlin.reflect.typeOf object ExerciseViewDestination { const val EXERCISE_VIEW_ROUTE = "exercise/{exerciseId}/{viewMode}" @@ -46,28 +43,37 @@ object ExerciseViewDestination { const val REQUIRE_RELOAD_KEY = "requireReload" } -/** - * Display the exercise view - */ -private const val NESTED_HOME_DESTINATION = "home" +@Serializable +sealed interface ExerciseViewUiNestedNavigation { + + /** + * Display the exercise view + */ + @Serializable + data object Home : ExerciseViewUiNestedNavigation -/** - * View the latest result - */ -private const val NESTED_EXERCISE_RESULT_DESTINATION = "view_result" + /** + * View the latest result + */ + @Serializable + data object Result : ExerciseViewUiNestedNavigation -private const val NESTED_PARTICIPATE_TEXT_EXERCISE_DESTINATION = - "participate/text_exercise/{participationId}" + @Serializable + data class ParticipateTextExercise(val participationId: Long) : ExerciseViewUiNestedNavigation +} + +@Serializable +data class ExerciseViewUi( + val exerciseId: Long, + val viewMode: ExerciseViewMode = ExerciseViewMode.Overview, +) fun NavController.navigateToExercise( exerciseId: Long, viewMode: ExerciseViewMode, builder: NavOptionsBuilder.() -> Unit ) { - val viewModeAsString = - URLEncoder.encode(Json.encodeToString(ExerciseViewMode.serializer(), viewMode), "UTF-8") - - navigate("exercise/$exerciseId/$viewModeAsString", builder) + navigate(ExerciseViewUi(exerciseId, viewMode), builder) } fun NavGraphBuilder.exercise( @@ -76,18 +82,12 @@ fun NavGraphBuilder.exercise( onParticipateInQuiz: (courseId: Long, exerciseId: Long, isPractice: Boolean) -> Unit, onClickViewQuizResults: (courseId: Long, exerciseId: Long) -> Unit ) { - composable( - route = ExerciseViewDestination.EXERCISE_VIEW_ROUTE, - arguments = listOf( - navArgument("exerciseId") { - type = NavType.LongType - nullable = false - }, - navArgument("viewMode") { - type = NavType.StringType - defaultValue = - Json.encodeToString(ExerciseViewMode.serializer(), ExerciseViewMode.Overview) - } + composable( + typeMap = mapOf( + typeOf() to KSerializableNavType( + isNullableAllowed = false, + ExerciseViewMode.Overview.serializer() + ) ), deepLinks = listOf( navDeepLink { @@ -95,13 +95,10 @@ fun NavGraphBuilder.exercise( } ) + generateLinks("courses/{courseId}/exercises/{exerciseId}") ) { backStackEntry -> - val exerciseId = - backStackEntry.arguments?.getLong("exerciseId") - checkNotNull(exerciseId) + val route: ExerciseViewUi = backStackEntry.toRoute() - val viewMode: ExerciseViewMode = backStackEntry.arguments?.getString("viewMode")?.let { - Json.decodeFromString(URLDecoder.decode(it, "UTF-8")) - } ?: ExerciseViewMode.Overview + val exerciseId = route.exerciseId + val viewMode: ExerciseViewMode = route.viewMode val exerciseViewModel = koinViewModel { parametersOf(exerciseId) } @@ -116,10 +113,13 @@ fun NavGraphBuilder.exercise( } } - val startDestination = when (viewMode) { - ExerciseViewMode.Overview -> NESTED_HOME_DESTINATION - is ExerciseViewMode.TextParticipation -> NESTED_PARTICIPATE_TEXT_EXERCISE_DESTINATION - ExerciseViewMode.ViewResult -> NESTED_EXERCISE_RESULT_DESTINATION + val startDestination: ExerciseViewUiNestedNavigation = when (viewMode) { + ExerciseViewMode.Overview -> ExerciseViewUiNestedNavigation.Home + is ExerciseViewMode.TextParticipation -> ExerciseViewUiNestedNavigation.ParticipateTextExercise( + participationId = viewMode.participationId + ) + + ExerciseViewMode.ViewResult -> ExerciseViewUiNestedNavigation.Result } val nestedNavigateUp: () -> Unit = { @@ -131,17 +131,21 @@ fun NavGraphBuilder.exercise( } NavHost(navController = nestedNavController, startDestination = startDestination) { - composable(NESTED_HOME_DESTINATION) { + composable { ExerciseScreen( modifier = Modifier.fillMaxSize(), viewModel = exerciseViewModel, onNavigateBack = nestedNavigateUp, onViewResult = { - nestedNavController.navigate(NESTED_EXERCISE_RESULT_DESTINATION) + nestedNavController.navigate(ExerciseViewUiNestedNavigation.Result) }, navController = navController, onViewTextExerciseParticipationScreen = { participationId -> - nestedNavController.navigate(createTextParticipationRoute(participationId)) + nestedNavController.navigate( + ExerciseViewUiNestedNavigation.ParticipateTextExercise( + participationId + ) + ) }, onParticipateInQuiz = { courseId, isPractice -> onParticipateInQuiz(courseId, exerciseId, isPractice) @@ -152,7 +156,7 @@ fun NavGraphBuilder.exercise( ) } - composable(NESTED_EXERCISE_RESULT_DESTINATION) { + composable { ViewResultScreen( modifier = Modifier.fillMaxSize(), viewModel = exerciseViewModel, @@ -160,21 +164,11 @@ fun NavGraphBuilder.exercise( ) } - composable( - NESTED_PARTICIPATE_TEXT_EXERCISE_DESTINATION, - arguments = listOf( - navArgument( - "participationId" - ) { - type = NavType.LongType - if (viewMode is ExerciseViewMode.TextParticipation) { - defaultValue = viewMode.participationId - } else nullable = false - } - ) - ) { backStackEntry -> - val participationId: Long = backStackEntry.arguments?.getLong("participationId") - ?: throw IllegalArgumentException() + composable { backStackEntry -> + val nestedRoute: ExerciseViewUiNestedNavigation.ParticipateTextExercise = + backStackEntry.toRoute() + + val participationId: Long = nestedRoute.participationId val exerciseDataState by exerciseViewModel.exerciseDataState.collectAsState() @@ -191,9 +185,6 @@ fun NavGraphBuilder.exercise( } } -private fun createTextParticipationRoute(participationId: Long) = - "participate/text_exercise/$participationId" - @Composable internal fun ExerciseDataStateUi( modifier: Modifier, @@ -216,7 +207,7 @@ internal fun ExerciseDataStateUi( sealed interface ExerciseViewMode { @Serializable @SerialName("overview") - object Overview : ExerciseViewMode + data object Overview : ExerciseViewMode @Serializable @SerialName("text_participation") @@ -224,7 +215,7 @@ sealed interface ExerciseViewMode { @Serializable @SerialName("view_result") - object ViewResult : ExerciseViewMode + data object ViewResult : ExerciseViewMode } internal val DataState.courseId: Long? diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreen.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreen.kt index 625fd6ea7..8c6168f81 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreen.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreen.kt @@ -8,12 +8,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf @@ -21,7 +18,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -29,7 +25,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.google.accompanist.web.WebViewState -import de.tum.informatics.www1.artemis.native_app.core.data.isSuccess import de.tum.informatics.www1.artemis.native_app.core.data.orNull import de.tum.informatics.www1.artemis.native_app.core.model.exercise.ProgrammingExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.latestParticipation @@ -42,10 +37,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.getProble import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.canDisplayMetisOnDisplaySide import kotlinx.coroutines.Deferred -import me.onebone.toolbar.CollapsingToolbarScaffold import me.onebone.toolbar.ExperimentalToolbarApi -import me.onebone.toolbar.ScrollStrategy -import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState val LocalExerciseScreenFloatingActionButton = compositionLocalOf { ExerciseScreenFloatingActionButtonProvider() } @@ -126,59 +118,8 @@ internal fun ExerciseScreen( metisContentRatio = METIS_RATIO ) - // Only collapse toolbar if otherwise too much of the screen would be occupied by it - val isToolbarCollapsible = windowSizeClass.heightSizeClass < WindowHeightSizeClass.Expanded - val isLongToolbar = windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium - // Keep state when device configuration changes - val body = @Composable { modifier: Modifier -> - val onParticipateInQuizDelegate = { isPractice: Boolean -> - courseId?.let { - onParticipateInQuiz(it, isPractice) - } - } - - val actions = remember( - courseId, - onViewTextExerciseParticipationScreen, - onParticipateInQuizDelegate, - onViewResult, - viewModel - ) { - ExerciseActions( - onClickStartTextExercise = { - startExerciseParticipationDeferred = viewModel.startExercise() - }, - onClickPracticeQuiz = { onParticipateInQuizDelegate(true) }, - onClickOpenQuiz = { onParticipateInQuizDelegate(false) }, - onClickStartQuiz = { onParticipateInQuizDelegate(false) }, - onClickViewResult = onViewResult, - onClickOpenTextExercise = onViewTextExerciseParticipationScreen, - onClickViewQuizResults = { - courseId?.let { - onClickViewQuizResults(it) - } - } - ) - } - - ExerciseScreenBody( - modifier = modifier, - exerciseDataState = exerciseDataState, - displayCommunicationOnSide = displayCommunicationOnSide, - navController = navController, - metisContext = metisContext, - actions = actions, - webViewState = webViewState, - setWebView = { savedWebView = it }, - webView = savedWebView, - onClickRetry = viewModel::requestReloadExercise, - serverUrl = serverUrl, - authToken = authToken - ) - } - val currentExerciseScreenFloatingActionButton = remember { ExerciseScreenFloatingActionButtonProvider() } @@ -197,65 +138,66 @@ internal fun ExerciseScreen( } } - if (isToolbarCollapsible) { - val state = rememberCollapsingToolbarScaffoldState() - // On the first load, we need to expand the toolbar, as otherwise content may be hidden - var hasExecutedInitialExpand by rememberSaveable { mutableStateOf(false) } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + ExerciseScreenTopAppBar( + modifier = Modifier.fillMaxWidth(), + onNavigateBack = onNavigateBack, + exerciseDataState = exerciseDataState, + onRequestReloadExercise = viewModel::requestReloadExercise + ) + }, + floatingActionButton = floatingActionButton + ) { padding -> - LaunchedEffect(exerciseDataState, hasExecutedInitialExpand) { - if (exerciseDataState.isSuccess && !hasExecutedInitialExpand) { - state.toolbarState.expand() - hasExecutedInitialExpand = true + val onParticipateInQuizDelegate = { isPractice: Boolean -> + courseId?.let { + onParticipateInQuiz(it, isPractice) } } - Scaffold( - modifier = Modifier.fillMaxSize(), - floatingActionButton = floatingActionButton - ) { padding -> - CollapsingToolbarScaffold( - modifier = Modifier - .fillMaxSize() - .padding(padding), - state = state, - toolbar = { - ExerciseScreenCollapsingTopBar( - modifier = Modifier.fillMaxWidth(), - state = state, - exercise = exerciseDataState, - onNavigateBack = onNavigateBack, - onRequestRefresh = viewModel::requestReloadExercise, - isLongToolbar = isLongToolbar - ) + val actions = remember( + courseId, + onViewTextExerciseParticipationScreen, + onParticipateInQuizDelegate, + onViewResult, + viewModel + ) { + ExerciseActions( + onClickStartTextExercise = { + startExerciseParticipationDeferred = viewModel.startExercise() }, - scrollStrategy = ScrollStrategy.ExitUntilCollapsed, - body = { - Surface(Modifier.fillMaxSize()) { - body(Modifier.fillMaxSize()) + onClickPracticeQuiz = { onParticipateInQuizDelegate(true) }, + onClickOpenQuiz = { onParticipateInQuizDelegate(false) }, + onClickStartQuiz = { onParticipateInQuizDelegate(false) }, + onClickViewResult = onViewResult, + onClickOpenTextExercise = onViewTextExerciseParticipationScreen, + onClickViewQuizResults = { + courseId?.let { + onClickViewQuizResults(it) } } ) } - } else { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - StaticTopAppBar( - modifier = Modifier.fillMaxWidth(), - onNavigateBack = onNavigateBack, - exerciseDataState = exerciseDataState, - isLongToolbar = isLongToolbar, - onRequestReloadExercise = viewModel::requestReloadExercise - ) - }, - floatingActionButton = floatingActionButton - ) { padding -> - body( - Modifier - .fillMaxSize() - .padding(padding) - ) - } + + ExerciseScreenBody( + modifier = Modifier + .fillMaxSize() + .padding(padding), + exerciseDataState = exerciseDataState, + isLongToolbar = isLongToolbar, + displayCommunicationOnSide = displayCommunicationOnSide, + navController = navController, + metisContext = metisContext, + actions = actions, + webViewState = webViewState, + setWebView = { savedWebView = it }, + webView = savedWebView, + onClickRetry = viewModel::requestReloadExercise, + serverUrl = serverUrl, + authToken = authToken + ) } } } 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 1bd9ba8fd..c55085050 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 @@ -37,6 +37,7 @@ const val METIS_RATIO = 0.3f internal fun ExerciseScreenBody( modifier: Modifier, exerciseDataState: DataState, + isLongToolbar: Boolean, displayCommunicationOnSide: Boolean, navController: NavController, metisContext: MetisContext?, @@ -60,6 +61,7 @@ internal fun ExerciseScreenBody( ExerciseOverviewTab( modifier = modifier, exercise = exercise, + isLongToolbar = isLongToolbar, webViewState = webViewState, setWebView = setWebView, webView = webView, @@ -73,6 +75,7 @@ internal fun ExerciseScreenBody( Modifier .fillMaxSize() .padding(horizontal = 8.dp) + .padding(bottom = 8.dp) ) // Commented out as we may need that code again once we display communications for exercises 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 31c6dba97..b4a28fb04 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 @@ -1,39 +1,20 @@ 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 -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 -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString @@ -41,68 +22,34 @@ 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 androidx.compose.ui.unit.TextUnit import com.google.accompanist.placeholder.material.placeholder import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.data.orNull import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise -import de.tum.informatics.www1.artemis.native_app.core.model.exercise.currentUserPoints -import de.tum.informatics.www1.artemis.native_app.core.ui.common.EmptyDataStateUi -import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime -import de.tum.informatics.www1.artemis.native_app.core.ui.date.hasPassed -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseCategoryChipData -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseCategoryChipRow -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChip -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChipTextHorizontalPadding -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExercisePointsDecimalFormat import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.getExerciseTypeIconPainter -import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R -import kotlinx.datetime.Instant -import me.onebone.toolbar.CollapsingToolbarScaffoldState -import me.onebone.toolbar.CollapsingToolbarScope -/** - * Display a collapsing top app bar with an incollapsible [TopAppBar] and [TopBarExerciseInformation] as the collapsible part in a column. - */ + @Composable -internal fun CollapsingToolbarScope.ExerciseScreenCollapsingTopBar( +internal fun ExerciseScreenTopAppBar( modifier: Modifier, - state: CollapsingToolbarScaffoldState, - isLongToolbar: Boolean, - exercise: DataState, + exerciseDataState: DataState, onNavigateBack: () -> Unit, - onRequestRefresh: () -> Unit + onRequestReloadExercise: () -> Unit ) { - TopAppBar( - modifier = modifier, - title = { - TitleText( - modifier = Modifier.graphicsLayer { alpha = 1f - state.toolbarState.progress }, - exerciseDataState = exercise, - maxLines = 1 - ) - }, - navigationIcon = { - TopAppBarNavigationIcon(onNavigateBack) - }, - actions = { - TopAppBarActions(onRequestRefresh = onRequestRefresh) - } - ) - - TopBarExerciseInformation( - modifier = Modifier - .fillMaxWidth() - .padding(top = 64.dp) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp) - .parallax(0f), - titleTextAlpha = state.toolbarState.progress, - exercise = exercise, - isLongToolbar = isLongToolbar - ) + Column(modifier = modifier) { + TopAppBar( + modifier = Modifier.fillMaxWidth(), + title = { TitleText(modifier = modifier, maxLines = 1, exerciseDataState = exerciseDataState) }, + navigationIcon = { + TopAppBarNavigationIcon(onNavigateBack = onNavigateBack) + }, + actions = { + TopAppBarActions(onRequestRefresh = onRequestReloadExercise) + } + ) + } } @Composable @@ -119,230 +66,6 @@ private fun TopAppBarActions(onRequestRefresh: () -> Unit) { } } -private val placeholderCategoryChips = listOf( - ExerciseCategoryChipData("WWWW", Color.Cyan), - ExerciseCategoryChipData("WWWW", Color.Cyan), - ExerciseCategoryChipData("WWWW", Color.Cyan) -) - -/** - * @param isLongToolbar if the deadline information is displayed on the right side of the toolbar. - * If false, the information is instead displayed in the column - */ -@Composable -internal fun TopBarExerciseInformation( - modifier: Modifier, - titleTextAlpha: Float, - exercise: DataState, - isLongToolbar: Boolean -) { - val dueDate = exercise.bind { it.dueDate }.orElse(null) - val assessmentDueData = exercise.bind { it.assessmentDueDate }.orElse(null) - 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( - dataState = exercise, - otherwise = { - ExerciseCategoryChipRow( - modifier = Modifier - .fillMaxWidth() - .placeholder(true), - chips = placeholderCategoryChips - ) - } - ) { loadedExercise -> - ExerciseCategoryChipRow( - modifier = Modifier.fillMaxWidth(), - exercise = loadedExercise - ) - } - - val currentUserPoints = exercise.bind { exercise -> - exercise.currentUserPoints?.let(ExercisePointsDecimalFormat::format) - }.orElse(null) - val maxPoints = exercise.bind { exercise -> - exercise.maxPoints?.let(ExercisePointsDecimalFormat::format) - }.orElse(null) - - val pointsHintText = when { - currentUserPoints != null && maxPoints != null -> stringResource( - id = R.string.exercise_view_overview_points_reached, - currentUserPoints, - maxPoints - ) - - maxPoints != null -> stringResource( - id = R.string.exercise_view_overview_points_max, - maxPoints - ) - - else -> stringResource(id = R.string.exercise_view_overview_points_none) - } - - Text( - 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 -> - - Column( - modifier = contentModifier, - ) { - - dueDate?.let { - dueDateTopBarTextInformation( - it, - R.string.exercise_view_overview_hint_submission_due_date - ) - } - - assessmentDueData?.let { - dueDateTopBarTextInformation( - it, - 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.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - - Text( - text = "Exercise Details", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .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 - - if (isLongToolbar) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f)) { - exerciseInfoUi() - } - - dueDateColumnUi( - Modifier - .width(IntrinsicSize.Max) - .align(Alignment.Bottom) - ) - } - } else { - Column(modifier = Modifier.fillMaxWidth()) { - exerciseInfoUi() - dueDateColumnUi(Modifier.fillMaxWidth()) - } - } - } -} - -/** - * Text information composable that achieves a table like layout, where the hint is the first column - * and the data is the second column. - */ -@Composable -private fun TopBarTextInformation( - modifier: Modifier, - hintColumnWidth: Int, - hint: String, - dataText: String, - dataColor: Color?, - updateHintColumnWidth: (Int) -> Unit -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - modifier = Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - - val assignedWidth = maxOf(hintColumnWidth, placeable.width) - if (assignedWidth > hintColumnWidth) { - updateHintColumnWidth(assignedWidth) - } - - layout(width = assignedWidth, height = placeable.height) { - placeable.placeRelative(0, 0) - } - }, - text = hint, - style = MaterialTheme.typography.bodyLarge, - ) - - val dataModifier = Modifier - - if (dataColor != null) { - ExerciseInfoChip(modifier = dataModifier, color = dataColor, text = dataText) - } else { - Text( - modifier = dataModifier.padding(horizontal = ExerciseInfoChipTextHorizontalPadding), - text = dataText, - style = MaterialTheme.typography.bodyMedium - ) - } - } -} - -@Composable -private fun getDueDateColor(dueDate: Instant): Color = - if (dueDate.hasPassed()) Color.Red else Color.Green - @Composable private fun TitleText( modifier: Modifier, @@ -352,40 +75,26 @@ private fun TitleText( ) { val fontSize = style.fontSize - val (titleText, inlineContent) = remember(exerciseDataState) { - val text = buildAnnotatedString { - appendInlineContent("icon") - append(" ") - append( - exerciseDataState.bind { it.title }.orNull() - ?: "Exercise name placeholder" - ) - } - - val inlineContent = mapOf( - "icon" to InlineTextContent( - Placeholder( - fontSize, - fontSize, - PlaceholderVerticalAlign.TextCenter - ) - ) { - Icon( - painter = getExerciseTypeIconPainter(exerciseDataState.orNull()), - contentDescription = null - ) - } - ) - - text to inlineContent - } + val (titleText, inlineContent) = rememberTitleTextWithInlineContent(exerciseDataState, fontSize) Text( text = titleText, inlineContent = inlineContent, modifier = modifier .placeholder(exerciseDataState !is DataState.Success) - .semantics { set(SemanticsProperties.Text, listOf(AnnotatedString(exerciseDataState.bind { it.title }.orNull().orEmpty()))) }, + .semantics { + set( + SemanticsProperties.Text, + listOf( + AnnotatedString( + exerciseDataState + .bind { it.title } + .orNull() + .orEmpty() + ) + ) + ) + }, style = style, maxLines = maxLines, overflow = TextOverflow.Ellipsis @@ -393,33 +102,33 @@ private fun TitleText( } @Composable -internal fun StaticTopAppBar( - modifier: Modifier, +private fun rememberTitleTextWithInlineContent( exerciseDataState: DataState, - isLongToolbar: Boolean, - onNavigateBack: () -> Unit, - onRequestReloadExercise: () -> Unit -) { - Column(modifier = modifier) { - TopAppBar( - modifier = Modifier.fillMaxWidth(), - title = { TitleText(modifier = modifier, maxLines = 1, exerciseDataState = exerciseDataState) }, - navigationIcon = { - TopAppBarNavigationIcon(onNavigateBack = onNavigateBack) - }, - actions = { - TopAppBarActions(onRequestRefresh = onRequestReloadExercise) - } - ) - TopBarExerciseInformation( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp) - .border(2.dp, Color.Black, RoundedCornerShape(4.dp)), - titleTextAlpha = 1f, - exercise = exerciseDataState, - isLongToolbar = isLongToolbar + fontSize: TextUnit +) = remember(exerciseDataState) { + val text = buildAnnotatedString { + appendInlineContent("icon") + append(" ") + append( + exerciseDataState.bind { it.title }.orNull() + ?: "Exercise name placeholder" ) } -} \ No newline at end of file + + val inlineContent = mapOf( + "icon" to InlineTextContent( + Placeholder( + fontSize, + fontSize, + PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + painter = getExerciseTypeIconPainter(exerciseDataState.orNull()), + contentDescription = null + ) + } + ) + + text to inlineContent +} 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 b351c303f..037c131d1 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,24 +2,55 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.ove import android.annotation.SuppressLint import android.webkit.WebView +import androidx.annotation.StringRes import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.accompanist.web.WebViewState import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise +import de.tum.informatics.www1.artemis.native_app.core.model.exercise.currentUserPoints +import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime +import de.tum.informatics.www1.artemis.native_app.core.ui.date.hasPassed import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseActions +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseCategoryChipRow +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChip +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExerciseInfoChipTextHorizontalPadding +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.ExercisePointsDecimalFormat import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWebView +import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R +import kotlinx.datetime.Instant + @SuppressLint("SetJavaScriptEnabled") @Composable internal fun ExerciseOverviewTab( modifier: Modifier = Modifier, exercise: Exercise, + isLongToolbar: Boolean, webViewState: WebViewState?, serverUrl: String, authToken: String, @@ -29,11 +60,18 @@ internal fun ExerciseOverviewTab( ) { Column( modifier = modifier - .fillMaxSize() - .background(Color.White), + .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - + ExerciseInformation( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 16.dp) + .border(2.dp, Color.Black, RoundedCornerShape(4.dp)), + exercise = exercise, + isLongToolbar = isLongToolbar + ) ParticipationStatusUi( modifier = Modifier @@ -56,7 +94,7 @@ internal fun ExerciseOverviewTab( ) } else { Text( - text = "No problem statement available.", + text = stringResource(id = R.string.exercise_view_overview_problem_statement_not_available), modifier = Modifier .fillMaxWidth() .padding(16.dp), @@ -65,3 +103,213 @@ internal fun ExerciseOverviewTab( } } } + + +/** + * @param isLongToolbar if the deadline information is displayed on the right side of the toolbar. + * If false, the information is instead displayed in the column + */ +@Composable +private fun ExerciseInformation( + modifier: Modifier, + exercise: Exercise, + isLongToolbar: Boolean +) { + var maxWidth: Int by remember { mutableIntStateOf(0) } + val updateMaxWidth = { new: Int -> maxWidth = new } + + val nullableDueDateTextInfo = @Composable { dueDate: Instant?, hintRes: @receiver:StringRes Int -> + if (dueDate != null) { + DueDateTextInfo( + dueDate = dueDate, + hintRes = hintRes, + maxWidth = maxWidth, + updateMaxWidth = updateMaxWidth + ) + } + } + + val categoryPointsReleaseDateUi = @Composable { + ExerciseCategoryChipRow( + modifier = Modifier.fillMaxWidth(), + exercise = exercise + ) + + ExercisePointInfo(exercise) + + nullableDueDateTextInfo( + exercise.releaseDate, + R.string.exercise_view_overview_hint_assessment_release_date + ) + } + + val dueDateColumnUi = @Composable { contentModifier: Modifier -> + Column( + modifier = contentModifier, + ) { + nullableDueDateTextInfo( + exercise.assessmentDueDate, + R.string.exercise_view_overview_hint_assessment_due_date + ) + + nullableDueDateTextInfo( + exercise.assessmentDueDate, + R.string.exercise_view_overview_hint_assessment_due_date + ) + + ExerciseCompliantPossibleInfo(exercise) + } + } + + // Actual UI + Column( + modifier = modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.exercise_view_overview_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(bottom = 1.dp) + .fillMaxWidth(), + textAlign = TextAlign.Start + ) + Divider( + color = Color.Black, + thickness = 2.dp, + modifier = Modifier.padding(vertical = 0.dp) + ) + + // Here we make the distinction in the layout between long toolbar and short toolbar + if (isLongToolbar) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + categoryPointsReleaseDateUi() + } + + dueDateColumnUi( + Modifier + .width(IntrinsicSize.Max) + .align(Alignment.Bottom) + ) + } + } else { + Column(modifier = Modifier.fillMaxWidth()) { + categoryPointsReleaseDateUi() + dueDateColumnUi(Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun ExerciseCompliantPossibleInfo(exercise: Exercise) { + val complaintPossible = exercise.allowComplaintsForAutomaticAssessments ?: false + val complaintPossibleText = stringResource( + R.string.exercise_view_overview_hint_assessment_complaint_possible, + stringResource(if (complaintPossible) R.string.exercise_view_overview_hint_assessment_complaint_possible_yes else R.string.exercise_view_overview_hint_assessment_complaint_possible_no) + ) + + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = complaintPossibleText, + style = MaterialTheme.typography.bodyLarge + ) +} + +@Composable +private fun ExercisePointInfo(exercise: Exercise) { + val currentUserPoints = exercise.currentUserPoints?.let(ExercisePointsDecimalFormat::format) + val maxPoints = exercise.maxPoints?.let(ExercisePointsDecimalFormat::format) + + val pointsHintText = when { + currentUserPoints != null && maxPoints != null -> stringResource( + id = R.string.exercise_view_overview_points_reached, + currentUserPoints, + maxPoints + ) + + maxPoints != null -> stringResource( + id = R.string.exercise_view_overview_points_max, + maxPoints + ) + + else -> stringResource(id = R.string.exercise_view_overview_points_none) + } + + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = pointsHintText, + style = MaterialTheme.typography.bodyLarge + ) +} + +@Composable +private fun DueDateTextInfo( + dueDate: Instant, + @StringRes hintRes: Int, + maxWidth: Int, + updateMaxWidth: (Int) -> Unit +) = TextInformation( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 1.dp), + hintColumnWidth = maxWidth, + hint = stringResource(id = hintRes), + dataText = getRelativeTime(to = dueDate).toString(), + dataColor = getDueDateColor(dueDate), + updateHintColumnWidth = updateMaxWidth + ) + +/** + * Text information composable that achieves a table like layout, where the hint is the first column + * and the data is the second column. + */ +@Composable +private fun TextInformation( + modifier: Modifier, + hintColumnWidth: Int, + hint: String, + dataText: String, + dataColor: Color?, + updateHintColumnWidth: (Int) -> Unit +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val assignedWidth = maxOf(hintColumnWidth, placeable.width) + if (assignedWidth > hintColumnWidth) { + updateHintColumnWidth(assignedWidth) + } + + layout(width = assignedWidth, height = placeable.height) { + placeable.placeRelative(0, 0) + } + }, + text = hint, + style = MaterialTheme.typography.bodyLarge, + ) + + val dataModifier = Modifier + + if (dataColor != null) { + ExerciseInfoChip(modifier = dataModifier, color = dataColor, text = dataText) + } else { + Text( + modifier = dataModifier.padding(horizontal = ExerciseInfoChipTextHorizontalPadding), + text = dataText, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun getDueDateColor(dueDate: Instant): Color = + if (dueDate.hasPassed()) Color.Red else Color.Green diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ParticipationStatus.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ParticipationStatus.kt index d07acca26..441bb6388 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ParticipationStatus.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ParticipationStatus.kt @@ -1,5 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.overview +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp 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 8580ba5ff..9c82f6693 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 @@ -8,15 +8,23 @@ Overview Communication + Exercise Details + Points: %1$s Points: %1$s / %2$s No points Submission due: Assessment due: + Complaint possible: %1$s Release date: + Yes + No + Your exercise status: + No problem statement available. + Exercise info Type Points diff --git a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/BaseExerciseTest.kt b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/BaseExerciseTest.kt index b31059d8e..399f9858c 100644 --- a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/BaseExerciseTest.kt +++ b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/BaseExerciseTest.kt @@ -1,39 +1,26 @@ package de.tum.informatics.www1.artemis.native_app.feature.exercise_view.participate.text_exercise -import android.content.Context -import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.platform.app.InstrumentationRegistry import de.tum.informatics.www1.artemis.native_app.core.model.Course import de.tum.informatics.www1.artemis.native_app.core.model.exercise.TextExercise +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.test.testWebsocketModule import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createExercise import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createTextExercise -import de.tum.informatics.www1.artemis.native_app.core.test.testWebsocketModule import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.exerciseModule import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Rule import org.koin.android.ext.koin.androidContext -import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.robolectric.shadows.ShadowLog -abstract class BaseExerciseTest : KoinTest { - - protected val testDispatcher = UnconfinedTestDispatcher() - - protected val context: Context get() = InstrumentationRegistry.getInstrumentation().context - - @get:Rule - val composeTestRole = createComposeRule() +abstract class BaseExerciseTest : BaseComposeTest() { @get:Rule val koinTestRule = KoinTestRule.create { @@ -52,18 +39,16 @@ abstract class BaseExerciseTest : KoinTest { open fun setup() { ShadowLog.stream = System.out - runBlocking { - withTimeout(DefaultTimeoutMillis) { - accessToken = performTestLogin() + runBlockingWithTestTimeout { + accessToken = performTestLogin() - course = createCourse(getAdminAccessToken()) - exercise = createExercise( - getAdminAccessToken(), - course.id!!, - endpoint = "text-exercises", - creator = ::createTextExercise - ) as TextExercise - } + course = createCourse(getAdminAccessToken()) + exercise = createExercise( + getAdminAccessToken(), + course.id!!, + endpoint = "text-exercises", + creator = ::createTextExercise + ) as TextExercise } } } \ No newline at end of file diff --git a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/ExerciseOverviewE2eTest.kt b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/ExerciseOverviewE2eTest.kt index 3296a2ae9..8b395d1cb 100644 --- a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/ExerciseOverviewE2eTest.kt +++ b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/ExerciseOverviewE2eTest.kt @@ -10,8 +10,8 @@ import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.navigation.compose.rememberNavController -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewModel import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.ExerciseScreen @@ -34,7 +34,7 @@ class ExerciseOverviewE2eTest : BaseExerciseTest() { fun `displays correct exercise data`() { setupUiAndViewModel() - composeTestRole.onAllNodesWithText(exercise.title!!).onFirst().assertExists() + composeTestRule.onAllNodesWithText(exercise.title!!).onFirst().assertExists() } @OptIn(ExperimentalTestApi::class) @@ -44,14 +44,14 @@ class ExerciseOverviewE2eTest : BaseExerciseTest() { setupUiAndViewModel { participationId = it } - composeTestRole.onNodeWithText( + composeTestRule.onNodeWithText( context.getString(CoreUiR.string.exercise_actions_start_exercise_button) ) .performClick() - composeTestRole.waitUntil(DefaultTimeoutMillis) { participationId != null } + composeTestRule.waitUntil(DefaultTimeoutMillis) { participationId != null } - composeTestRole + composeTestRule .waitUntilExactlyOneExists( hasText(context.getString(CoreUiR.string.exercise_actions_open_exercise_button)), DefaultTimeoutMillis @@ -73,7 +73,7 @@ class ExerciseOverviewE2eTest : BaseExerciseTest() { coroutineContext = testDispatcher ) - composeTestRole.setContent { + composeTestRule.setContent { CompositionLocalProvider( LocalKoinScope provides KoinPlatformTools.defaultContext() .get().scopeRegistry.rootScope, diff --git a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/TextExerciseParticipationE2eTest.kt b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/TextExerciseParticipationE2eTest.kt index 386508415..ed8699ef8 100644 --- a/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/TextExerciseParticipationE2eTest.kt +++ b/feature/exercise-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exercise_view/participate/text_exercise/TextExerciseParticipationE2eTest.kt @@ -10,25 +10,24 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest +import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.core.data.service.network.CourseExerciseService import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.Participation import de.tum.informatics.www1.artemis.native_app.core.model.exercise.participation.StudentParticipation import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.SubmissionType import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.TextSubmission -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.R -import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.service.TextSubmissionService import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.participate.textexercise.SyncState import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.participate.textexercise.TEST_TAG_TEXT_FIELD_PARTICIPATION import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.participate.textexercise.TextExerciseParticipationScreen import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.participate.textexercise.TextExerciseParticipationViewModel +import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.service.TextSubmissionService import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.Clock import org.junit.Before @@ -56,20 +55,18 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { @Before override fun setup() { super.setup() - runBlocking { - withTimeout(DefaultTimeoutMillis) { - val courseExerciseService: CourseExerciseService = get() - participation = - courseExerciseService - .startExercise(exercise.id!!, testServerUrl, accessToken) - .orThrow("Start text exercise participation") - - val submissions = participation.submissions - assertNotNull(submissions, "Submissions are not given in participation") - assertFalse(submissions.isEmpty(), "Submissions must not be empty") - - initialSubmission = assertIs(submissions.first()) - } + runBlockingWithTestTimeout { + val courseExerciseService: CourseExerciseService = get() + participation = + courseExerciseService + .startExercise(exercise.id!!, testServerUrl, accessToken) + .orThrow("Start text exercise participation") + + val submissions = participation.submissions + assertNotNull(submissions, "Submissions are not given in participation") + assertFalse(submissions.isEmpty(), "Submissions must not be empty") + + initialSubmission = assertIs(submissions.first()) } } @@ -77,27 +74,26 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { fun `can view already entered text`() { val textSubmissionService: TextSubmissionService = get() - runBlocking { - withTimeout(DefaultTimeoutMillis) { - textSubmissionService.update( - TextSubmission( - id = initialSubmission.id, - submissionDate = Clock.System.now(), - participation = StudentParticipation.StudentParticipationImpl(id = participation.id!!), - submitted = true, - text = textToEnter, - submissionType = SubmissionType.MANUAL - ), - exercise.id!!, - testServerUrl, - accessToken - ).orThrow("Could no update text submission to set initial submission") - } + runBlockingWithTestTimeout { + textSubmissionService.update( + TextSubmission( + id = initialSubmission.id, + submissionDate = Clock.System.now(), + participation = StudentParticipation.StudentParticipationImpl(id = participation.id!!), + submitted = true, + text = textToEnter, + submissionType = SubmissionType.MANUAL + ), + exercise.id!!, + testServerUrl, + accessToken + ).orThrow("Could no update text submission to set initial submission") } + setupUi() - composeTestRole + composeTestRule .onNodeWithTag(TEST_TAG_TEXT_FIELD_PARTICIPATION) .assert(hasText(textToEnter)) } @@ -106,11 +102,11 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { fun `can update text by entering new text`() { val viewModel = setupUi() - composeTestRole + composeTestRule .onNodeWithTag(TEST_TAG_TEXT_FIELD_PARTICIPATION) .performTextInput(textToEnter) - composeTestRole + composeTestRule .onNodeWithText(context.getString(R.string.participate_text_exercise_submit_button)) .performClick() @@ -123,7 +119,7 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { } ?: throw RuntimeException("State could not be synced in time.") } - composeTestRole + composeTestRule .onNodeWithText(context.getString(R.string.participate_text_exercise_synced_changes)) .assertExists() } @@ -140,7 +136,7 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { coroutineContext = testDispatcher ) - composeTestRole.setContent { + composeTestRule.setContent { TextExerciseParticipationScreen( modifier = Modifier.fillMaxSize(), viewModel = viewModel, @@ -149,7 +145,7 @@ class TextExerciseParticipationE2eTest : BaseExerciseTest() { ) } - composeTestRole.waitUntilAtLeastOneExists( + composeTestRule.waitUntilAtLeastOneExists( hasTestTag(TEST_TAG_TEXT_FIELD_PARTICIPATION), DefaultTimeoutMillis ) diff --git a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/AttachmentsTab.kt b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/AttachmentsTab.kt index 64ba8bc73..0e01ea757 100644 --- a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/AttachmentsTab.kt +++ b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/AttachmentsTab.kt @@ -2,9 +2,13 @@ package de.tum.informatics.www1.artemis.native_app.feature.lectureview import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -31,7 +35,12 @@ internal fun AttachmentsTab( onClickOpenLinkAttachment: (Attachment) -> Unit ) { if (attachments.isNotEmpty()) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { items(attachments) { attachment -> AttachmentItem( modifier = Modifier.fillMaxWidth(), diff --git a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/LectureScreenUi.kt b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/LectureScreenUi.kt index 8a6787be9..e4884dab0 100644 --- a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/LectureScreenUi.kt +++ b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/LectureScreenUi.kt @@ -35,6 +35,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import io.github.fornewid.placeholder.material3.placeholder import de.tum.informatics.www1.artemis.native_app.core.model.lecture.Attachment import de.tum.informatics.www1.artemis.native_app.core.ui.LocalLinkOpener @@ -45,16 +46,20 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.canDisplayMet import io.ktor.http.HttpHeaders import io.ktor.http.URLBuilder import io.ktor.http.appendPathSegments +import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf const val METIS_RATIO = 0.3f +@Serializable +private data class LectureScreenUi(val lectureId: Long) + fun NavController.navigateToLecture( lectureId: Long, builder: NavOptionsBuilder.() -> Unit ) { - navigate("lecture/$lectureId", builder) + navigate(LectureScreenUi(lectureId), builder) } fun NavGraphBuilder.lecture( @@ -66,23 +71,16 @@ fun NavGraphBuilder.lecture( onParticipateInQuiz: (courseId: Long, exerciseId: Long, isPractice: Boolean) -> Unit, onClickViewQuizResults: (courseId: Long, exerciseId: Long) -> Unit, ) { - composable( - route = "lecture/{lectureId}", - arguments = listOf( - navArgument("lectureId") { - type = NavType.LongType - nullable = false - } - ), + composable( deepLinks = listOf( navDeepLink { uriPattern = "artemis://lectures/{lectureId}" } ) + generateLinks("courses/{courseId}/lectures/{lectureId}") ) { backStackEntry -> - val lectureId = - backStackEntry.arguments?.getLong("lectureId") - checkNotNull(lectureId) + val route: LectureScreenUi = backStackEntry.toRoute() + + val lectureId = route.lectureId val viewModel: LectureViewModel = koinViewModel { parametersOf(lectureId) } val lectureDataState by viewModel.lectureDataState.collectAsState() @@ -195,7 +193,7 @@ internal fun LectureScreen( ) { padding -> val bodyModifier = Modifier .fillMaxSize() - .padding(padding) + .padding(top = padding.calculateTopPadding()) contentBody( bodyModifier diff --git a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/OverviewTab.kt b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/OverviewTab.kt index 2da11137c..a53a80388 100644 --- a/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/OverviewTab.kt +++ b/feature/lecture-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lectureview/OverviewTab.kt @@ -2,7 +2,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.lectureview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState @@ -50,7 +55,10 @@ internal fun OverviewTab( LazyColumn( modifier = modifier.testTag(TEST_TAG_OVERVIEW_LIST), verticalArrangement = Arrangement.spacedBy(16.dp), - state = state + state = state, + contentPadding = PaddingValues( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) ) { if (description != null) { item { diff --git a/feature/lecture-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lecture_view/LectureE2eTest.kt b/feature/lecture-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lecture_view/LectureE2eTest.kt index ae2bbe6cb..584e30e1b 100644 --- a/feature/lecture-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lecture_view/LectureE2eTest.kt +++ b/feature/lecture-view/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/lecture_view/LectureE2eTest.kt @@ -1,13 +1,11 @@ package de.tum.informatics.www1.artemis.native_app.feature.lecture_view -import android.content.Context import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -15,6 +13,7 @@ import androidx.compose.ui.test.performScrollToKey import androidx.lifecycle.SavedStateHandle import androidx.navigation.compose.rememberNavController import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.data.service.impl.JsonProvider import de.tum.informatics.www1.artemis.native_app.core.data.test.awaitFirstSuccess @@ -22,9 +21,9 @@ import de.tum.informatics.www1.artemis.native_app.core.model.Course import de.tum.informatics.www1.artemis.native_app.core.model.lecture.Lecture import de.tum.informatics.www1.artemis.native_app.core.model.lecture.lecture_units.LectureUnit import de.tum.informatics.www1.artemis.native_app.core.model.lecture.lecture_units.LectureUnitExercise +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules import de.tum.informatics.www1.artemis.native_app.core.test.testWebsocketModule -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createAttachment import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createAttachmentUnit @@ -37,19 +36,18 @@ import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_cr import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createTextExercise import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createTextLectureUnit import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createVideoLectureUnit -import de.tum.informatics.www1.artemis.native_app.feature.lectureview.lecture_units.TEST_TAG_CHECKBOX_LECTURE_UNIT_COMPLETED import de.tum.informatics.www1.artemis.native_app.feature.lectureview.LectureScreen import de.tum.informatics.www1.artemis.native_app.feature.lectureview.LectureViewModel +import de.tum.informatics.www1.artemis.native_app.feature.lectureview.R import de.tum.informatics.www1.artemis.native_app.feature.lectureview.TEST_TAG_OVERVIEW_LIST import de.tum.informatics.www1.artemis.native_app.feature.lectureview.getLectureUnitTestTag import de.tum.informatics.www1.artemis.native_app.feature.lectureview.lectureModule +import de.tum.informatics.www1.artemis.native_app.feature.lectureview.lecture_units.TEST_TAG_CHECKBOX_LECTURE_UNIT_COMPLETED import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.withTimeout import kotlinx.serialization.encodeToString import org.junit.Before import org.junit.Rule @@ -61,24 +59,17 @@ import org.koin.compose.LocalKoinApplication import org.koin.compose.LocalKoinScope import org.koin.core.annotation.KoinInternalApi import org.koin.mp.KoinPlatformTools -import org.koin.test.KoinTest import org.koin.test.KoinTestRule import org.koin.test.get import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import de.tum.informatics.www1.artemis.native_app.feature.lectureview.R @OptIn(ExperimentalTestApi::class) @Category(EndToEndTest::class) @RunWith(RobolectricTestRunner::class) -class LectureE2eTest : KoinTest { - - private val testDispatcher = UnconfinedTestDispatcher() - - @get:Rule - val composeTestRule = createComposeRule() +class LectureE2eTest : BaseComposeTest() { @get:Rule val koinTestRule = KoinTestRule.create { @@ -88,20 +79,16 @@ class LectureE2eTest : KoinTest { modules(loginModule, lectureModule, testLoginModule, testWebsocketModule) } - private val context: Context get() = InstrumentationRegistry.getInstrumentation().context - private lateinit var course: Course private lateinit var lecture: Lecture @Before fun setup() { - runBlocking { - withTimeout(DefaultTestTimeoutMillis) { - performTestLogin() + runBlockingWithTestTimeout { + performTestLogin() - course = createCourse(getAdminAccessToken()) - lecture = createLecture(getAdminAccessToken(), course.id!!) - } + course = createCourse(getAdminAccessToken()) + lecture = createLecture(getAdminAccessToken(), course.id!!) } } @@ -175,10 +162,8 @@ class LectureE2eTest : KoinTest { assert(attachments.size == 3) { "Expected 3 lecture units" } val viewModel = setupViewModelAndUi() - val loadedAttachments = runBlocking { - withTimeout(DefaultTimeoutMillis) { - viewModel.lectureDataState.awaitFirstSuccess("Lecture Data State").attachments - } + val loadedAttachments = runBlockingWithTestTimeout { + viewModel.lectureDataState.awaitFirstSuccess("Lecture Data State").attachments } assertEquals(loadedAttachments.size, 3, "Expected 3 attachments") diff --git a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/AccountUi.kt b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/AccountUi.kt index a64be37e2..646e47f87 100644 --- a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/AccountUi.kt +++ b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/AccountUi.kt @@ -10,10 +10,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll @@ -55,13 +60,14 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.navigation.toRoute import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService import de.tum.informatics.www1.artemis.native_app.core.datastore.defaults.ArtemisInstances import de.tum.informatics.www1.artemis.native_app.core.model.server_config.ProfileInfo import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi -import de.tum.informatics.www1.artemis.native_app.core.ui.material.linkTextColor +import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.linkTextColor import de.tum.informatics.www1.artemis.native_app.feature.login.custom_instance_selection.CustomInstanceSelectionScreen import de.tum.informatics.www1.artemis.native_app.feature.login.instance_selection.InstanceSelectionScreen import de.tum.informatics.www1.artemis.native_app.feature.login.login.LoginScreen @@ -73,39 +79,35 @@ import de.tum.informatics.www1.artemis.native_app.feature.login.service.ServerNo import io.ktor.http.encodeURLPathPart import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf import java.io.IOException -private const val NAV_ARG_NEXT_DESTINATION = "next_destination" - -const val LOGIN_DESTINATION = "login/{$NAV_ARG_NEXT_DESTINATION}" private const val ARG_REMEMBER_ME = "rememberMe" private const val NESTED_SAML2_LOGIN_ROUTE = "saml2_login" -private enum class NestedDestination(val destination: String) { - INSTANCE_SELECTION("instance_selection"), - CUSTOM_INSTANCE_SELECTION("custom_instance_selection"), - HOME("nested_home"), - LOGIN("nested_login"), - REGISTER("nested_register"), - SAML2_LOGIN("$NESTED_SAML2_LOGIN_ROUTE/{$ARG_REMEMBER_ME}"); - - companion object { - fun getByRoute(route: String?): NestedDestination? = when (route) { - INSTANCE_SELECTION.destination -> INSTANCE_SELECTION - CUSTOM_INSTANCE_SELECTION.destination -> CUSTOM_INSTANCE_SELECTION - HOME.destination -> HOME - LOGIN.destination -> LOGIN - REGISTER.destination -> REGISTER - NESTED_SAML2_LOGIN_ROUTE -> SAML2_LOGIN - else -> null - } - } +@Serializable +private sealed interface NestedDestination { + @Serializable + data object InstanceSelection : NestedDestination + @Serializable + data object CustomInstanceSelection : NestedDestination + @Serializable + data object Home : NestedDestination + @Serializable + data object Login : NestedDestination + @Serializable + data object Register : NestedDestination + @Serializable + data class Saml2Login(val rememberMe: Boolean) : NestedDestination } +@Serializable +data class LoginScreen(val nextDestination: String?) + /** * @param nextDestination the deep link to a destination that should be opened after a successful login */ @@ -113,11 +115,13 @@ fun NavController.navigateToLogin( nextDestination: String? = null, builder: NavOptionsBuilder.() -> Unit ) { - if (nextDestination != null) { - navigate("login/${nextDestination.encodeURLPathPart()}", builder) + val screen = if (nextDestination != null) { + LoginScreen(nextDestination) } else { - navigate("login/null", builder) + LoginScreen(null) } + + navigate(screen, builder) } /** @@ -127,17 +131,9 @@ fun NavGraphBuilder.loginScreen( onFinishedLoginFlow: (deepLink: String?) -> Unit, onRequestOpenSettings: () -> Unit ) { - composable( - LOGIN_DESTINATION, - arguments = listOf( - navArgument(NAV_ARG_NEXT_DESTINATION) { - type = NavType.StringType - defaultValue = null - nullable = true - } - ) - ) { - val nextDestinationValue = it.arguments?.getString(NAV_ARG_NEXT_DESTINATION) + composable { + val screen = it.toRoute() + val nextDestinationValue = screen.nextDestination var nextDestination by remember(nextDestinationValue) { mutableStateOf(if (nextDestinationValue == null || nextDestinationValue == "null") null else nextDestinationValue) @@ -227,16 +223,14 @@ private fun LoginUiScreen( ?: return // Display nothing to avoid switching between destinations // Force recomposition + val currentBackStack by nestedNavController.currentBackStackEntryAsState() nestedNavController.currentBackStackEntryAsState().value val supportsBackNavigation = nestedNavController.previousBackStackEntry != null - val selectedDestination: NestedDestination? = - NestedDestination.getByRoute(nestedNavController.currentDestination?.route) + val selectedDestination: NestedDestination? = currentBackStack?.toRoute() val onClickSaml2Login: (rememberMe: Boolean) -> Unit = { rememberMe -> - nestedNavController.navigate( - createSaml2LoginRoute(rememberMe) - ) + nestedNavController.navigate(NestedDestination.Saml2Login(rememberMe)) } Scaffold( @@ -252,9 +246,9 @@ private fun LoginUiScreen( }, title = { val titleText: Int? = when (selectedDestination) { - NestedDestination.CUSTOM_INSTANCE_SELECTION -> R.string.account_select_custom_instance_selection_title - NestedDestination.LOGIN -> R.string.login_title - NestedDestination.REGISTER -> R.string.register_title + NestedDestination.CustomInstanceSelection -> R.string.account_select_custom_instance_selection_title + NestedDestination.Login -> R.string.login_title + NestedDestination.Register -> R.string.register_title else -> null } @@ -273,25 +267,27 @@ private fun LoginUiScreen( NavHost( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .imePadding() + .consumeWindowInsets(WindowInsets.systemBars) + .padding(top = paddingValues.calculateTopPadding()), navController = nestedNavController, - startDestination = if (hasSelectedInstance) NestedDestination.HOME.destination else NestedDestination.INSTANCE_SELECTION.destination + startDestination = if (hasSelectedInstance) NestedDestination.Home else NestedDestination.InstanceSelection ) { - composable(NestedDestination.HOME.destination) { + composable() { AccountScreen( modifier = Modifier.fillMaxSize(), canSwitchInstance = !BuildConfig.hasInstanceRestriction, onNavigateToLoginScreen = { - nestedNavController.navigate(NestedDestination.LOGIN.destination) + nestedNavController.navigate(NestedDestination.Login) }, onNavigateToRegisterScreen = { - nestedNavController.navigate(NestedDestination.REGISTER.destination) + nestedNavController.navigate(NestedDestination.Register) }, onNavigateToInstanceSelection = { onNavigatedToInstanceSelection() - nestedNavController.navigate(NestedDestination.INSTANCE_SELECTION.destination) { - popUpTo(NestedDestination.HOME.destination) { + nestedNavController.navigate(NestedDestination.InstanceSelection) { + popUpTo { inclusive = true } } @@ -301,21 +297,21 @@ private fun LoginUiScreen( ) } - composable(NestedDestination.CUSTOM_INSTANCE_SELECTION.destination) { + composable { CustomInstanceSelectionScreen( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp) ) { - nestedNavController.navigate(NestedDestination.HOME.destination) { - popUpTo(NestedDestination.INSTANCE_SELECTION.destination) { + nestedNavController.navigate(NestedDestination.Home) { + popUpTo { inclusive = true } } } } - composable(NestedDestination.LOGIN.destination) { + composable { LoginScreen( modifier = Modifier.fillMaxSize(), viewModel = getViewModel(), @@ -324,12 +320,7 @@ private fun LoginUiScreen( ) } - composable( - route = NestedDestination.SAML2_LOGIN.destination, - arguments = listOf(navArgument("rememberMe") { - type = NavType.BoolType - }) - ) { backStack -> + composable { backStack -> val rememberMe = backStack.arguments?.getBoolean(ARG_REMEMBER_ME) checkNotNull(rememberMe) @@ -343,7 +334,7 @@ private fun LoginUiScreen( ) } - composable(NestedDestination.REGISTER.destination) { + composable { RegisterUi( modifier = Modifier .fillMaxSize() @@ -352,12 +343,12 @@ private fun LoginUiScreen( viewModel = koinViewModel(), onRegistered = { nestedNavController.popBackStack() - nestedNavController.navigate(NestedDestination.LOGIN.destination) + nestedNavController.navigate(NestedDestination.Login) } ) } - composable(NestedDestination.INSTANCE_SELECTION.destination) { + composable { val scope = rememberCoroutineScope() InstanceSelectionScreen( @@ -368,8 +359,8 @@ private fun LoginUiScreen( onSelectArtemisInstance = { serverUrl -> scope.launch { serverConfigurationService.updateServerUrl(serverUrl) - nestedNavController.navigate(NestedDestination.HOME.destination) { - popUpTo(NestedDestination.INSTANCE_SELECTION.destination) { + nestedNavController.navigate(NestedDestination.Home) { + popUpTo { inclusive = true } } @@ -377,7 +368,7 @@ private fun LoginUiScreen( }, onRequestOpenCustomInstanceSelection = { nestedNavController.navigate( - NestedDestination.CUSTOM_INSTANCE_SELECTION.destination + NestedDestination.CustomInstanceSelection ) } ) @@ -386,9 +377,6 @@ private fun LoginUiScreen( } } -private fun createSaml2LoginRoute(rememberMe: Boolean): String = - NestedDestination.SAML2_LOGIN.destination.replace("{$ARG_REMEMBER_ME}", rememberMe.toString()) - /** * Displays the screen to login and register. Also allows to change the artemis instance. */ @@ -463,7 +451,11 @@ private fun AccountUi( ClickableText( modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(bottom = 8.dp), + .padding( + bottom = WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ), text = AnnotatedString(stringResource(id = R.string.account_change_artemis_instance_label)), style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.linkTextColor), onClick = { onNavigateToInstanceSelection() } diff --git a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/NotificationSettingsUi.kt b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/NotificationSettingsUi.kt index 04f4ee1fd..40e576e96 100644 --- a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/NotificationSettingsUi.kt +++ b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/NotificationSettingsUi.kt @@ -5,6 +5,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -88,8 +89,9 @@ internal fun NotificationSettingsUi(modifier: Modifier, onDone: () -> Unit) { Column( modifier = Modifier .fillMaxSize() - .padding(padding) - .padding(8.dp) + .imePadding() + .padding(top = padding.calculateTopPadding()) + .padding(horizontal = 8.dp) .verticalScroll(rememberScrollState()) ) { de.tum.informatics.www1.artemis.native_app.feature.push.ui.PushNotificationSettingsUi( diff --git a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/instance_selection/InstanceSelectionScreen.kt b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/instance_selection/InstanceSelectionScreen.kt index 686a22a61..0342ff367 100644 --- a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/instance_selection/InstanceSelectionScreen.kt +++ b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/instance_selection/InstanceSelectionScreen.kt @@ -4,10 +4,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -79,7 +83,9 @@ internal fun InstanceSelectionScreen( columns = GridCells.Fixed(columnCount), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 16.dp) + contentPadding = PaddingValues( + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) ) { items(availableInstances) { instance -> val item = GridCellItem.ArtemisInstanceGridCellItem( diff --git a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/login/LoginUi.kt b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/login/LoginUi.kt index 902aa9382..f38f78582 100644 --- a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/login/LoginUi.kt +++ b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/login/LoginUi.kt @@ -3,6 +3,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.login.login import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState diff --git a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/register/RegisterUi.kt b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/register/RegisterUi.kt index 93dcbb6b2..fa7b7ed48 100644 --- a/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/register/RegisterUi.kt +++ b/feature/login/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/login/register/RegisterUi.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Divider @@ -92,7 +93,7 @@ internal fun RegisterUi( } Column( - modifier = modifier, + modifier = modifier.imePadding(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { val textFieldModifier = Modifier diff --git a/feature/login/src/test/java/de/tum/informatics/www1/artemis/native_app/feature/login/RegisterEndToEndTest.kt b/feature/login/src/test/java/de/tum/informatics/www1/artemis/native_app/feature/login/RegisterEndToEndTest.kt index dafc3c397..98015640b 100644 --- a/feature/login/src/test/java/de/tum/informatics/www1/artemis/native_app/feature/login/RegisterEndToEndTest.kt +++ b/feature/login/src/test/java/de/tum/informatics/www1/artemis/native_app/feature/login/RegisterEndToEndTest.kt @@ -1,6 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.login import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.ui.Modifier @@ -83,6 +84,7 @@ class RegisterEndToEndTest : BaseComposeTest() { RegisterUi( modifier = Modifier .fillMaxSize() + .imePadding() .verticalScroll(rememberScrollState()), viewModel = viewModel, onRegistered = { } diff --git a/feature/metis-test/build.gradle.kts b/feature/metis-test/build.gradle.kts index a7c2bfe47..c81a769cf 100644 --- a/feature/metis-test/build.gradle.kts +++ b/feature/metis-test/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { api(libs.koin.test.junit4) api(libs.robolectric) api(libs.koin.android.test) + api(libs.androidx.paging.testing) } diff --git a/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt index 677e86c8f..cbba57727 100644 --- a/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt +++ b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt @@ -32,7 +32,10 @@ class MetisServiceStub( return NetworkResponse.Response(posts.first()) } - override fun subscribeToPostUpdates(metisContext: MetisContext): Flow> { + override fun subscribeToPostUpdates( + courseId: Long, + clientId: Long + ): Flow> { return flowOf() } } \ No newline at end of file diff --git a/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/paging_source_util.kt b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/paging_source_util.kt new file mode 100644 index 000000000..880d8ee6d --- /dev/null +++ b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/paging_source_util.kt @@ -0,0 +1,15 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metistest + +import android.annotation.SuppressLint +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.testing.asSnapshot + + +@SuppressLint("VisibleForTests") +suspend fun PagingSource.loadAsList(): List { + return Pager(PagingConfig(pageSize = 10), pagingSourceFactory = { this }).flow.asSnapshot { + scrollTo(50) + } +} \ No newline at end of file diff --git a/feature/metis/code-of-conduct/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/codeofconduct/ui/AcceptCodeOfConductUi.kt b/feature/metis/code-of-conduct/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/codeofconduct/ui/AcceptCodeOfConductUi.kt index fb255be90..39a57b9a9 100644 --- a/feature/metis/code-of-conduct/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/codeofconduct/ui/AcceptCodeOfConductUi.kt +++ b/feature/metis/code-of-conduct/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/codeofconduct/ui/AcceptCodeOfConductUi.kt @@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -125,7 +128,11 @@ fun AcceptCodeOfConductUi( Icon( modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 8.dp) + .padding( + bottom = WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ) .offset { val offsetInDp = 4.dp.toPx() IntOffset(0, (offsetInDp * additionalOffsetPercent).toInt()) diff --git a/feature/metis/conversation/build.gradle.kts b/feature/metis/conversation/build.gradle.kts index 498c87285..c83ed2e88 100644 --- a/feature/metis/conversation/build.gradle.kts +++ b/feature/metis/conversation/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":core:device")) implementation(project(":feature:metis:shared")) + testImplementation(project(":feature:metis-test")) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) @@ -33,10 +34,9 @@ dependencies { implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.dataStore.preferences) - - testImplementation(project(":feature:metis-test")) - implementation("androidx.paging:paging-common:3.2.1") - + implementation(libs.androidx.paging.common) + + testImplementation(libs.androidx.paging.testing) testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/MetisService.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/MetisService.kt index e858583f8..9fd5aeb05 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/MetisService.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/MetisService.kt @@ -34,7 +34,8 @@ interface MetisService { ): NetworkResponse fun subscribeToPostUpdates( - metisContext: MetisContext + courseId: Long, + clientId: Long, ): Flow> /** diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/impl/MetisServiceImpl.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/impl/MetisServiceImpl.kt index 7d67bb9bf..14f77a9be 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/impl/MetisServiceImpl.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/network/impl/MetisServiceImpl.kt @@ -5,12 +5,13 @@ import de.tum.informatics.www1.artemis.native_app.core.data.cookieAuth import de.tum.informatics.www1.artemis.native_app.core.data.performNetworkCall import de.tum.informatics.www1.artemis.native_app.core.data.service.KtorProvider import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider +import de.tum.informatics.www1.artemis.native_app.core.websocket.impl.WebsocketTopic +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.RESOURCE_PATH_SEGMENTS import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisFilter import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostDTO import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisSortingStrategy -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.RESOURCE_PATH_SEGMENTS import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.CourseWideContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost import io.ktor.client.call.body @@ -18,6 +19,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.appendPathSegments import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge internal class MetisServiceImpl( private val ktorProvider: KtorProvider, @@ -148,9 +150,16 @@ internal class MetisServiceImpl( } } - override fun subscribeToPostUpdates(metisContext: MetisContext): Flow> { - val channel = "/topic/metis/courses/${metisContext.courseId}" + override fun subscribeToPostUpdates( + courseId: Long, + clientId: Long + ): Flow> { + val courseWideTopic = WebsocketTopic.getCourseWideConversationUpdateTopic(courseId) + val normalTopic = WebsocketTopic.getNormalConversationUpdateTopic(clientId) + + val courseWideUpdates = websocketProvider.subscribe(courseWideTopic, MetisPostDTO.serializer()) + val normalUpdates = websocketProvider.subscribe(normalTopic, MetisPostDTO.serializer()) - return websocketProvider.subscribe(channel, MetisPostDTO.serializer()) + return merge(courseWideUpdates, normalUpdates) } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImpl.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImpl.kt index da80b42be..85e6be33e 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImpl.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImpl.kt @@ -2,9 +2,9 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.se import androidx.paging.PagingSource import androidx.room.withTransaction -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.MetisStorageService import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.MetisDatabaseProvider +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.BasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.CourseWideContext @@ -146,6 +146,7 @@ internal class MetisStorageServiceImpl( serverId: String, clientSidePostId: String, serverSidePostId: Long?, + conversationId: Long = this.conversationId, postingType: BasePostingEntity.PostingType ): MetisPostContextEntity = MetisPostContextEntity( @@ -336,6 +337,19 @@ internal class MetisStorageServiceImpl( val metisDao = databaseProvider.metisDao databaseProvider.database.withTransaction { + val doesPostAnswerAlreadyExist = metisDao.isPostPresentInContext( + serverId = host, + serverPostId = post.id ?: return@withTransaction, + courseId = metisContext.courseId, + conversationId = metisContext.conversationId + ) + + // In rare cases, the websocket connection already inserted the post answer. In that case, we can delete the client side post. + if (doesPostAnswerAlreadyExist) { + metisDao.deletePostingWithClientSideId(clientPostId = clientSidePostId) + return@withTransaction + } + metisDao.upgradePost( clientSidePostId = clientSidePostId, serverSidePostId = post.id ?: return@withTransaction @@ -458,11 +472,13 @@ internal class MetisStorageServiceImpl( metisDao.insertOrUpdateUser(postingAuthor) val standalonePostId = sp.id + val conversationId = sp.conversation?.id val postMetisContext = metisContext.toPostMetisContext( serverId = host, clientSidePostId = clientSidePostId, serverSidePostId = standalonePostId, + conversationId = conversationId?: metisContext.conversationId, postingType = BasePostingEntity.PostingType.STANDALONE ) @@ -502,7 +518,6 @@ internal class MetisStorageServiceImpl( answerServerIds = sp.answers.orEmpty().mapNotNull { it.id } ) } - for (ap in sp.answers.orEmpty()) { val answerPostId = ap.id ?: continue diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt index a35aa4177..5b2431e2d 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationChatListScreen.kt @@ -2,8 +2,18 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -29,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import de.tum.informatics.www1.artemis.native_app.core.data.isSuccess @@ -106,9 +117,10 @@ internal fun ConversationChatListScreen( MetisChatList( modifier = Modifier .fillMaxSize() - .padding(padding), + .imePadding() + .padding(bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()), viewModel = viewModel, - listContentPadding = PaddingValues(), + listContentPadding = PaddingValues(top = padding.calculateTopPadding()), onClickViewPost = onClickViewPost, isReplyEnabled = isReplyEnabled, state = chatListState, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationThreadScreen.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationThreadScreen.kt index 4bf13c8d0..e4d194d21 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationThreadScreen.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationThreadScreen.kt @@ -1,9 +1,14 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -58,13 +63,15 @@ internal fun ConversationThreadScreen( Column( modifier = Modifier .fillMaxSize() - .padding(padding) + .imePadding() + .padding(bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()), ) { CompositionLocalProvider(LocalReplyAutoCompleteHintProvider provides viewModel) { MetisThreadUi( modifier = Modifier .fillMaxWidth() .weight(1f), + listContentPadding = PaddingValues(top = padding.calculateTopPadding()), viewModel = viewModel ) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt index f6c320231..0cd0a62cd 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt @@ -1,7 +1,9 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui +import android.content.Context import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewModelScope +import coil.ImageLoader 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.NetworkResponse @@ -25,6 +27,7 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Programmin import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.TextExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.UnknownExercise +import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.DefaultImageProvider import de.tum.informatics.www1.artemis.native_app.core.ui.serverUrlStateFlow import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R @@ -42,7 +45,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.ReplyAutoCompleteHintProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.ConversationThreadUseCase import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostAction +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisCrudAction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.StandalonePostId import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.ConversationWebsocketDto @@ -233,7 +236,7 @@ internal open class ConversationViewModel( clientId.filterSuccess() ) { conversationDataState, clientId -> websocketProvider.subscribeToConversationUpdates(clientId, metisContext.courseId) - .filter { it.crudAction == MetisPostAction.UPDATE } + .filter { it.crudAction == MetisCrudAction.UPDATE } .map> { DataState.Success(it.conversation) } .onStart { emit(conversationDataState) } } @@ -270,12 +273,14 @@ internal open class ConversationViewModel( // Receive websocket updates and store them in the db. viewModelScope.launch(coroutineContext) { - serverConfigurationService.host.collect { host -> - webSocketUpdateUseCase.updatePosts( - host = host, - context = MetisContext.Conversation(courseId, conversationId) - ) - } + combine(serverConfigurationService.host, clientId.filterSuccess()) { host, clientId -> host to clientId } + .collect { (host, clientId) -> + webSocketUpdateUseCase.updatePosts( + host = host, + context = metisContext, + clientId = clientId + ) + } } } @@ -702,4 +707,12 @@ internal open class ConversationViewModel( fun updateOpenedThread(newPostId: StandalonePostId?) { _postId.value = newPostId } + + fun createMarkdownImageLoader(context: Context): Deferred { + return viewModelScope.async(coroutineContext) { + val imageProvider = DefaultImageProvider() + val authorizationToken = accountService.authToken.first() + imageProvider.createImageLoader(context, authorizationToken) + } + } } \ No newline at end of file diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationWebSocketUpdateUseCase.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationWebSocketUpdateUseCase.kt index 731139a2b..1afe25d8b 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationWebSocketUpdateUseCase.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationWebSocketUpdateUseCase.kt @@ -2,9 +2,10 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostAction import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.MetisStorageService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisCrudAction +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostDTO /** * Manages updates to the conversation over the web socket. @@ -19,31 +20,46 @@ class ConversationWebSocketUpdateUseCase( */ suspend fun updatePosts( host: String, - context: MetisContext + context: MetisContext, + clientId: Long ) { - metisService.subscribeToPostUpdates(context).collect { websocketData -> + metisService.subscribeToPostUpdates( + courseId = context.courseId, + clientId = clientId + ).collect { websocketData -> if (websocketData is WebsocketProvider.WebsocketData.Message) { - val dto = websocketData.message - when (dto.action) { - MetisPostAction.CREATE -> { - metisStorageService.insertLiveCreatedPost(host, context, dto.post) - } + updateDatabaseWithDto( + dto = websocketData.message, + context = context, + host = host + ) + } + } + } - MetisPostAction.UPDATE -> { - metisStorageService.updatePost(host, context, dto.post) - } + private suspend fun updateDatabaseWithDto( + dto: MetisPostDTO, + context: MetisContext, + host: String + ) { + when (dto.action) { + MetisCrudAction.CREATE -> { + metisStorageService.insertLiveCreatedPost(host, context, dto.post) + } - MetisPostAction.DELETE -> { - metisStorageService.deletePosts( - host, - listOf(dto.post.id ?: return@collect) - ) - } + MetisCrudAction.UPDATE -> { + metisStorageService.updatePost(host, context, dto.post) + } - MetisPostAction.NEW_MESSAGE -> { + MetisCrudAction.DELETE -> { + metisStorageService.deletePosts( + host, + listOf(dto.post.id ?: return) + ) + } - } - } + MetisCrudAction.NEW_MESSAGE -> { + // Nothing to do here. Only relevant for the conversation overview. } } } 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 235d245d9..900010940 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 @@ -16,19 +16,27 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource 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 coil.ImageLoader +import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.ProvideMarkwon import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.EmojiService 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 import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.DisplayPostOrder @@ -82,33 +90,41 @@ internal fun MetisChatList( val conversationDataState by viewModel.latestUpdatedConversation.collectAsState() + val context = LocalContext.current + var imageLoader: ImageLoader? by remember { mutableStateOf(null) } + LaunchedEffect(true) { + imageLoader = viewModel.createMarkdownImageLoader(context).await() + } + val updatedTitle by remember(conversationDataState) { derivedStateOf { conversationDataState.bind { it.humanReadableName }.orElse("Conversation") } } - MetisChatList( - modifier = modifier, - initialReplyTextProvider = viewModel, - posts = posts.asPostsDataState(), - clientId = clientId, - hasModerationRights = hasModerationRights, - isAtLeastTutorInCourse = isAtLeastTutorInCourse, - listContentPadding = listContentPadding, - serverUrl = serverUrl, - courseId = viewModel.courseId, - state = state, - bottomItem = bottomItem, - isReplyEnabled = isReplyEnabled, - onCreatePost = viewModel::createPost, - onEditPost = viewModel::editPost, - onDeletePost = viewModel::deletePost, - onRequestReactWithEmoji = viewModel::createOrDeleteReaction, - onClickViewPost = onClickViewPost, - onRequestRetrySend = viewModel::retryCreatePost, - title = updatedTitle - ) + ProvideMarkwon(imageLoader) { + MetisChatList( + modifier = modifier, + initialReplyTextProvider = viewModel, + posts = posts.asPostsDataState(), + clientId = clientId, + hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, + listContentPadding = listContentPadding, + serverUrl = serverUrl, + courseId = viewModel.courseId, + state = state, + bottomItem = bottomItem, + isReplyEnabled = isReplyEnabled, + onCreatePost = viewModel::createPost, + onEditPost = viewModel::editPost, + onDeletePost = viewModel::deletePost, + onRequestReactWithEmoji = viewModel::createOrDeleteReaction, + onClickViewPost = onClickViewPost, + onRequestRetrySend = viewModel::retryCreatePost, + title = updatedTitle + ) + } } @Composable @@ -124,6 +140,7 @@ fun MetisChatList( serverUrl: String, courseId: Long, state: LazyListState, + emojiService: EmojiService = koinInject(), isReplyEnabled: Boolean, onCreatePost: () -> Deferred, onEditPost: (IStandalonePost, String) -> Deferred, @@ -155,7 +172,7 @@ fun MetisChatList( state = state, itemCount = posts.itemCount, order = DisplayPostOrder.REVERSED, - emojiService = koinInject(), + emojiService = emojiService, bottomItem = bottomItem ) { when (posts) { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt index 7eff3509b..b9d51bfd8 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt @@ -19,15 +19,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import coil.ImageLoader import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PostArtemisMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.LocalMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.ProvideMarkwon import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.EmojiService -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceStub import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ProvideEmojis import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.DisplayPostOrder import kotlinx.coroutines.launch -import org.koin.compose.koinInject /** * Handles scrolling down to new items if the list was scrolled down before the new items came in. @@ -119,13 +118,11 @@ internal fun MetisPostListHandler( PostArtemisMarkdownTransformer(serverUrl = strippedServerUrl, courseId = courseId) } - ProvideMarkwon { - ProvideEmojis(emojiService) { - CompositionLocalProvider(LocalMarkdownTransformer provides markdownTransformer) { - content() - } + ProvideEmojis(emojiService) { + CompositionLocalProvider(LocalMarkdownTransformer provides markdownTransformer) { + content() } } } } -} +} \ No newline at end of file diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt index d2bfdfb21..6a1643f64 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost data class PostActions( @@ -46,24 +45,24 @@ fun rememberPostActions( onRequestRetrySend, clipboardManager ) { - if (post != null) { - val doesPostExistOnServer = post.serverPostId != null - val hasEditPostRights = hasModerationRights || post.authorId == clientId - val hasResolvePostRights = isAtLeastTutorInCourse || post.authorId == clientId - - PostActions( - requestEditPost = if (doesPostExistOnServer && hasEditPostRights) onRequestEdit else null, - requestDeletePost = if (hasEditPostRights) onRequestDelete else null, - onClickReaction = if (doesPostExistOnServer) onClickReaction else null, - onCopyText = { - clipboardManager.setText(AnnotatedString(post.content.orEmpty())) - }, - onReplyInThread = if (doesPostExistOnServer) onReplyInThread else null, - onResolvePost = if (hasResolvePostRights) onResolvePost else null, - onRequestRetrySend = onRequestRetrySend - ) - } else { - PostActions() + if (post == null) { + return@remember PostActions() } + + val doesPostExistOnServer = post.serverPostId != null + val isPostAuthor = post.authorId == clientId + val hasResolvePostRights = isAtLeastTutorInCourse || post.authorId == clientId + + PostActions( + requestEditPost = if (doesPostExistOnServer && isPostAuthor) onRequestEdit else null, + requestDeletePost = if (isPostAuthor || hasModerationRights) onRequestDelete else null, + onClickReaction = if (doesPostExistOnServer) onClickReaction else null, + onCopyText = { + clipboardManager.setText(AnnotatedString(post.content.orEmpty())) + }, + onReplyInThread = if (doesPostExistOnServer) onReplyInThread else null, + onResolvePost = if (hasResolvePostRights) onResolvePost else null, + onRequestRetrySend = onRequestRetrySend + ) } } 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 474f6a443..e10dab188 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 @@ -7,12 +7,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -54,7 +55,6 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.LocalEmojiProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.getUnicodeForEmojiId -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IReaction @@ -75,6 +75,7 @@ internal fun PostContextBottomSheet( if (!displayAllEmojis) { ModalBottomSheet( modifier = Modifier.testTag(TEST_TAG_POST_CONTEXT_BOTTOM_SHEET), + contentWindowInsets = { WindowInsets.statusBars }, sheetState = rememberModalBottomSheetState(), onDismissRequest = onDismissRequest ) { @@ -83,7 +84,8 @@ internal fun PostContextBottomSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(start = Spacings.ScreenHorizontalSpacing, end = Spacings.ScreenHorizontalSpacing, bottom = 40.dp) + .padding(horizontal = Spacings.ScreenHorizontalSpacing) + .padding(bottom = 40.dp) ) { postActions.onClickReaction?.let { onClickReaction -> EmojiReactionBar( 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 8914c06db..90cb8b9e4 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 @@ -16,14 +16,20 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Divider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import coil.ImageLoader import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.data.isSuccess import de.tum.informatics.www1.artemis.native_app.core.data.orNull @@ -45,7 +51,6 @@ 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.dto.IAnswerPost 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 @@ -66,6 +71,7 @@ internal fun testTagForAnswerPost(answerPostId: String) = "answerPost$answerPost @Composable internal fun MetisThreadUi( modifier: Modifier, + listContentPadding: PaddingValues, viewModel: ConversationViewModel ) { val postDataState: DataState by viewModel.threadUseCase.post.collectAsState() @@ -76,6 +82,12 @@ internal fun MetisThreadUi( val hasModerationRights by viewModel.hasModerationRights.collectAsState() val isAtLeastTutorInCourse by viewModel.isAtLeastTutorInCourse.collectAsState() + val context = LocalContext.current + var imageLoader: ImageLoader? by remember { mutableStateOf(null) } + LaunchedEffect(true) { + imageLoader = viewModel.createMarkdownImageLoader(context).await() + } + postDataState.bind { it.serverPostId }.orNull()?.let { serverSidePostId -> ReportVisibleMetisContext( remember( @@ -89,48 +101,51 @@ internal fun MetisThreadUi( val conversationDataState by viewModel.conversation.collectAsState() - MetisThreadUi( - modifier = modifier, - courseId = viewModel.courseId, - initialReplyTextProvider = viewModel, - conversationDataState = conversationDataState, - postDataState = postDataState, - isAtLeastTutorInCourse = isAtLeastTutorInCourse, - hasModerationRights = hasModerationRights, - serverUrl = serverUrl, - emojiService = koinInject(), - clientId = clientId, - onCreatePost = viewModel::createReply, - onEditPost = { post, newText -> - val parentPost = postDataState.orNull() + ProvideMarkwon(imageLoader) { + MetisThreadUi( + modifier = modifier, + courseId = viewModel.courseId, + initialReplyTextProvider = viewModel, + conversationDataState = conversationDataState, + postDataState = postDataState, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, + hasModerationRights = hasModerationRights, + listContentPadding = listContentPadding, + serverUrl = serverUrl, + emojiService = koinInject(), + clientId = clientId, + onCreatePost = viewModel::createReply, + onEditPost = { post, newText -> + val parentPost = postDataState.orNull() + + when (post) { + is AnswerPostPojo -> { + if (parentPost == null) CompletableDeferred( + MetisModificationFailure.UPDATE_POST + ) else viewModel.editAnswerPost(parentPost, post, newText) + } + + is PostPojo -> viewModel.editPost(post, newText) + else -> throw NotImplementedError() + } + }, + onResolvePost = { post -> + val parentPost = postDataState.orNull() - when (post) { - is AnswerPostPojo -> { + if (post is AnswerPostPojo) { if (parentPost == null) CompletableDeferred( MetisModificationFailure.UPDATE_POST - ) else viewModel.editAnswerPost(parentPost, post, newText) + ) else viewModel.toggleResolvePost(parentPost, post) + } else { + throw NotImplementedError() } - - is PostPojo -> viewModel.editPost(post, newText) - else -> throw NotImplementedError() - } - }, - onResolvePost = { post -> - val parentPost = postDataState.orNull() - - if (post is AnswerPostPojo) { - if (parentPost == null) CompletableDeferred( - MetisModificationFailure.UPDATE_POST - ) else viewModel.toggleResolvePost(parentPost, post) - } else { - throw NotImplementedError() - } - }, - onDeletePost = viewModel::deletePost, - onRequestReactWithEmoji = viewModel::createOrDeleteReaction, - onRequestReload = viewModel::requestReload, - onRequestRetrySend = viewModel::retryCreateReply - ) + }, + onDeletePost = viewModel::deletePost, + onRequestReactWithEmoji = viewModel::createOrDeleteReaction, + onRequestReload = viewModel::requestReload, + onRequestRetrySend = viewModel::retryCreateReply + ) + } } @Composable @@ -142,6 +157,7 @@ internal fun MetisThreadUi( conversationDataState: DataState, hasModerationRights: Boolean, isAtLeastTutorInCourse: Boolean, + listContentPadding: PaddingValues, serverUrl: String, emojiService: EmojiService, initialReplyTextProvider: InitialReplyTextProvider, @@ -191,7 +207,7 @@ internal fun MetisThreadUi( itemCount = post.orderedAnswerPostings.size, order = DisplayPostOrder.REGULAR, emojiService = emojiService, - bottomItem = post.orderedAnswerPostings.lastOrNull(), + bottomItem = post.orderedAnswerPostings.lastOrNull() ) { PostAndRepliesList( modifier = Modifier @@ -200,6 +216,7 @@ internal fun MetisThreadUi( post = post, hasModerationRights = hasModerationRights, isAtLeastTutorInCourse = isAtLeastTutorInCourse, + listContentPadding = listContentPadding, clientId = clientId, onRequestReactWithEmoji = onRequestReactWithEmojiDelegate, onRequestEdit = onEditPostDelegate, @@ -234,6 +251,7 @@ private fun PostAndRepliesList( post: PostPojo, hasModerationRights: Boolean, isAtLeastTutorInCourse: Boolean, + listContentPadding: PaddingValues, clientId: Long, onRequestEdit: (IBasePost) -> Unit, onRequestDelete: (IBasePost) -> Unit, @@ -267,59 +285,57 @@ private fun PostAndRepliesList( ) } - ProvideMarkwon { - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - state = state - ) { - item { - val postActions = rememberPostActions(post) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - PostWithBottomSheet( - modifier = Modifier.padding(top = 8.dp), - post = post, - postItemViewType = PostItemViewType.ThreadContextPostItem, - postActions = postActions, - displayHeader = true, - clientId = clientId, - onClick = {} - ) - - Divider() - - Box {} - } - } - - itemsIndexed( - post.orderedAnswerPostings, - key = { _, post -> post.postId }) { index, answerPost -> - val postActions = rememberPostActions(answerPost) + LazyColumn( + modifier = modifier, + contentPadding = listContentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + state = state + ) { + item { + val postActions = rememberPostActions(post) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { PostWithBottomSheet( - modifier = Modifier - .fillMaxWidth() - .testTag(testTagForAnswerPost(answerPost.clientPostId)), - post = answerPost, + modifier = Modifier.padding(top = 8.dp), + post = post, + postItemViewType = PostItemViewType.ThreadContextPostItem, postActions = postActions, - postItemViewType = PostItemViewType.ThreadAnswerItem, + displayHeader = true, clientId = clientId, - displayHeader = shouldDisplayHeader( - index = index, - post = answerPost, - postCount = post.orderedAnswerPostings.size, - order = DisplayPostOrder.REGULAR, - getPost = post.orderedAnswerPostings::get - ), onClick = {} ) + + Divider() + + Box {} } } + + itemsIndexed( + post.orderedAnswerPostings, + key = { _, post -> post.postId }) { index, answerPost -> + val postActions = rememberPostActions(answerPost) + + PostWithBottomSheet( + modifier = Modifier + .fillMaxWidth() + .testTag(testTagForAnswerPost(answerPost.clientPostId)), + post = answerPost, + postActions = postActions, + postItemViewType = PostItemViewType.ThreadAnswerItem, + clientId = clientId, + displayHeader = shouldDisplayHeader( + index = index, + post = answerPost, + postCount = post.orderedAnswerPostings.size, + order = DisplayPostOrder.REGULAR, + getPost = post.orderedAnswerPostings::get + ), + onClick = {} + ) + } } } diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/BaseChatUItest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/BaseChatUItest.kt new file mode 100644 index 000000000..f47eb705b --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/BaseChatUItest.kt @@ -0,0 +1,135 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import de.tum.informatics.www1.artemis.native_app.core.data.DataState +import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.model.account.User +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceStub +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.ChatListItem +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisChatList +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.PostsDataState +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.MetisThreadUi +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.OneToOneChat +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.datetime.Clock + +abstract class BaseChatUItest : BaseComposeTest() { + + val clientId = 20L + + private val course: Course = Course(id = 1) + val conversation = OneToOneChat(id = 2) + + val answers = (0..2).map { index -> + AnswerPostPojo( + parentPostId = "client-id", + postId = "answer-client-id-$index", + resolvesPost = false, + basePostingCache = AnswerPostPojo.BasePostingCache( + serverPostId = index.toLong(), + authorId = clientId, + creationDate = Clock.System.now(), + updatedDate = null, + content = "Answer Post content $index", + authorRole = UserRole.USER, + authorName = "author name" + ), + reactions = emptyList(), + serverPostIdCache = AnswerPostPojo.ServerPostIdCache( + serverPostId = index.toLong() + ) + ) + } + + val posts = (0..2).map { index -> + PostPojo( + clientPostId = "client-id-$index", + serverPostId = index.toLong(), + content = "Post content $index", + resolved = false, + updatedDate = null, + creationDate = Clock.System.now(), + authorId = clientId, + title = null, + authorName = "author name", + authorRole = UserRole.USER, + courseWideContext = null, + tags = emptyList(), + answers = if (index == 0) answers else emptyList(), + reactions = emptyList() + ) + } + + fun setupThreadUi( + post: PostPojo, + onResolvePost: ((IBasePost) -> Deferred)? + ) { + composeTestRule.setContent { + MetisThreadUi( + modifier = Modifier.fillMaxSize(), + courseId = course.id!!, + clientId = clientId, + postDataState = DataState.Success(post), + conversationDataState = DataState.Success(conversation), + hasModerationRights = false, + isAtLeastTutorInCourse = false, + listContentPadding = PaddingValues(), + serverUrl = "", + emojiService = EmojiServiceStub, + initialReplyTextProvider = remember { TestInitialReplyTextProvider() }, + onCreatePost = { CompletableDeferred() }, + onEditPost = { _, _ -> CompletableDeferred() }, + onResolvePost = onResolvePost, + onDeletePost = { CompletableDeferred() }, + onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, + onRequestReload = {}, + onRequestRetrySend = { _, _ -> }, + ) + } + } + + fun setupChatUi( + posts: List, + currentUser: User = User(id = clientId), + hasModerationRights: Boolean = false + ) { + composeTestRule.setContent { + val list = posts.map { post -> ChatListItem.PostChatListItem(post) }.toMutableList() + MetisChatList( + modifier = Modifier.fillMaxSize(), + initialReplyTextProvider = remember { TestInitialReplyTextProvider() }, + posts = PostsDataState.Loaded.WithList(list, PostsDataState.NotLoading), + clientId = currentUser.id, + hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = false, + listContentPadding = PaddingValues(), + serverUrl = "", + courseId = course.id!!, + state = rememberLazyListState(), + emojiService = EmojiServiceStub, + bottomItem = null, + isReplyEnabled = true, + onCreatePost = { CompletableDeferred() }, + onEditPost = { _, _ -> CompletableDeferred() }, + onDeletePost = { CompletableDeferred() }, + onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, + onClickViewPost = {}, + onRequestRetrySend = { _ -> }, + title = "Title" + ) + } + } + +} diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt index da5f2f06d..27198cde3 100644 --- a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt @@ -1,105 +1,41 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.hasAnyChild -import androidx.compose.ui.test.hasScrollAction -import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onParent -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.test.printToLog -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest -import de.tum.informatics.www1.artemis.native_app.core.data.DataState -import de.tum.informatics.www1.artemis.native_app.core.model.Course -import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceStub -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.MetisThreadUi +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.TEST_TAG_THREAD_LIST import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole -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.db.pojo.AnswerPostPojo -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.runBlocking -import kotlinx.datetime.Clock +import org.junit.Ignore import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@Category(EndToEndTest::class) +@Category(UnitTest::class) @RunWith(RobolectricTestRunner::class) -class ConversationAnswerMessagesUITest : BaseComposeTest() { - - private val clientId = 20L - - private val course: Course = Course(id = 1) - private val conversation = OneToOneChat(id = 2) - - private val answers = (0..2).map { index -> - AnswerPostPojo( - parentPostId = "client-id", - postId = "answer-client-id-$index", - resolvesPost = false, - basePostingCache = AnswerPostPojo.BasePostingCache( - serverPostId = index.toLong(), - authorId = clientId, - creationDate = Clock.System.now(), - updatedDate = null, - content = "Answer Post content $index", - authorRole = UserRole.USER, - authorName = "author name" - ), - reactions = emptyList(), - serverPostIdCache = AnswerPostPojo.ServerPostIdCache( - serverPostId = index.toLong() - ) - ) - } - - private val post = PostPojo( - clientPostId = "client-id", - serverPostId = 12, - content = "Post content", - resolved = false, - updatedDate = null, - creationDate = Clock.System.now(), - authorId = clientId, - title = null, - authorName = "author name", - authorRole = UserRole.USER, - courseWideContext = null, - tags = emptyList(), - answers = answers, - reactions = emptyList() - ) +@Ignore("There is an open issue about onClick events not working for the ModalBottomSheetLayout with" + + "the robolectric test runner. Enable this test again as soon as the following issue is resolved:" + + "https://github.com/robolectric/robolectric/issues/9595") +class ConversationAnswerMessagesUITest : BaseChatUItest() { private fun testTagForAnswerPost(answerPostId: String) = "answerPost$answerPostId" + private val post = posts[0] + @Test fun `test GIVEN post is not resolved WHEN resolving the post THEN the post is resolved with the first answer post`() { var resolvedPost: IBasePost? = null - setupUi(post) { post -> + setupThreadUi(post) { post -> resolvedPost = post CompletableDeferred() } @@ -118,7 +54,7 @@ class ConversationAnswerMessagesUITest : BaseComposeTest() { fun `test GIVEN post is not resolved WHEN resolving the post THEN the post is resolved with the third answer post`() { var resolvedPost: IBasePost? = null - setupUi(post) { post -> + setupThreadUi(post) { post -> resolvedPost = post CompletableDeferred() } @@ -145,7 +81,7 @@ class ConversationAnswerMessagesUITest : BaseComposeTest() { var unresolvedPost: IBasePost? = null - setupUi(resolvedPost) { post -> + setupThreadUi(resolvedPost) { post -> unresolvedPost = post CompletableDeferred() } @@ -162,7 +98,7 @@ class ConversationAnswerMessagesUITest : BaseComposeTest() { @Test fun `test GIVEN the post is not resolved and no answer post is resolving THEN the post is shown as not resolved and no answer post is shown as resolving`() { - setupUi(post) { CompletableDeferred() } + setupThreadUi(post) { CompletableDeferred() } composeTestRule.onNodeWithText(post.content).assertExists() for (answer in answers) { @@ -185,7 +121,7 @@ class ConversationAnswerMessagesUITest : BaseComposeTest() { answers = modifiedAnswers ) - setupUi(resolvedPost) { CompletableDeferred() } + setupThreadUi(resolvedPost) { CompletableDeferred() } val resolvesAssertion = hasAnyChild(hasText(context.getString(R.string.post_resolves))) @@ -199,31 +135,4 @@ class ConversationAnswerMessagesUITest : BaseComposeTest() { .assert(if (i == resolvingIndex) resolvesAssertion else resolvesAssertion.not()) } } - - private fun setupUi( - post: PostPojo, - onResolvePost: ((IBasePost) -> Deferred)? - ) { - composeTestRule.setContent { - MetisThreadUi( - modifier = Modifier.fillMaxSize(), - courseId = course.id!!, - clientId = clientId, - postDataState = DataState.Success(post), - conversationDataState = DataState.Success(conversation), - hasModerationRights = false, - isAtLeastTutorInCourse = false, - serverUrl = "", - emojiService = EmojiServiceStub, - initialReplyTextProvider = remember { TestInitialReplyTextProvider() }, - onCreatePost = { CompletableDeferred() }, - onEditPost = { _, _ -> CompletableDeferred() }, - onResolvePost = onResolvePost, - onDeletePost = { CompletableDeferred() }, - onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, - onRequestReload = {}, - onRequestRetrySend = { _, _ -> }, - ) - } - } } \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesBaseTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesBaseTest.kt index de8f24e65..6da5e9e2a 100644 --- a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesBaseTest.kt +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesBaseTest.kt @@ -1,26 +1,21 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasTestTag -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.feature.login.test.user2Username -import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisModificationService -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.TEST_TAG_REPLY_TEXT_FIELD +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.DisplayPriority import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout +import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest import kotlinx.datetime.Clock import org.junit.Before import org.koin.test.get -@OptIn(ExperimentalTestApi::class) abstract class ConversationMessagesBaseTest : ConversationBaseTest() { protected val metisModificationService: MetisModificationService get() = get() @@ -37,17 +32,15 @@ abstract class ConversationMessagesBaseTest : ConversationBaseTest() { override fun setup() { super.setup() - runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversation = conversationService.createOneToOneConversation( - courseId = course.id!!, - partner = user2Username, - authToken = accessToken, - serverUrl = testServerUrl - ).orThrow("Could not create one to one conversation") + runBlockingWithTestTimeout { + conversation = conversationService.createOneToOneConversation( + courseId = course.id!!, + partner = user2Username, + authToken = accessToken, + serverUrl = testServerUrl + ).orThrow("Could not create one to one conversation") - metisContext = MetisContext.Conversation(course.id!!, conversation.id) - } + metisContext = MetisContext.Conversation(course.id!!, conversation.id) } } diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageBaseTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageBaseTest.kt new file mode 100644 index 000000000..bef36786f --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageBaseTest.kt @@ -0,0 +1,87 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.impl + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.testing.asSnapshot +import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.model.account.User +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.OneToOneChat +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.metistest.MetisDatabaseProviderMock +import kotlinx.datetime.Clock + +abstract class MetisStorageBaseTest { + + private val databaseProviderMock = MetisDatabaseProviderMock(InstrumentationRegistry.getInstrumentation().context) + internal val sut = MetisStorageServiceImpl(databaseProviderMock) + + internal val host = "host" + + private val author = User(id = 20, name = "AuthorName") + private val parentClientPostId = "parent-client-id-0" + internal val answerClientPostId = "answer-client-id-0" + + private val course: MetisContext.Course = MetisContext.Course(courseId = 1) + internal val conversation = OneToOneChat(id = 2) + internal val metisContext = MetisContext.Conversation(course.courseId, conversation.id) + + internal val conversationTwo = OneToOneChat(id = 3) + internal val metisContextTwo = MetisContext.Conversation(course.courseId, conversationTwo.id) + + internal val localAnswerPojo = AnswerPostPojo( + parentPostId = parentClientPostId, + postId = answerClientPostId, + resolvesPost = false, + basePostingCache = AnswerPostPojo.BasePostingCache( + serverPostId = 0, + authorId = author.id, + creationDate = Clock.System.now(), + updatedDate = null, + content = "Answer post content 0", + authorRole = UserRole.USER, + authorName = author.name!! + ), + reactions = emptyList(), + serverPostIdCache = AnswerPostPojo.ServerPostIdCache( + serverPostId = null // Only local answer post, no server id + ) + ) + + internal val basePostPojo = PostPojo( + clientPostId = parentClientPostId, + serverPostId = 0, + content = "Base post content", + resolved = false, + updatedDate = null, + creationDate = Clock.System.now(), + authorId = author.id, + title = null, + authorName = author.name!!, + authorRole = UserRole.USER, + courseWideContext = null, + tags = emptyList(), + answers = emptyList(), + reactions = emptyList() + ) + + internal val basePost = StandalonePost(basePostPojo, conversation) + internal val basePostTwo = StandalonePost(basePostPojo, conversationTwo) + internal val localAnswer = AnswerPost(localAnswerPojo, basePost) + + internal suspend fun getStoredPosts(metisContext: MetisContext) = sut.getStoredPosts( + serverId = host, + metisContext = metisContext + ).loadAsList() + + internal suspend fun PagingSource.loadAsList(): List { + return Pager(PagingConfig(pageSize = 10), pagingSourceFactory = { this }).flow.asSnapshot { + scrollTo(50) + } + } +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImplTestUpgradeLocalAnswerPost.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImplTestUpgradeLocalAnswerPost.kt new file mode 100644 index 000000000..57ecaa244 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceImplTestUpgradeLocalAnswerPost.kt @@ -0,0 +1,132 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.impl + + +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Category(UnitTest::class) +@RunWith(RobolectricTestRunner::class) +class MetisStorageServiceImplUpgradeLocalAnswerPostTest : MetisStorageBaseTest() { + + private lateinit var basePostUpdated: StandalonePost + private lateinit var answerUpdated: AnswerPost + + @Test + fun testInsertClientSidePost() = runTest { + // GIVEN: A base post + sut.insertOrUpdatePosts( + host = host, + metisContext = metisContext, + posts = listOf(basePost), + ) + + // WHEN: Inserting a client side answer post + sut.insertClientSidePost( + host = host, + metisContext = metisContext, + post = localAnswer, + clientSidePostId = answerClientPostId + ) + + // THEN: Both the base post and the answer post are stored + assertStoredContentIsTheSame() + } + + @Test + fun testUpgradeClientSideAnswerPost() = runTest { + // GIVEN: A post with a new only local answer post + setupPostWithLocalAnswer() + + // WHEN: insertOrUpdatePosts is called before upgradeClientSideAnswerPost. + updateAnswerPostWithServerId() + + // Called by the WebSocket + sut.updatePost( + host = host, + metisContext = metisContext, + post = basePostUpdated + ) + + // Called by SendConversationPostWorker + sut.upgradeClientSideAnswerPost( + host = host, + metisContext = metisContext, + clientSidePostId = answerClientPostId, + post = answerUpdated + ) + + // THEN: Content stays the same and the upgrade is successful + assertStoredContentIsTheSame() + assertUpgradeSuccessful() + } + + @Test + fun testUpgradeClientSideAnswerPost2() = runTest { + // GIVEN: A post with a new only local answer post + setupPostWithLocalAnswer() + + // WHEN: upgradeClientSideAnswerPost is called before updatePost. + updateAnswerPostWithServerId() + + // Called by SendConversationPostWorker + sut.upgradeClientSideAnswerPost( + host = host, + metisContext = metisContext, + clientSidePostId = answerClientPostId, + post = answerUpdated + ) + + // Called by the WebSocket + sut.updatePost( + host = host, + metisContext = metisContext, + post = basePostUpdated + ) + + // THEN: Content stays the same and the upgrade is successful + assertStoredContentIsTheSame() + assertUpgradeSuccessful() + } + + private suspend fun setupPostWithLocalAnswer() { + sut.insertOrUpdatePosts( + host = host, + metisContext = metisContext, + posts = listOf(basePost) + ) + sut.insertClientSidePost( + host = host, + metisContext = metisContext, + clientSidePostId = answerClientPostId, + post = localAnswer + ) + } + + + private fun updateAnswerPostWithServerId() { + val answerPojoUpdated = localAnswerPojo.copy(serverPostIdCache = localAnswerPojo.serverPostIdCache.copy(serverPostId = 1)) + basePostUpdated = StandalonePost(basePostPojo, conversation) + answerUpdated = AnswerPost(answerPojoUpdated, basePostUpdated) + basePostUpdated = basePostUpdated.copy(answers = listOf(answerUpdated)) + } + + private suspend fun assertStoredContentIsTheSame() { + val posts = getStoredPosts(metisContext) + assertEquals(1, posts.size) + assertEquals(basePostPojo.content, posts.first().content) + assertEquals(1, posts.first().answers.size) + assertEquals(localAnswerPojo.content, posts.first().answers.first().content) + } + + private suspend fun assertUpgradeSuccessful() { + val posts = getStoredPosts(metisContext) + assertEquals(answerUpdated.serverPostId, posts.first().answers.first().serverPostId) + } +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceTestLiveCreation.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceTestLiveCreation.kt new file mode 100644 index 000000000..7b933c23b --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/impl/MetisStorageServiceTestLiveCreation.kt @@ -0,0 +1,62 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.impl + +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@Category(UnitTest::class) +@RunWith(RobolectricTestRunner::class) +class MetisStorageServiceTestLiveCreation : MetisStorageBaseTest() { + + @Test + fun testInsertLiveCreatedPostWithExistingPost() = runTest { + // GIVEN: A base post already exists + sut.insertOrUpdatePosts( + host = host, + metisContext = metisContext, + posts = listOf(basePost), + ) + + // WHEN: Inserting a live-created post with the same id into the same conversation + sut.insertLiveCreatedPost( + host = host, + metisContext = metisContext, + post = basePost + ) + + // THEN: No post should be inserted and the existing posts should not be modified + val posts = getStoredPosts(metisContext) + val standalonePost = StandalonePost(posts.first(), conversation) + assertEquals(1, posts.size) + assertEquals(basePost.content, standalonePost.content) + assertEquals(basePost.id, standalonePost.id) + assertEquals(basePost.conversation, standalonePost.conversation) + } + + @Test + fun testInsertLiveCreatedPost() = runTest { + // GIVEN: An empty conversation + + // WHEN: Inserting a live-created post into a different conversation than the current + sut.insertLiveCreatedPost( + host = host, + metisContext = metisContext, + post = basePostTwo + ) + + // THEN: The post should be inserted and matched to the correct conversation + val posts = getStoredPosts(metisContextTwo) + assertEquals(1, posts.size) + assertEquals(basePostTwo.content, posts.first().content) + + val createdPost = posts[0] + + val createdStandalonePost = StandalonePost(createdPost, conversationTwo) + assertEquals(basePostTwo.conversation?.id, createdStandalonePost.conversation?.id) + } +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/ConversationBottomSheetUiTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/ConversationBottomSheetUiTest.kt new file mode 100644 index 000000000..2f01d35ce --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/ConversationBottomSheetUiTest.kt @@ -0,0 +1,119 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.core.model.account.User +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.BaseChatUItest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith + +@Category(UnitTest::class) +@RunWith(AndroidJUnit4::class) +class ConversationBottomSheetUiTest : BaseChatUItest() { + + private val currentUser = User(id = clientId) + private val otherUser = User(id = 1234) + + private val postContent = "Post content" + + @Test + fun `test GIVEN a post WHEN long pressing the post THEN Edit action is shown`() { + setupChatUi( + posts = listOf(StandalonePost( + id = 1, + author = currentUser, + content = postContent, + )), + currentUser = currentUser + ) + + composeTestRule.assertPostActionVisibility(R.string.post_edit, isVisible = true) + } + + + @Test + fun `test GIVEN a user with moderation-rights WHEN long pressing the post THEN Edit action is not shown`() { + setupChatUi( + posts = listOf(StandalonePost( + id = 1, + author = otherUser, + content = postContent, + )), + currentUser = currentUser, + hasModerationRights = true + ) + + composeTestRule.assertPostActionVisibility(R.string.post_edit, isVisible = false) + } + + @Test + fun `test GIVEN a user with moderation-rights WHEN long pressing the post THEN delete option is shown`() { + setupChatUi( + posts = listOf(StandalonePost( + id = 1, + author = otherUser, + content = postContent, + )), + currentUser = currentUser, + hasModerationRights = true + ) + + composeTestRule.assertPostActionVisibility(R.string.post_delete, isVisible = true) + } + + @Test + fun `test GIVEN a post WHEN long pressing the post as the post author THEN delete option is shown`() { + setupChatUi( + posts = listOf(StandalonePost( + id = 1, + author = currentUser, + content = postContent, + )), + currentUser = currentUser + ) + + composeTestRule.assertPostActionVisibility(R.string.post_delete, isVisible = true) + } + + @Test + fun `test GIVEN a post WHEN long pressing the post as non-moderator THEN delete option is not shown`() { + setupChatUi( + posts = listOf(StandalonePost( + id = 1, + author = otherUser, + content = postContent, + )), + currentUser = currentUser + ) + + composeTestRule.assertPostActionVisibility(R.string.post_delete, isVisible = false) + } + + private fun ComposeTestRule.assertPostActionVisibility( + stringResId: Int, + isVisible: Boolean + ) { + onNodeWithText(postContent) + .performSemanticsAction(SemanticsActions.OnLongClick) + + onNodeWithTag(TEST_TAG_POST_CONTEXT_BOTTOM_SHEET) + .assertIsDisplayed() + + val actionNode = onNodeWithText(context.getString(stringResId)) + if (isVisible) { + actionNode.assertExists().assertIsDisplayed() + } else { + actionNode.assertDoesNotExist() + } + } + +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldVisibilityUITest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldVisibilityUITest.kt new file mode 100644 index 000000000..687e5f0e3 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldVisibilityUITest.kt @@ -0,0 +1,45 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.BaseChatUItest +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.CompletableDeferred +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Category(UnitTest::class) +@RunWith(RobolectricTestRunner::class) +class ReplyTextFieldVisibilityUITest : BaseChatUItest() { + + @Test + fun `test GIVEN the thread view is shown containing one post and three answer posts WHEN the markdown text field is clicked THEN the keyboard is shown below the markdown text field`() { + setupThreadUi(posts[0]) { CompletableDeferred() } + runTest() + } + + @Test + fun `test GIVEN the chat list containing three posts is shown WHEN the markdown text field is clicked THEN the keyboard is shown below the markdown text field`() { + setupChatUi(posts) + runTest() + } + + private fun runTest() { + val markdownTextField = composeTestRule.onNodeWithTag(TEST_TAG_CAN_CREATE_REPLY) + val initialPosition = markdownTextField.fetchSemanticsNode().positionInRoot.y + + markdownTextField.performClick() + composeTestRule.waitForIdle() + + val newPosition = markdownTextField.fetchSemanticsNode().positionInRoot.y + + markdownTextField + .assertExists() + .assertIsDisplayed() + assertTrue("Text field should move up when the keyboard appears", newPosition < initialPosition) + } +} 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 15463df2f..61a95fb97 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 @@ -3,9 +3,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversat import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -103,7 +106,8 @@ internal fun BrowseChannelsScreen( BasicDataStateUi( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(top = padding.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars), dataState = channelsDataState, loadingText = stringResource(id = R.string.browse_channel_list_loading), failureText = stringResource(id = R.string.browse_channel_list_failure), 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 6f209ac66..0091182bd 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 @@ -7,6 +7,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -92,6 +93,7 @@ internal fun CreateChannelScreen( Column( modifier = Modifier .fillMaxSize() + .imePadding() .padding(paddingValues) .verticalScroll(rememberScrollState()) .padding(horizontal = Spacings.ScreenHorizontalSpacing), diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_personal_conversation/CreatePersonalConversationScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_personal_conversation/CreatePersonalConversationScreen.kt index 1fe03354b..816af3a5d 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_personal_conversation/CreatePersonalConversationScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_personal_conversation/CreatePersonalConversationScreen.kt @@ -1,6 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.create_personal_conversation import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -87,6 +88,7 @@ internal fun CreatePersonalConversationScreen( MemberSelection( modifier = Modifier .fillMaxSize() + .imePadding() .padding(padding) .padding(horizontal = Spacings.ScreenHorizontalSpacing), viewModel = viewModel 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 4863750f6..9c1be4744 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 @@ -277,7 +277,7 @@ private fun LazyListScope.conversationList( onNavigateToConversation: (conversationId: Long) -> Unit, onToggleMarkAsFavourite: (conversationId: Long, favorite: Boolean) -> Unit, onToggleHidden: (conversationId: Long, hidden: Boolean) -> Unit, - onToggleMuted: (conversationId: Long, muted: Boolean) -> Unit, + onToggleMuted: (conversationId: Long, muted: Boolean) -> Unit ) { if (!conversations.isExpanded) return items( @@ -300,7 +300,7 @@ private fun LazyListScope.conversationList( ) }, onToggleHidden = { onToggleHidden(conversation.id, !conversation.isHidden) }, - onToggleMuted = { onToggleMuted(conversation.id, !conversation.isMuted) }, + onToggleMuted = { onToggleMuted(conversation.id, !conversation.isMuted) } ) } } @@ -314,7 +314,7 @@ private fun ConversationListItem( onNavigateToConversation: () -> Unit, onToggleMarkAsFavourite: () -> Unit, onToggleHidden: () -> Unit, - onToggleMuted: () -> Unit, + onToggleMuted: () -> Unit ) { var isContextDialogShown by remember { mutableStateOf(false) } val onDismissRequest = { isContextDialogShown = false } @@ -547,4 +547,4 @@ private fun String.removeSectionPrefix(): String { } } return result.trim() -} +} \ No newline at end of file diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/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 960a3b505..a2fc5f4ed 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 @@ -7,10 +7,15 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -155,7 +160,8 @@ fun ConversationOverviewBody( Box( modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp) + .padding(16.dp) + .padding(bottom = 24.dp) ) { TextButton( modifier = Modifier.align(Alignment.Center), @@ -175,15 +181,16 @@ fun ConversationOverviewBody( } ConversationFabMenu( + canCreateChannel = canCreateChannel, onCreateChat = onRequestCreatePersonalConversation, onBrowseChannels = onRequestBrowseChannel, - onCreateChannel = onRequestAddChannel, - canCreateChannel = canCreateChannel + onCreateChannel = onRequestAddChannel ) } if (showCodeOfConduct) { ModalBottomSheet( + contentWindowInsets = { WindowInsets.statusBars }, onDismissRequest = { showCodeOfConduct = false } ) { CodeOfConductUi( @@ -200,17 +207,23 @@ fun ConversationOverviewBody( @Composable fun ConversationFabMenu( + canCreateChannel: Boolean, onCreateChat: () -> Unit, onBrowseChannels: () -> Unit, - onCreateChannel: () -> Unit, - canCreateChannel: Boolean + onCreateChannel: () -> Unit ) { var expanded by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() - .padding(16.dp), + .padding( + bottom = WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + 8.dp, + end = 16.dp + ) + .imePadding(), contentAlignment = Alignment.BottomEnd ) { Box { 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 83bf39bb8..cdcf8b958 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 @@ -21,7 +21,7 @@ import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvid import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ConversationCollections.ConversationCollection import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.service.storage.ConversationPreferenceService import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostAction +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisCrudAction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.ConversationWebsocketDto import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation @@ -281,13 +281,15 @@ class ConversationOverviewViewModel( is ServerSentConversationUpdate -> { val serverSentUpdate = update.update + // TODO: It seems like there are no updates received from the websocket -> investigate + when (serverSentUpdate.crudAction) { - MetisPostAction.CREATE, MetisPostAction.UPDATE -> { + MetisCrudAction.CREATE, MetisCrudAction.UPDATE -> { currentConversations[serverSentUpdate.conversation.id] = serverSentUpdate.conversation } - MetisPostAction.NEW_MESSAGE -> { + MetisCrudAction.NEW_MESSAGE -> { val isMetisContextVisible = visibleMetisContexts.value.any { visibleMetisContext -> val metisContext = visibleMetisContext.metisContext @@ -305,7 +307,7 @@ class ConversationOverviewViewModel( } } - MetisPostAction.DELETE -> { + MetisCrudAction.DELETE -> { currentConversations.remove(serverSentUpdate.conversation.id) } } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/members/ConversationMembersScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/members/ConversationMembersScreen.kt index 662c1a63a..eb6bdec30 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/members/ConversationMembersScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/members/ConversationMembersScreen.kt @@ -1,7 +1,10 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.members +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -61,7 +64,7 @@ fun ConversationMembersScreen( ConversationMembersBody( modifier = Modifier .fillMaxSize() - .padding(padding) + .consumeWindowInsets(WindowInsets.systemBars) .padding(top = 16.dp), courseId = courseId, conversationId = conversationId diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsBody.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsBody.kt index cc5136014..db639141c 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsBody.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsBody.kt @@ -2,9 +2,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -136,7 +139,8 @@ internal fun ConversationSettingsBody( Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .padding(bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { ConversationInfoSettings( diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsScreen.kt index 795755ce1..e87e15fdc 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/settings/overview/ConversationSettingsScreen.kt @@ -1,7 +1,11 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.overview +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Icon @@ -121,7 +125,9 @@ internal fun ConversationSettingsScreen( ConversationSettingsBody( modifier = Modifier .fillMaxSize() - .padding(paddingValues), + .imePadding() + .padding(top = paddingValues.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars), viewModel = viewModel, onRequestViewAllMembers = onRequestViewAllMembers, onRequestAddMembers = onRequestAddMembers, diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/members/ConversationMemberSettingsE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/members/ConversationMemberSettingsE2eTest.kt index b51c8f4e9..67f15900d 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/members/ConversationMemberSettingsE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/members/ConversationMemberSettingsE2eTest.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assert import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText @@ -15,10 +14,10 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.lifecycle.SavedStateHandle -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.feature.login.test.user1Username import de.tum.informatics.www1.artemis.native_app.feature.login.test.user2Username import de.tum.informatics.www1.artemis.native_app.feature.login.test.user3Username @@ -29,8 +28,6 @@ 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.testTagForMember import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith @@ -83,28 +80,24 @@ class ConversationMemberSettingsE2eTest : ConversationBaseTest() { composeTestRule .onNodeWithTag(testTagForMember(user1Username)) .performScrollTo() - .assert( - hasAnyDescendant(hasText(user1Username)) and hasAnyDescendant( - hasContentDescription(context.getString(R.string.conversation_members_content_description_moderator)) - ) - ) + .assert(hasText(user1Username)) + .assert(hasContentDescription(context.getString(R.string.conversation_members_content_description_moderator)) + ) composeTestRule .onNodeWithTag(testTagForMember(user2Username)) .performScrollTo() .assert( - hasAnyDescendant(hasText(user2Username)) and !hasAnyDescendant( + hasText(user2Username) and ! hasContentDescription(context.getString(R.string.conversation_members_content_description_moderator)) - ) ) composeTestRule .onNodeWithTag(testTagForMember(user2Username)) .performScrollTo() .assert( - hasAnyDescendant(hasText(user2Username)) and !hasAnyDescendant( + hasText(user2Username) and ! hasContentDescription(context.getString(R.string.conversation_members_content_description_moderator)) - ) ) } @@ -117,16 +110,14 @@ class ConversationMemberSettingsE2eTest : ConversationBaseTest() { @Test(timeout = DefaultTestTimeoutMillis) fun `can revoke moderation rights`() { - runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.grantModerationRights( - course.id!!, - channel, - user2Username, - accessToken, - testServerUrl - ).orThrow("Could not promote user to moderator") - } + runBlockingWithTestTimeout { + conversationService.grantModerationRights( + course.id!!, + channel, + user2Username, + accessToken, + testServerUrl + ).orThrow("Could not promote user to moderator") } setupUiAndViewModel() @@ -186,10 +177,8 @@ class ConversationMemberSettingsE2eTest : ConversationBaseTest() { ) .performClick() - val isModeratorCheck = hasAnyDescendant( - hasContentDescription( - context.getString(R.string.conversation_members_content_description_moderator) - ) + val isModeratorCheck = hasContentDescription( + context.getString(R.string.conversation_members_content_description_moderator) ) composeTestRule.waitUntilExactlyOneExists( 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 a40d62c6d..f653a92dd 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 @@ -8,18 +8,16 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat 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.browse_channels.BrowseChannelsViewModel import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.browse_channels.testTagForBrowsedChannelItem +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith @@ -38,19 +36,17 @@ class BrowseChannelsE2eTest : ConversationBaseTest() { override fun setup() { super.setup() - runBlocking { - withTimeout(DefaultTimeoutMillis) { - channels = (0 until 2).map { index -> - conversationService.createChannel( - courseId = course.id!!, - name = "channel$index", - description = "", - isPublic = true, - isAnnouncement = false, - authToken = getAdminAccessToken(), - serverUrl = testServerUrl - ).orThrow("Could not create channel $index") - } + runBlockingWithTestTimeout { + channels = (0 until 2).map { index -> + conversationService.createChannel( + courseId = course.id!!, + name = "channel$index", + description = "", + isPublic = true, + isAnnouncement = false, + authToken = getAdminAccessToken(), + serverUrl = testServerUrl + ).orThrow("Could not create channel $index") } } } @@ -88,10 +84,8 @@ class BrowseChannelsE2eTest : ConversationBaseTest() { composeTestRule.waitUntil(DefaultTimeoutMillis) { navigatedToChannelId != null } assertEquals(channelToJoin.id, navigatedToChannelId, "Joined channel id is not the channel we navigated to") - val conversations = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.getConversations(course.id!!, accessToken, testServerUrl).orThrow("Could not load conversations") - } + val conversations = runBlockingWithTestTimeout { + conversationService.getConversations(course.id!!, accessToken, testServerUrl).orThrow("Could not load conversations") } assertTrue(conversations.any { it.id == channelToJoin.id }, "Conversations $conversations does not contain the channel we just joined.") 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 2897322f8..7bac498fd 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 @@ -43,7 +43,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.metistest.Conversation import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Ignore import org.junit.Test import org.junit.experimental.categories.Category @@ -290,13 +289,11 @@ class ConversationOverviewE2eTest : ConversationBaseTest() { ) .performClick() - runBlocking { - withTimeout(DefaultTimeoutMillis) { - viewModel - .conversations - .filter { it.bind { conv -> conv.hidden.isExpanded }.orElse(false) } - .first() - } + runBlockingWithTestTimeout { + viewModel + .conversations + .filter { it.bind { conv -> conv.hidden.isExpanded }.orElse(false) } + .first() } } diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ChannelSettingsE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ChannelSettingsE2eTest.kt index ddfaf50ef..9fbb7ff54 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ChannelSettingsE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ChannelSettingsE2eTest.kt @@ -8,15 +8,13 @@ import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.user1Username import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith @@ -34,28 +32,26 @@ internal class ChannelSettingsE2eTest : ConversationSettingsBaseE2eTest() { */ @Test(timeout = DefaultTestTimeoutMillis) fun `can leave channel`() { - val channel = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.createChannel( - courseId = course.id!!, - name = "clcchannel", - description = "", - isPublic = true, - isAnnouncement = true, - authToken = getAdminAccessToken(), - serverUrl = testServerUrl - ) - .orThrow("Could not create channel") - .apply { - conversationService.registerMembers( - courseId = course.id!!, - conversation = this, - users = listOf(user1Username), - authToken = accessToken, - serverUrl = testServerUrl - ) - } - } + val channel = runBlockingWithTestTimeout { + conversationService.createChannel( + courseId = course.id!!, + name = "clcchannel", + description = "", + isPublic = true, + isAnnouncement = true, + authToken = getAdminAccessToken(), + serverUrl = testServerUrl + ) + .orThrow("Could not create channel") + .apply { + conversationService.registerMembers( + courseId = course.id!!, + conversation = this, + users = listOf(user1Username), + authToken = accessToken, + serverUrl = testServerUrl + ) + } } canLeaveConversationTestImpl(channel) @@ -63,19 +59,17 @@ internal class ChannelSettingsE2eTest : ConversationSettingsBaseE2eTest() { @Test(timeout = DefaultTestTimeoutMillis) fun `can archive channel`() { - val channel = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.createChannel( - courseId = course.id!!, - name = "cacchannel", - description = "", - isPublic = true, - isAnnouncement = true, - authToken = accessToken, - serverUrl = testServerUrl - ) - .orThrow("Could not create channel") - } + val channel = runBlockingWithTestTimeout { + conversationService.createChannel( + courseId = course.id!!, + name = "cacchannel", + description = "", + isPublic = true, + isAnnouncement = true, + authToken = accessToken, + serverUrl = testServerUrl + ) + .orThrow("Could not create channel") } setupUiAndViewModel(channel) @@ -85,28 +79,26 @@ internal class ChannelSettingsE2eTest : ConversationSettingsBaseE2eTest() { @Test(timeout = DefaultTestTimeoutMillis) fun `can unarchive archived channel`() { - val channel = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.createChannel( - courseId = course.id!!, - name = "cuacchannel", - description = "", - isPublic = true, - isAnnouncement = true, - authToken = accessToken, - serverUrl = testServerUrl - ) - .orThrow("Could not create channel") - .also { channel -> - conversationService.archiveChannel( - course.id!!, - channel.id, - accessToken, - testServerUrl - ) - .orThrow("could not archive channel") - } - } + val channel = runBlockingWithTestTimeout { + conversationService.createChannel( + courseId = course.id!!, + name = "cuacchannel", + description = "", + isPublic = true, + isAnnouncement = true, + authToken = accessToken, + serverUrl = testServerUrl + ) + .orThrow("Could not create channel") + .also { channel -> + conversationService.archiveChannel( + course.id!!, + channel.id, + accessToken, + testServerUrl + ) + .orThrow("could not archive channel") + } } setupUiAndViewModel(channel) @@ -153,19 +145,17 @@ internal class ChannelSettingsE2eTest : ConversationSettingsBaseE2eTest() { @Test(timeout = DefaultTestTimeoutMillis) fun `can change channel name, description and topic`() { - val channel = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.createChannel( - courseId = course.id!!, - name = "ccndtchannel", - description = "some description", - isPublic = true, - isAnnouncement = true, - authToken = accessToken, - serverUrl = testServerUrl - ) - .orThrow("Could not create channel") - } + val channel = runBlockingWithTestTimeout { + conversationService.createChannel( + courseId = course.id!!, + name = "ccndtchannel", + description = "some description", + isPublic = true, + isAnnouncement = true, + authToken = accessToken, + serverUrl = testServerUrl + ) + .orThrow("Could not create channel") } val newTitle = "ccndtchannel2" diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ConversationSettingsBaseE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ConversationSettingsBaseE2eTest.kt index 1ed2ae47e..6a507cb13 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ConversationSettingsBaseE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/ConversationSettingsBaseE2eTest.kt @@ -12,16 +12,14 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.getConversation 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.manageconversations.ui.conversation.settings.overview.ConversationSettingsViewModel 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.service.network.getConversation import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.koin.compose.LocalKoinApplication import org.koin.compose.LocalKoinScope import org.koin.core.annotation.KoinInternalApi @@ -52,17 +50,15 @@ internal abstract class ConversationSettingsBaseE2eTest : ConversationBaseTest() composeTestRule .waitUntilDoesNotExist(hasText(context.getString(R.string.conversation_settings_basic_data_save))) - val updatedConversation = runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService - .getConversation( - courseId = course.id!!, - conversationId = conversation.id, - authToken = accessToken, - serverUrl = testServerUrl - ) - .orThrow("Could not load updated conversation") - } + val updatedConversation = runBlockingWithTestTimeout { + conversationService + .getConversation( + courseId = course.id!!, + conversationId = conversation.id, + authToken = accessToken, + serverUrl = testServerUrl + ) + .orThrow("Could not load updated conversation") } verifyChanges(assertIs(updatedConversation, "Loaded conversation is not of correct type")) diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/GroupChatSettingsE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/GroupChatSettingsE2eTest.kt index cd0b3a201..43a5aeb48 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/GroupChatSettingsE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/settings/GroupChatSettingsE2eTest.kt @@ -1,16 +1,13 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.settings -import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.user1Username import de.tum.informatics.www1.artemis.native_app.feature.login.test.user2Username import de.tum.informatics.www1.artemis.native_app.feature.login.test.user3Username import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.GroupChat -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith @@ -45,17 +42,15 @@ internal class GroupChatSettingsE2eTest : ConversationSettingsBaseE2eTest() { ) } - private fun createGroupChat(): de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.GroupChat { - return runBlocking { - withTimeout(DefaultTimeoutMillis) { - conversationService.createGroupChat( - courseId = course.id!!, - groupMembers = listOf(user1Username, user2Username, user3Username), - authToken = getAdminAccessToken(), - serverUrl = testServerUrl - ) - .orThrow("Could not create group chat") - } + private fun createGroupChat(): GroupChat { + return runBlockingWithTestTimeout { + conversationService.createGroupChat( + courseId = course.id!!, + groupMembers = listOf(user1Username, user2Username, user3Username), + authToken = getAdminAccessToken(), + serverUrl = testServerUrl + ) + .orThrow("Could not create group chat") } } } \ No newline at end of file diff --git a/feature/metis/shared/build.gradle.kts b/feature/metis/shared/build.gradle.kts index 93f98bfb5..c2c0a841b 100644 --- a/feature/metis/shared/build.gradle.kts +++ b/feature/metis/shared/build.gradle.kts @@ -11,4 +11,5 @@ android { } dependencies { implementation(project(":core:device")) + testImplementation(project(":feature:metis-test")) } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostAction.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisCrudAction.kt similarity index 58% rename from feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostAction.kt rename to feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisCrudAction.kt index fd21d0d9f..6a560cc6c 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostAction.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisCrudAction.kt @@ -3,9 +3,9 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content import kotlinx.serialization.Serializable @Serializable -enum class MetisPostAction(val value: String) { +enum class MetisCrudAction(val value: String) { CREATE("CREATE"), UPDATE("UPDATE"), DELETE("DELETE"), - NEW_MESSAGE("NEW_MESSAGE") + NEW_MESSAGE("NEW_MESSAGE") // Only used when for the first message in a new conversation. } \ No newline at end of file diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostDTO.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostDTO.kt index d754a69d0..bc9df9013 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostDTO.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/MetisPostDTO.kt @@ -4,4 +4,4 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.d import kotlinx.serialization.Serializable @Serializable -data class MetisPostDTO(val post: StandalonePost, val action: MetisPostAction) \ No newline at end of file +data class MetisPostDTO(val post: StandalonePost, val action: MetisCrudAction) \ No newline at end of file diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/ConversationWebsocketDto.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/ConversationWebsocketDto.kt index 03313ca8e..5c45aef16 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/ConversationWebsocketDto.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/ConversationWebsocketDto.kt @@ -1,6 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostAction +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisCrudAction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable data class ConversationWebsocketDto( val conversation: Conversation, @SerialName("metisCrudAction") - val crudAction: MetisPostAction + val crudAction: MetisCrudAction ) diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDao.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDao.kt index 73207808a..c82654043 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDao.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDao.kt @@ -140,7 +140,7 @@ interface MetisDao { postingType: BasePostingEntity.PostingType = BasePostingEntity.PostingType.STANDALONE ) - @Query("delete from metis_post_context where client_post_id = :clientPostId") + @Query("delete from postings where id = :clientPostId") suspend fun deletePostingWithClientSideId( clientPostId: String ) diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/PostReactionEntity.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/PostReactionEntity.kt index ebf795960..b92d11335 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/PostReactionEntity.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/PostReactionEntity.kt @@ -8,6 +8,19 @@ import androidx.room.Index @Entity( tableName = "reactions", primaryKeys = ["post_id", "emoji", "author_id", "server_id"], + foreignKeys = [ + ForeignKey( + entity = BasePostingEntity::class, + parentColumns = ["id"], + childColumns = ["post_id"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MetisUserEntity::class, + parentColumns = ["server_id", "id"], + childColumns = ["server_id", "author_id"] + ) + ], indices = [Index("server_id", "author_id", name = "server_id_author_id_index")] ) data class PostReactionEntity( diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationWebsocketExtensions.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationWebsocketExtensions.kt index 91020cfa6..cb888168c 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationWebsocketExtensions.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationWebsocketExtensions.kt @@ -1,11 +1,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider +import de.tum.informatics.www1.artemis.native_app.core.websocket.impl.WebsocketTopic import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.ConversationWebsocketDto import kotlinx.coroutines.flow.Flow fun WebsocketProvider.subscribeToConversationUpdates(userId: Long, courseId: Long): Flow { - val topic = "/user/topic/metis/courses/$courseId/conversations/user/$userId" + val topic = WebsocketTopic.getConversationMetaUpdateTopic(courseId, userId) return subscribeMessage(topic, ConversationWebsocketDto.serializer()) } \ No newline at end of file diff --git a/feature/metis/shared/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDaoTest.kt b/feature/metis/shared/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDaoTest.kt new file mode 100644 index 000000000..216ec3504 --- /dev/null +++ b/feature/metis/shared/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/MetisDaoTest.kt @@ -0,0 +1,168 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db + +import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.BasePostingEntity +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.MetisPostContextEntity +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.MetisUserEntity +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.PostReactionEntity +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.StandalonePostingEntity +import de.tum.informatics.www1.artemis.native_app.feature.metistest.MetisDatabaseProviderMock +import de.tum.informatics.www1.artemis.native_app.feature.metistest.MetisTestDatabase +import de.tum.informatics.www1.artemis.native_app.feature.metistest.loadAsList +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Category(UnitTest::class) +class MetisDaoTest { + + private val serverId = "host" + private val courseId = 1L + private val conversationId = 1L + private val clientPostId = "clientPostId" + + private val user = MetisUserEntity( + serverId = serverId, + id = 4, + displayName = "User4" + ) + private val basePost = BasePostingEntity( + postId = clientPostId, + serverId = serverId, + postingType = BasePostingEntity.PostingType.STANDALONE, + authorId = user.id, + creationDate = Clock.System.now(), + updatedDate = Clock.System.now(), + content = "post content", + authorRole = UserRole.USER, + ) + private val metisContext = MetisPostContextEntity( + serverId = serverId, + courseId = courseId, + conversationId = conversationId, + serverPostId = 1, + clientPostId = clientPostId, + postingType = BasePostingEntity.PostingType.STANDALONE, + ) + private val post = StandalonePostingEntity( + postId = clientPostId, + title = null, + context = null, + displayPriority = BasePostingEntity.DisplayPriority.NONE, + resolved = false, + liveCreated = false + ) + private val reaction = PostReactionEntity( + postId = clientPostId, + authorId = user.id, + serverId = serverId, + emojiId = "emojiId", + id = 1, + ) + + private lateinit var database: MetisTestDatabase + private lateinit var metisDao: MetisDao + + @Before + fun setup() { + val databaseProviderMock = MetisDatabaseProviderMock(InstrumentationRegistry.getInstrumentation().context) + database = databaseProviderMock.database + metisDao = database.metisDao() + } + + @After + fun teardown() { + database.close() + } + + @Test + fun testAddPost() = runBlocking { + // GIVEN: A inserted post + insertStandalonePost() + + // WHEN: Querying the post + val storedPosts = metisDao.queryCoursePosts( + courseId = courseId, + conversationId = conversationId, + serverId = serverId, + ).loadAsList() + + // THEN: Return post with correct userId and clientPostId + assertEquals(1, storedPosts.size) + val storedPost = storedPosts[0] + assertEquals(user.id, storedPost.authorId) + assertEquals(clientPostId, storedPost.clientPostId) + } + + @Test + fun testDeletePost() = runBlocking { + // GIVEN: A inserted post + insertStandalonePost() + + // WHEN: Deleting the post + metisDao.deletePostingWithClientSideId(clientPostId) + + // THEN: The post is deleted in both tables + database.query("SELECT * FROM standalone_postings", args = null).use { + assertEquals(0, it.count) + } + database.query("SELECT * FROM postings", args = null).use { + assertEquals(0, it.count) + } + + // AND: The post context is deleted + database.query("SELECT * FROM metis_post_context", args = null).use { + assertEquals(0, it.count) + } + } + + @Test + fun testAddPostWithReaction() = runBlocking { + // GIVEN: A inserted post with a reaction + insertStandalonePost() + metisDao.insertReactions(listOf(reaction)) + + // WHEN: Querying the post + val storedPost = metisDao.queryCoursePosts( + courseId = courseId, + conversationId = conversationId, + serverId = serverId, + ).loadAsList()[0] + + // THEN: The reaction is stored + assertEquals(1, storedPost.reactions.size) + assertEquals(reaction.emojiId, storedPost.reactions[0].emojiId) + } + + @Test + fun testDeletePostWithReaction() = runBlocking { + // GIVEN: A inserted post with a reaction + insertStandalonePost() + metisDao.insertReactions(listOf(reaction)) + + // WHEN: Deleting the post + metisDao.deletePostingWithClientSideId(clientPostId) + + // THEN: The reaction is deleted + database.query("SELECT * FROM reactions", args = null).use { + assertEquals(0, it.count) + } + } + + + private suspend fun insertStandalonePost() { + metisDao.insertUser(user) + metisDao.insertBasePost(basePost) + metisDao.insertPost(post) + metisDao.insertPostMetisContext(metisContext) + } +} \ No newline at end of file diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/communication_module.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/communication_module.kt index 06e05ea65..f3e253d19 100644 --- a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/communication_module.kt +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/communication_module.kt @@ -5,6 +5,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.con import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.manageConversationsModule import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.sharedConversationModule import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.NavigateToUserConversationViewModel +import de.tum.informatics.www1.artemis.native_app.feature.metis.ui.SinglePageConversationBodyViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -22,4 +23,15 @@ val communicationModule = module { get() ) } + + viewModel { params -> + SinglePageConversationBodyViewModel( + params[0], + get(), + get(), + get(), + get(), + get() + ) + } } 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 973363b8f..90694625c 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,7 +4,10 @@ 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.androidx.compose.getViewModel +import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf /** * Displays the conversation ui. If the code of conduct has not yet been accepted, displays a code @@ -22,13 +25,9 @@ fun ConversationFacadeUi( codeOfConductAcceptedContent = { SinglePageConversationBody( modifier = Modifier.fillMaxSize(), + viewModel = koinViewModel { parametersOf(courseId) }, courseId = courseId, 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 cfe7a811b..56da5fc4c 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,23 +4,13 @@ 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.collectAsState 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 @@ -30,20 +20,14 @@ 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, + viewModel: SinglePageConversationBodyViewModel, courseId: Long, - initialConfiguration: ConversationConfiguration = NothingOpened, - accountService: AccountService, - serverConfigurationService: ServerConfigurationService, - courseService: CourseService, - accountDataService: AccountDataService, - networkStatusProvider: NetworkStatusProvider + initialConfiguration: ConversationConfiguration = NothingOpened ) { var configuration: ConversationConfiguration by rememberSaveable(initialConfiguration) { mutableStateOf(initialConfiguration) @@ -56,30 +40,7 @@ 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 - } - } - } + val canCreateChannel by viewModel.canCreateChannel.collectAsState() BackHandler(configuration != NothingOpened) { when (val config = configuration) { diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBodyViewModel.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBodyViewModel.kt new file mode 100644 index 000000000..fc007eed6 --- /dev/null +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBodyViewModel.kt @@ -0,0 +1,44 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.ui + +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.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 kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +internal class SinglePageConversationBodyViewModel( + courseId: Long, + accountService: AccountService, + serverConfigurationService: ServerConfigurationService, + courseService: CourseService, + accountDataService: AccountDataService, + networkStatusProvider: NetworkStatusProvider, + private val coroutineContext: CoroutineContext = EmptyCoroutineContext +) : ViewModel() { + + val canCreateChannel: StateFlow = 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) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) +} \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ui/PushNotificationSettingsUi.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ui/PushNotificationSettingsUi.kt index bafafbcf2..9aa5e26aa 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ui/PushNotificationSettingsUi.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ui/PushNotificationSettingsUi.kt @@ -31,7 +31,7 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog -import de.tum.informatics.www1.artemis.native_app.core.ui.material.linkTextColor +import de.tum.informatics.www1.artemis.native_app.core.ui.material.colors.linkTextColor import de.tum.informatics.www1.artemis.native_app.feature.push.R import kotlinx.coroutines.Job import org.koin.androidx.compose.koinViewModel diff --git a/feature/push/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/PushNotificationSettingsE2eTest.kt b/feature/push/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/PushNotificationSettingsE2eTest.kt index 4c853f79c..8b07cd608 100644 --- a/feature/push/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/PushNotificationSettingsE2eTest.kt +++ b/feature/push/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/PushNotificationSettingsE2eTest.kt @@ -10,19 +10,19 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.platform.app.InstrumentationRegistry +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest +import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.core.data.filterSuccess import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules -import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.generateId -import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin import de.tum.informatics.www1.artemis.native_app.feature.login.test.testLoginModule -import de.tum.informatics.www1.artemis.native_app.feature.push.service.network.NotificationSettingsService import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationConfigurationService +import de.tum.informatics.www1.artemis.native_app.feature.push.service.network.NotificationSettingsService import de.tum.informatics.www1.artemis.native_app.feature.push.service.network.impl.NotificationSettingsServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.push.ui.PushNotificationSettingsUi import de.tum.informatics.www1.artemis.native_app.feature.push.ui.PushNotificationSettingsViewModel @@ -88,13 +88,11 @@ class PushNotificationSettingsE2eTest : BaseComposeTest() { fun `can retrieve notification settings`() { val viewModel = setupUiAndViewModel() - val currentSettingsByGroup = runBlocking { - withTimeout(DefaultTimeoutMillis) { - viewModel - .currentSettingsByGroup - .filterSuccess() - .first() - } + val currentSettingsByGroup = runBlockingWithTestTimeout { + viewModel + .currentSettingsByGroup + .filterSuccess() + .first() } assertTrue( @@ -124,13 +122,11 @@ class PushNotificationSettingsE2eTest : BaseComposeTest() { fun `can update notification settings`() { val viewModel = setupUiAndViewModel() - val currentSettingsByGroup = runBlocking { - withTimeout(DefaultTimeoutMillis) { - viewModel - .currentSettingsByGroup - .filterSuccess() - .first() - } + val currentSettingsByGroup = runBlockingWithTestTimeout { + viewModel + .currentSettingsByGroup + .filterSuccess() + .first() } Logger.info("CurrentSettingsByGroup=$currentSettingsByGroup") diff --git a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/participation/QuizParticipationScreen.kt b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/participation/QuizParticipationScreen.kt index 50e90b59b..79dfe92d1 100644 --- a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/participation/QuizParticipationScreen.kt +++ b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/participation/QuizParticipationScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -15,7 +14,6 @@ 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 @@ -28,25 +26,25 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import de.tum.informatics.www1.artemis.native_app.core.data.service.impl.JsonProvider import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion import de.tum.informatics.www1.artemis.native_app.core.ui.alert.DestructiveMarkdownTextAlertDialog import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog import de.tum.informatics.www1.artemis.native_app.core.ui.common.ButtonWithLoadingAnimation +import de.tum.informatics.www1.artemis.native_app.core.ui.navigation.KSerializableNavType import de.tum.informatics.www1.artemis.native_app.feature.quiz.QuizType import de.tum.informatics.www1.artemis.native_app.feature.quiz.R import de.tum.informatics.www1.artemis.native_app.feature.quiz.view_result.ViewQuizResultScreen import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Job +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf +import kotlin.reflect.typeOf private val submitButtonColor: Color @Composable get() = if (isSystemInDarkTheme()) Color(0xff00bc8c) else Color(0xff28a745) @@ -54,31 +52,28 @@ private val submitButtonColor: Color private val submitButtonTextColor: Color @Composable get() = Color.White +@Serializable +private data class QuizParticipationScreen( + val courseId: Long, + val exerciseId: Long, + val quizType: QuizType.WorkableQuizType = QuizType.Live +) + fun NavController.navigateToQuizParticipation( courseId: Long, exerciseId: Long, quizType: QuizType.WorkableQuizType ) { - val quizTypeAsString = Json.encodeToString(QuizType.WorkableQuizType.serializer(), quizType) - - navigate("quiz-participation/$courseId/$exerciseId/$quizTypeAsString") + navigate(QuizParticipationScreen(courseId, exerciseId, quizType)) } fun NavGraphBuilder.quizParticipation(onLeaveQuiz: () -> Unit) { - composable( - route = "quiz-participation/{courseId}/{exerciseId}/{quizType}", - arguments = listOf( - navArgument("courseId") { - type = NavType.LongType - }, - navArgument("exerciseId") { - type = NavType.LongType - }, - navArgument("quizType") { - type = NavType.StringType - defaultValue = - Json.encodeToString(QuizType.WorkableQuizType.serializer(), QuizType.Live) - } + composable( + typeMap = mapOf( + typeOf() to KSerializableNavType( + isNullableAllowed = false, + QuizType.WorkableQuizType.serializer() + ) ), deepLinks = listOf( navDeepLink { @@ -86,15 +81,10 @@ fun NavGraphBuilder.quizParticipation(onLeaveQuiz: () -> Unit) { } ) ) { backStackEntry -> - val courseId = backStackEntry.arguments?.getLong("courseId") - val exerciseId = backStackEntry.arguments?.getLong("exerciseId") - val quizTypeString = backStackEntry.arguments?.getString("quizType") - - checkNotNull(courseId) - checkNotNull(exerciseId) - checkNotNull(quizTypeString) - - val quizType: QuizType.WorkableQuizType = Json.decodeFromString(quizTypeString) + val screen = backStackEntry.toRoute() + val courseId = screen.courseId + val exerciseId = screen.exerciseId + val quizType = screen.quizType val jsonProvider: JsonProvider = koinInject() diff --git a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/question/draganddrop/body/work_area/DragAndDropWorkArea.kt b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/question/draganddrop/body/work_area/DragAndDropWorkArea.kt index 918b6e8b7..92ea2967a 100644 --- a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/question/draganddrop/body/work_area/DragAndDropWorkArea.kt +++ b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/question/draganddrop/body/work_area/DragAndDropWorkArea.kt @@ -24,12 +24,11 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.toSize import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.scale -import coil.request.ImageRequest import de.tum.informatics.www1.artemis.native_app.core.model.exercise.quiz.DragAndDropQuizQuestion import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi import de.tum.informatics.www1.artemis.native_app.core.ui.common.image.loadAsyncImageDrawable +import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.DefaultImageProvider import de.tum.informatics.www1.artemis.native_app.feature.quiz.R -import io.ktor.http.HttpHeaders internal sealed interface DragAndDropAreaType { data class ViewOnly( @@ -60,12 +59,15 @@ internal fun DragAndDropWorkArea( ) { val context = LocalContext.current + val defaultImageProvider = DefaultImageProvider() val request = remember(imageUrl, questionId) { - ImageRequest.Builder(context) - .data(imageUrl) - .memoryCacheKey("QQ_$questionId") - .addHeader(HttpHeaders.Cookie, "jwt=$authToken") - .build() + defaultImageProvider.createImageRequest( + context, + imageUrl, + serverUrl, + authToken, + "QQ_$questionId" + ) } val resultData = loadAsyncImageDrawable(request = request) diff --git a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/view_result/ViewQuizResultScreen.kt b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/view_result/ViewQuizResultScreen.kt index 363fa1050..c33d5b5d0 100644 --- a/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/view_result/ViewQuizResultScreen.kt +++ b/feature/quiz/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/view_result/ViewQuizResultScreen.kt @@ -17,9 +17,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navArgument +import androidx.navigation.toRoute import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.data.join import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise @@ -29,37 +28,27 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateU import de.tum.informatics.www1.artemis.native_app.feature.quiz.QuizType import de.tum.informatics.www1.artemis.native_app.feature.quiz.R import de.tum.informatics.www1.artemis.native_app.feature.quiz.participation.QuizQuestionData +import kotlinx.serialization.Serializable import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +@Serializable +private data class ViewQuizResultScreen(val courseId: Long, val exerciseId: Long) + fun NavController.navigateToQuizResult( courseId: Long, exerciseId: Long ) { - navigate("view-quiz-result/$courseId/$exerciseId") + navigate(ViewQuizResultScreen(courseId, exerciseId)) } fun NavGraphBuilder.quizResults(onRequestLeaveQuizResults: () -> Unit) { - composable( - "view-quiz-result/{courseId}/{exerciseId}", - arguments = listOf( - navArgument("courseId") { - type = NavType.LongType - }, - navArgument("exerciseId") { - type = NavType.LongType - } - ) - ) { backStackEntry -> - val courseId = backStackEntry.arguments?.getLong("courseId") - val exerciseId = backStackEntry.arguments?.getLong("exerciseId") - - checkNotNull(courseId) - checkNotNull(exerciseId) + composable { backStackEntry -> + val route: ViewQuizResultScreen = backStackEntry.toRoute() ViewQuizResultScreen( modifier = Modifier.fillMaxSize(), - exerciseId = exerciseId, + exerciseId = route.exerciseId, quizType = QuizType.ViewResults, onNavigateUp = onRequestLeaveQuizResults ) @@ -112,7 +101,14 @@ internal fun ViewQuizResultScreen( modifier = Modifier .fillMaxSize() .padding(padding), - dataState = joinMultiple(quizExercise, submission, result, quizQuestions, maxPoints, ::ResultData), + dataState = joinMultiple( + quizExercise, + submission, + result, + quizQuestions, + maxPoints, + ::ResultData + ), loadingText = stringResource(id = R.string.quiz_result_loading), failureText = stringResource(id = R.string.quiz_result_failure), retryButtonText = stringResource(id = R.string.quiz_result_try_again), diff --git a/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizParticipationBaseE2eTest.kt b/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizParticipationBaseE2eTest.kt index 8c0d49f03..a3264f663 100644 --- a/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizParticipationBaseE2eTest.kt +++ b/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizParticipationBaseE2eTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.core.data.filterSuccess import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.QuizSubmission @@ -16,9 +17,8 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.quiz.MultipleChoiceSubmittedAnswer import de.tum.informatics.www1.artemis.native_app.core.model.exercise.submission.quiz.ShortAnswerSubmittedAnswer import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis -import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createQuizExercise -import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createExerciseFormBodyWithPng +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createQuizExercise import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.generateId import de.tum.informatics.www1.artemis.native_app.core.ui.common.TEST_TAG_BUTTON_WITH_LOADING_ANIMATION_LOADING import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken @@ -187,7 +187,9 @@ internal abstract class QuizParticipationBaseE2eTest(quizType: QuizType.Workable ) } - runBlockingWithTestTimeout { + runBlockingWithTestTimeout( + timeoutMultiplier = 2 + ) { setupAndVerify(viewModel) { // Lambda to submit diff --git a/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizWaitingScreenE2eTest.kt b/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizWaitingScreenE2eTest.kt index 4b51ed12f..1baf2cfb3 100644 --- a/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizWaitingScreenE2eTest.kt +++ b/feature/quiz/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/quiz/QuizWaitingScreenE2eTest.kt @@ -23,8 +23,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAcc import de.tum.informatics.www1.artemis.native_app.feature.quiz.participation.QuizParticipationUi import de.tum.informatics.www1.artemis.native_app.feature.quiz.screens.TEST_TAG_TEXT_FIELD_BATCH_PASSWORD import de.tum.informatics.www1.artemis.native_app.feature.quiz.screens.TEST_TAG_WAIT_FOR_QUIZ_START_SCREEN -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Ignore import org.junit.Test import org.junit.experimental.categories.Category @@ -86,12 +84,10 @@ internal class QuizWaitingScreenE2eTest : QuizBaseE2eTest(QuizType.Live) { DefaultTimeoutMillis ) - val participation = runBlocking { - withTimeout(DefaultTimeoutMillis) { - participationService - .findParticipation(quiz.id, testServerUrl, accessToken) - .orThrow("Could not load participation. Expected a participation to have been created") - } + val participation = runBlockingWithTestTimeout { + participationService + .findParticipation(quiz.id, testServerUrl, accessToken) + .orThrow("Could not load participation. Expected a participation to have been created") } assertIs(participation) @@ -153,18 +149,14 @@ internal class QuizWaitingScreenE2eTest : QuizBaseE2eTest(QuizType.Live) { ) // Start the batch to generate a participation - runBlocking { - withTimeout(DefaultTimeoutMillis) { - startQuizExerciseBatch(getAdminAccessToken(), quiz.id, batch) - } + runBlockingWithTestTimeout { + startQuizExerciseBatch(getAdminAccessToken(), quiz.id, batch) } - val participation = runBlocking { - withTimeout(DefaultTimeoutMillis) { - participationService - .findParticipation(quiz.id, testServerUrl, accessToken) - .orThrow("Could not load participation. Expected a participation to have been created") - } + val participation = runBlockingWithTestTimeout { + participationService + .findParticipation(quiz.id, testServerUrl, accessToken) + .orThrow("Could not load participation. Expected a participation to have been created") } assertIs(participation) diff --git a/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/PushNotificationSettingsScreen.kt b/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/PushNotificationSettingsScreen.kt index 28bcd23f3..c6b216307 100644 --- a/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/PushNotificationSettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/PushNotificationSettingsScreen.kt @@ -1,7 +1,10 @@ package de.tum.informatics.www1.artemis.native_app.feature.settings +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -67,9 +70,10 @@ internal fun PushNotificationSettingsScreen(modifier: Modifier, onNavigateBack: PushNotificationSettingsUi( modifier = Modifier .fillMaxSize() - .padding(padding) + .padding(top = padding.calculateTopPadding()) .padding(horizontal = 8.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .padding(bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()), viewModel = viewModel ) diff --git a/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/SettingsScreen.kt index e17afc751..ed577395c 100644 --- a/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/settings/SettingsScreen.kt @@ -6,9 +6,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -54,13 +57,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import org.koin.compose.koinInject -private const val SETTINGS_DESTINATION = "settings" -private const val PUSH_NOTIFICATION_SETTINGS_DESTINATION = "push_notification_settings" +@Serializable +private data object SettingsScreen + +@Serializable +private data object PushNotificationSettingsScreen fun NavController.navigateToSettings(builder: NavOptionsBuilder.() -> Unit) { - navigate(SETTINGS_DESTINATION, builder) + navigate(SettingsScreen, builder) } /** @@ -74,7 +81,7 @@ fun NavGraphBuilder.settingsScreen( onLoggedOut: () -> Unit, onDisplayThirdPartyLicenses: () -> Unit ) { - composable(SETTINGS_DESTINATION) { + composable { SettingsScreen( modifier = Modifier.fillMaxSize(), versionCode = versionCode, @@ -83,11 +90,11 @@ fun NavGraphBuilder.settingsScreen( onLoggedOut = onLoggedOut, onDisplayThirdPartyLicenses = onDisplayThirdPartyLicenses ) { - navController.navigate(PUSH_NOTIFICATION_SETTINGS_DESTINATION) + navController.navigate(PushNotificationSettingsScreen) } } - composable(PUSH_NOTIFICATION_SETTINGS_DESTINATION) { + composable { PushNotificationSettingsScreen( modifier = Modifier.fillMaxSize(), onNavigateBack = onNavigateUp @@ -175,8 +182,9 @@ private fun SettingsScreen( Column( modifier = Modifier .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + .padding(top = padding.calculateTopPadding()) + .verticalScroll(rememberScrollState()) + .padding(bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { if (authToken != null) { @@ -207,6 +215,7 @@ private fun SettingsScreen( AboutSection( modifier = Modifier.fillMaxWidth(), hasUserSelectedInstance = hasUserSelectedInstance, + serverUrl = serverUrl, onOpenPrivacyPolicy = { val link = URLBuilder(serverUrl).appendPathSegments("privacy").buildString() @@ -334,6 +343,7 @@ private fun NotificationSection(modifier: Modifier, onOpenNotificationSettings: private fun AboutSection( modifier: Modifier, hasUserSelectedInstance: Boolean, + serverUrl: String, onRequestSelectServerInstance: () -> Unit, onOpenPrivacyPolicy: () -> Unit, onOpenImprint: () -> Unit, @@ -352,6 +362,12 @@ private fun AboutSection( } if (hasUserSelectedInstance) { + PreferenceEntry( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.settings_server_url, serverUrl), + onClick = {} + ) + PreferenceEntry( modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.settings_about_privacy_policy), diff --git a/feature/settings/src/main/res/values/settings_strings.xml b/feature/settings/src/main/res/values/settings_strings.xml index 3954c4b7c..ec4a71b13 100644 --- a/feature/settings/src/main/res/values/settings_strings.xml +++ b/feature/settings/src/main/res/values/settings_strings.xml @@ -12,6 +12,7 @@ About Imprint and privacy policy depend on the server you have currently selected. + Server URL: %1$s Imprint and privacy policy are unavailable as you have not yet selected a server instance. Please select a server instance to view their privacy policy and imprint. Select instance Privacy policy diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b8007277..7aeb2024d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,32 +7,32 @@ buildToolsVersion = "35.0.0" accompanist = "0.30.1" androidDesugarJdkLibs = "2.0.4" androidxActivity = "1.8.1" -androidxAppCompat = "1.6.1" -androidxComposeBom = "2023.10.01" -androidxLifecycle = "2.6.2" -androidxNavigation = "2.7.5" -androidGradlePlugin = "8.7.0" -androidxDataStore = "1.0.0" -androidxPaging = "3.2.1" +androidxAppCompat = "1.7.0" +androidxComposeBom = "2024.11.00" +androidxLifecycle = "2.8.7" +androidGradlePlugin = "8.7.1" +androidxNavigation = "2.8.4" +androidxPaging = "3.3.4" +androidxDataStore = "1.1.1" androidxPagingCompose = "3.2.1" -coil = "2.4.0" -emoji2 = "1.4.0" +coil = "2.7.0" +emoji2 = "1.5.0" kotlin = "1.9.25" -kotlinxCoroutines = "1.7.3" +kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.1" koin = "3.5.3" koinAndroidxCompose = "3.4.5" kover = "0.7.2" -ktor = "2.3.7" +ktor = "2.3.13" krossbow = "5.2.0" markwon = "4.6.2" -mockk = "1.13.8" +mockk = "1.13.13" ossLicensesPlugin = "0.10.6" -placeholderMaterial = "1.0.1" -room = "2.6.0" -sentry-android = "6.22.0" -work = "2.9.0" +placeholderMaterial = "2.0.0" +room = "2.6.1" +sentry-android = "7.17.0" +work = "2.10.0" # Used indirecly in the build config -> Do not remove without double checking. androidxComposeCompiler = "1.5.15" @@ -72,6 +72,8 @@ androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "li androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "androidxPaging" } androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "androidxPagingCompose" } +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "androidxPaging" } +androidx-paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "androidxPaging" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" } @@ -121,7 +123,7 @@ noties-markwon-simple-ext = { group = "io.noties.markwon", name = "simple-ext", noties-markwon-image-coil = { group = "io.noties.markwon", name = "image-coil", version.ref = "markwon" } oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "ossLicensesPlugin" } placeholder-material = { group = "io.github.fornewid", name = "placeholder-material3", version.ref = "placeholderMaterial" } -play-services-oss-licences = { group = "com.google.android.gms", name = "play-services-oss-licenses", version = "17.0.1" } +play-services-oss-licences = { group = "com.google.android.gms", name = "play-services-oss-licenses", version = "17.1.0" } robolectric = { group = "org.robolectric", name = "robolectric", version = "4.13" } sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry-android" } sentry-compose-android = { group = "io.sentry", name = "sentry-compose-android", version.ref = "sentry-android" } @@ -137,6 +139,6 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version = "4.4.2" } -google-firebase-appdistribution = { id = "com.google.firebase.appdistribution", version = "4.0.1" } +google-firebase-appdistribution = { id = "com.google.firebase.appdistribution", version = "5.0.0" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } google-ksp = { id = "com.google.devtools.ksp", version = "1.9.25-1.0.20" }