diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index ce3a6da91..673f445c5 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -56,6 +56,7 @@ jobs: name: Android End To End Test Results path: test-outputs/**/*.xml reporter: java-junit + fail-on-empty: 'false' - name: Cleanup -> Stop all running docker containers if: always() diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 038a31f1f..cb039152a 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -40,4 +40,5 @@ jobs: with: name: Android Unit Tests path: test-outputs/**/*.xml - reporter: java-junit \ No newline at end of file + reporter: java-junit + fail-on-empty: 'false' \ No newline at end of file diff --git a/.whitesource b/.whitesource new file mode 100644 index 000000000..9c7ae90b4 --- /dev/null +++ b/.whitesource @@ -0,0 +1,14 @@ +{ + "scanSettings": { + "baseBranches": [] + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff", + "useMendCheckNames": true + }, + "issueSettings": { + "minSeverityLevel": "LOW", + "issueType": "DEPENDENCY" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9e9..a7504c5ef 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2024 TUM Applied Software Engineering + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6bd65814c..908d806d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,7 +24,7 @@ plugins { android { namespace = "de.tum.informatics.www1.artemis.native_app.android" - val versionName = "0.8.0" + val versionName = "0.9.0" val versionCode = if (!System.getenv("bamboo_buildNumber") .isNullOrEmpty() diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ArtemisApplication.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ArtemisApplication.kt index d54d54531..66e56a3da 100644 --- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ArtemisApplication.kt +++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ArtemisApplication.kt @@ -22,7 +22,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.exerciseM import de.tum.informatics.www1.artemis.native_app.feature.lectureview.lectureModule import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.metis.communicationModule -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel +import de.tum.informatics.www1.artemis.native_app.core.common.ArtemisNotificationChannel import de.tum.informatics.www1.artemis.native_app.feature.push.pushModule import de.tum.informatics.www1.artemis.native_app.feature.quiz.quizParticipationModule import de.tum.informatics.www1.artemis.native_app.feature.settings.settingsModule 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 0115609dd..a22343443 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 = 8 + version = 9 ) @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 22fc40213..329d6431e 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 @@ -51,13 +51,11 @@ import de.tum.informatics.www1.artemis.native_app.feature.lectureview.navigateTo 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.navigateToLogin -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ProvideLocalVisibleMetisContextManager import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleMetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleMetisContextManager import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleMetisContextReporter import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleStandalonePostDetails -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.unsubscribeFromNotifications import de.tum.informatics.www1.artemis.native_app.feature.quiz.QuizType @@ -392,15 +390,9 @@ class MainActivity : AppCompatActivity(), private fun cancelCommunicationNotifications(visibleMetisContext: VisibleMetisContext) { if (visibleMetisContext is VisibleStandalonePostDetails) { val parentId = visibleMetisContext.postId - val communicationType: CommunicationType = when (visibleMetisContext.metisContext) { - is MetisContext.Course -> CommunicationType.QNA_COURSE - is MetisContext.Exercise -> CommunicationType.QNA_EXERCISE - is MetisContext.Lecture -> CommunicationType.QNA_LECTURE - is MetisContext.Conversation -> CommunicationType.CONVERSATION - } lifecycleScope.launch { - communicationNotificationManager.deleteCommunication(parentId, communicationType) + communicationNotificationManager.deleteCommunication(parentId) } } } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 72e6fcd92..881998015 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { api(libs.kotlinx.coroutines.android) api(libs.kotlinx.datetime) + + api(libs.androidx.work.runtime.ktx) } diff --git a/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/ArtemisNotificationChannel.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/ArtemisNotificationChannel.kt new file mode 100644 index 000000000..edc508034 --- /dev/null +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/ArtemisNotificationChannel.kt @@ -0,0 +1,23 @@ +package de.tum.informatics.www1.artemis.native_app.core.common + +import android.app.NotificationManager + +enum class ArtemisNotificationChannel( + val id: String, + val title: Int, + val description: Int, + val importance: Int = NotificationManager.IMPORTANCE_DEFAULT +) { + MiscNotificationChannel( + "misc-notification-channel", + R.string.push_notification_channel_misc_name, + R.string.push_notification_channel_misc_description, + NotificationManager.IMPORTANCE_DEFAULT + ), + CommunicationNotificationChannel( + "communication-notification-channel", + R.string.push_notification_channel_communication_name, + R.string.push_notification_channel_communication_description, + NotificationManager.IMPORTANCE_HIGH + ) +} \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/job_util.kt b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/job_util.kt similarity index 93% rename from feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/job_util.kt rename to core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/job_util.kt index 50bbfabb3..fec3f023d 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/job_util.kt +++ b/core/common/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/common/job_util.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.feature.push +package de.tum.informatics.www1.artemis.native_app.core.common import androidx.work.BackoffPolicy import androidx.work.Constraints diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml new file mode 100644 index 000000000..6e4a60204 --- /dev/null +++ b/core/common/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Miscellaneous + All non-communication related notifications. + + Communication + New messages in channels, group chats and personal chats. + \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a94b1a247..300026f0a 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -24,8 +24,11 @@ dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.koin.core) + implementation(libs.koin.android.compat) implementation(libs.kotlinx.datetime) + implementation(libs.androidx.dataStore.preferences) + debugImplementation(libs.ktor.client.cio) testImplementation(project(":core:common-test")) diff --git a/core/data/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/AccountDataServiceStub.kt b/core/data/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/AccountDataServiceStub.kt index 7d38fedc4..02c7d68f1 100644 --- a/core/data/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/AccountDataServiceStub.kt +++ b/core/data/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/AccountDataServiceStub.kt @@ -8,4 +8,6 @@ class AccountDataServiceStub(private val account: Account = Account()) : Account serverUrl: String, bearerToken: String ): NetworkResponse = NetworkResponse.Response(account) + + override suspend fun getCachedAccountData(serverUrl: String, bearerToken: String): Account = account } diff --git a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/data_module.kt b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/data_module.kt index 8aa2f124f..6548b5e19 100644 --- a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/data_module.kt +++ b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/data_module.kt @@ -15,6 +15,7 @@ import de.tum.informatics.www1.artemis.native_app.core.data.service.network.impl import de.tum.informatics.www1.artemis.native_app.core.data.service.network.impl.CourseServiceImpl import de.tum.informatics.www1.artemis.native_app.core.data.service.network.impl.ExerciseServiceImpl import de.tum.informatics.www1.artemis.native_app.core.data.service.network.impl.ServerTimeServiceImpl +import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -24,7 +25,7 @@ val dataModule = module { single { CourseServiceImpl(get()) } single { ExerciseServiceImpl(get(), get()) } - single { AccountDataServiceImpl(get()) } + single { AccountDataServiceImpl(androidContext(), get(), get()) } single { CourseExerciseServiceImpl(get()) } single { ParticipationServiceImpl(get()) } single { ServerTimeServiceImpl(get()) } diff --git a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/AccountDataService.kt b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/AccountDataService.kt index dcb705408..606e1dc6f 100644 --- a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/AccountDataService.kt +++ b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/AccountDataService.kt @@ -6,4 +6,6 @@ import de.tum.informatics.www1.artemis.native_app.core.model.account.Account interface AccountDataService { suspend fun getAccountData(serverUrl: String, bearerToken: String): NetworkResponse + + suspend fun getCachedAccountData(serverUrl: String, bearerToken: String): Account? } diff --git a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/AccountDataServiceImpl.kt b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/AccountDataServiceImpl.kt index c1684defd..ac4141790 100644 --- a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/AccountDataServiceImpl.kt +++ b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/AccountDataServiceImpl.kt @@ -1,34 +1,74 @@ package de.tum.informatics.www1.artemis.native_app.core.data.service.network.impl +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse import de.tum.informatics.www1.artemis.native_app.core.data.cookieAuth +import de.tum.informatics.www1.artemis.native_app.core.data.onSuccess import de.tum.informatics.www1.artemis.native_app.core.data.performNetworkCall import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService import de.tum.informatics.www1.artemis.native_app.core.data.service.KtorProvider +import de.tum.informatics.www1.artemis.native_app.core.data.service.impl.JsonProvider import de.tum.informatics.www1.artemis.native_app.core.model.account.Account import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.http.ContentType import io.ktor.http.appendPathSegments import io.ktor.http.contentType +import kotlinx.coroutines.flow.first +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString -internal class AccountDataServiceImpl(private val ktorProvider: KtorProvider) : AccountDataService { +internal class AccountDataServiceImpl( + private val context: Context, + private val ktorProvider: KtorProvider, + private val jsonProvider: JsonProvider +) : AccountDataService { + + companion object { + private const val ACCOUNT_DATA_CACHE_NAME = "account_data_cache" + } + + private val Context.accountDataCache by preferencesDataStore(ACCOUNT_DATA_CACHE_NAME) override suspend fun getAccountData( serverUrl: String, bearerToken: String ): NetworkResponse { - return performNetworkCall { - ktorProvider - .ktorClient - .get(serverUrl) { - url { - appendPathSegments("api", "public", "account") - } - - contentType(ContentType.Application.Json) - cookieAuth(bearerToken) - }.body() + return performNetworkCall { + ktorProvider.ktorClient.get(serverUrl) { + url { + appendPathSegments("api", "public", "account") + } + + contentType(ContentType.Application.Json) + cookieAuth(bearerToken) + }.body() + }.onSuccess { account -> + context.accountDataCache.edit { data -> + data[getAccountDataCacheKey(bearerToken)] = jsonProvider.applicationJsonConfiguration.encodeToString(account) + } } } + + override suspend fun getCachedAccountData(serverUrl: String, bearerToken: String): Account? { + val cacheData = context.accountDataCache.data.first() + val cacheKey = getAccountDataCacheKey(bearerToken) + val cacheEntry = cacheData[cacheKey] + if (cacheEntry != null) { + try { + val cachedAccount: Account = jsonProvider.applicationJsonConfiguration.decodeFromString(cacheEntry) + return cachedAccount + } catch (_: SerializationException) { + } catch (_: IllegalArgumentException) { + } + } + + return null + } + + private fun getAccountDataCacheKey(bearerToken: String) = stringPreferencesKey(bearerToken) } diff --git a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt index b87ddd6a6..e671f2fd3 100644 --- a/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt +++ b/core/data/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/data/service/network/impl/ExerciseServiceImpl.kt @@ -7,11 +7,13 @@ import de.tum.informatics.www1.artemis.native_app.core.data.service.network.Exer import de.tum.informatics.www1.artemis.native_app.core.data.service.KtorProvider import de.tum.informatics.www1.artemis.native_app.core.data.service.impl.JsonProvider import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise -import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.appendPathSegments import io.ktor.http.contentType +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject internal class ExerciseServiceImpl( private val ktorProvider: KtorProvider, @@ -23,14 +25,20 @@ internal class ExerciseServiceImpl( authToken: String ): NetworkResponse { return performNetworkCall { - ktorProvider.ktorClient.get(serverUrl) { - url { - appendPathSegments("api", "exercises", exerciseId.toString(), "details") + val response = ktorProvider.ktorClient.get(serverUrl) { + url { + appendPathSegments("api", "exercises", exerciseId.toString(), "details") + } + + contentType(ContentType.Application.Json) + cookieAuth(authToken) } - contentType(ContentType.Application.Json) - cookieAuth(authToken) - }.body() + val jsonElement = jsonProvider.applicationJsonConfiguration.parseToJsonElement(response.bodyAsText()) + val exercise = jsonProvider.applicationJsonConfiguration + .decodeFromJsonElement(jsonElement.jsonObject["exercise"]!!) + + exercise } } -} \ No newline at end of file +} diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationManager.kt b/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/ArtemisNotificationManager.kt similarity index 93% rename from feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationManager.kt rename to core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/ArtemisNotificationManager.kt index c46c8262c..efa57bcdd 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationManager.kt +++ b/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/ArtemisNotificationManager.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.feature.push +package de.tum.informatics.www1.artemis.native_app.core.datastore import android.content.Context import androidx.datastore.preferences.core.edit diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/Dashboard.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/Dashboard.kt index 59fb65b6d..b3cfa3360 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/Dashboard.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/Dashboard.kt @@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable * A dashboard is a collection of courses. */ @Serializable -data class Dashboard(val courses: List) +data class Dashboard(val courses: List = emptyList()) diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt index 9e665cc41..f1cad1241 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/Exercise.kt @@ -39,13 +39,15 @@ sealed class Exercise { abstract val mode: Mode abstract val categories: List abstract val visibleToStudents: Boolean? + abstract val secondCorrectionEnabled: Boolean? + abstract val presentationScoreEnabled: Boolean? abstract val teamMode: Boolean abstract val studentAssignedTeamId: Long? abstract val studentAssignedTeamIdComputed: Boolean abstract val problemStatement: String? abstract val assessmentType: AssessmentType? abstract val allowComplaintsForAutomaticAssessments: Boolean? - abstract val allowManualFeedbackRequests: Boolean? + abstract val allowFeedbackRequests: Boolean? abstract val includedInOverallScore: IncludedInOverallScore abstract val exampleSolutionPublicationDate: Instant? abstract val studentParticipations: List? diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt index 8f728d935..a3f0c6220 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/FileUploadExercise.kt @@ -20,6 +20,8 @@ data class FileUploadExercise( override val assessmentDueDate: Instant? = null, override val difficulty: Difficulty? = null, override val mode: Mode = Mode.INDIVIDUAL, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val categories: List = emptyList(), override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, @@ -28,7 +30,7 @@ data class FileUploadExercise( override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt index de29e6863..1f5befe08 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ModelingExercise.kt @@ -25,10 +25,12 @@ data class ModelingExercise( override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, override val studentAssignedTeamIdComputed: Boolean = false, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt index abd6923cc..87eb872f6 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/ProgrammingExercise.kt @@ -29,11 +29,13 @@ data class ProgrammingExercise( override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt index c7bdbaec2..b7875d1d2 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/QuizExercise.kt @@ -30,11 +30,13 @@ data class QuizExercise( override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, override val studentAssignedTeamId: Long? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt index f7c3a799b..7a80f7a85 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/TextExercise.kt @@ -23,12 +23,14 @@ data class TextExercise( override val categories: List = emptyList(), override val visibleToStudents: Boolean? = null, override val teamMode: Boolean = false, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val studentAssignedTeamId: Long? = null, override val studentAssignedTeamIdComputed: Boolean = false, override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt index 0ab6e8075..c0c2eb3f9 100644 --- a/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt +++ b/core/model/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/model/exercise/UnknownExercise.kt @@ -29,7 +29,9 @@ data class UnknownExercise( override val problemStatement: String? = null, override val assessmentType: AssessmentType? = null, override val allowComplaintsForAutomaticAssessments: Boolean? = null, - override val allowManualFeedbackRequests: Boolean? = null, + override val allowFeedbackRequests: Boolean? = null, + override val secondCorrectionEnabled: Boolean = false, + override val presentationScoreEnabled: Boolean = false, override val includedInOverallScore: IncludedInOverallScore = IncludedInOverallScore.INCLUDED_COMPLETELY, override val exampleSolutionPublicationDate: Instant? = null, override val attachments: List = emptyList(), diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt index 6a8ada4e9..f189deaca 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/common/BasicHintTextField.kt @@ -1,6 +1,8 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.common import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable @@ -11,8 +13,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText @@ -27,7 +31,7 @@ fun BasicHintTextField( hintStyle: TextStyle = LocalTextStyle.current ) { var hasFocus by remember { mutableStateOf(false) } - + val keyboardController = LocalSoftwareKeyboardController.current val isValueDisplayed = value.isNotBlank() || (hasFocus && hideHintOnFocus) val currentValue = if (isValueDisplayed) value else hint @@ -48,6 +52,14 @@ fun BasicHintTextField( AnnotatedString(text = currentValue, spanStyle = hintStyle.toSpanStyle()), OffsetMapping.Identity ) - } + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + } + ) ) } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt index 1a1f41c5b..286ac4a67 100644 --- a/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt +++ b/core/ui/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/ui/exercise/ExerciseActionButtons.kt @@ -1,11 +1,24 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.exercise +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.ProgrammingExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.QuizExercise @@ -116,6 +129,10 @@ fun ExerciseActionButtons( ) } } + } else { + Row(modifier=Modifier.padding(top=2.dp, bottom = 2.dp)) { + InfoMessageCard() + } } if (templateStatus is ResultTemplateStatus.WithResult) { @@ -187,4 +204,30 @@ class BoundExerciseActions( onClickViewResult = { onClickViewResult(exerciseId) }, onClickViewQuizResults = { onClickViewQuizResults(exerciseId) } ) -} \ No newline at end of file +} + + +@Composable +fun InfoMessageCard() { + Box( + modifier = Modifier + .border(width = 2.dp, color = Color.LightGray) + .background(Color(0xFFB3E5FC)) // Light sky blue background + .padding(10.dp) + .fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "Information", + modifier = Modifier.padding(end = 8.dp), + tint = Color(0xFF0288D1) + ) + Text( + text = stringResource(id = R.string.exercise_participation_not_possible), + fontSize = 16.sp, + color = Color.Black + ) + } + } +} diff --git a/core/ui/src/main/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 551cb0bb4..9c3e293c7 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 @@ -65,7 +65,8 @@ import io.noties.markwon.linkify.LinkifyPlugin * SOFTWARE. */ -val LocalMarkdownTransformer = compositionLocalOf { ArtemisMarkdownTransformer } +val LocalMarkdownTransformer = + compositionLocalOf { ArtemisMarkdownTransformer } @Composable fun MarkdownText( @@ -131,6 +132,8 @@ fun MarkdownText( true } } + + textView.applyStyleAndColor(fontSize, textAlign, color, defaultColor, style) }, onReset = {}, onRelease = {} @@ -150,15 +153,6 @@ private fun createTextView( onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null ): TextView { - - val textColor = color.takeOrElse { style.color.takeOrElse { defaultColor } } - val mergedStyle = style.merge( - TextStyle( - color = textColor, - fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize, - textAlign = textAlign, - ) - ) return TextView(context).apply { onClick?.let { setOnClickListener { onClick() } } onLongClick?.let { @@ -167,24 +161,46 @@ private fun createTextView( true } } - setTextColor(textColor.toArgb()) setMaxLines(maxLines) - setTextSize(TypedValue.COMPLEX_UNIT_DIP, mergedStyle.fontSize.value) highlightColor = android.graphics.Color.TRANSPARENT viewId?.let { id = viewId } - textAlign?.let { align -> - textAlignment = when (align) { - TextAlign.Left, TextAlign.Start -> View.TEXT_ALIGNMENT_TEXT_START - TextAlign.Right, TextAlign.End -> View.TEXT_ALIGNMENT_TEXT_END - TextAlign.Center -> View.TEXT_ALIGNMENT_CENTER - else -> View.TEXT_ALIGNMENT_TEXT_START - } - } fontResource?.let { font -> typeface = ResourcesCompat.getFont(context, font) } + + applyStyleAndColor(fontSize, textAlign, color, defaultColor, style) + } +} + +private fun TextView.applyStyleAndColor( + fontSize: TextUnit, + textAlign: TextAlign?, + color: Color, + defaultColor: Color, + style: TextStyle +) { + val textColor = color.takeOrElse { style.color.takeOrElse { defaultColor } } + + val mergedStyle = style.merge( + TextStyle( + color = textColor, + fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize, + textAlign = textAlign, + ) + ) + + setTextColor(textColor.toArgb()) + setTextSize(TypedValue.COMPLEX_UNIT_DIP, mergedStyle.fontSize.value) + + textAlign?.let { align -> + textAlignment = when (align) { + TextAlign.Left, TextAlign.Start -> View.TEXT_ALIGNMENT_TEXT_START + TextAlign.Right, TextAlign.End -> View.TEXT_ALIGNMENT_TEXT_END + TextAlign.Center -> View.TEXT_ALIGNMENT_CENTER + else -> View.TEXT_ALIGNMENT_TEXT_START + } } } diff --git a/core/ui/src/main/res/values/exercise_strings.xml b/core/ui/src/main/res/values/exercise_strings.xml index 5b518152b..1929bc041 100644 --- a/core/ui/src/main/res/values/exercise_strings.xml +++ b/core/ui/src/main/res/values/exercise_strings.xml @@ -39,6 +39,8 @@ View result + Participating this exercise is currently + not possible in the mobile app. Start exercise Open exercise View submission diff --git a/docker/mysql.yml b/docker/mysql.yml index dc1118652..72896b51d 100644 --- a/docker/mysql.yml +++ b/docker/mysql.yml @@ -5,7 +5,7 @@ services: mysql: container_name: artemis-mysql - image: docker.io/library/mysql:8.0.33 + image: docker.io/library/mysql:9.0.1 # DO NOT use this default file for production systems! environment: MYSQL_ALLOW_EMPTY_PASSWORD: true diff --git a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt index 5501f043b..7852856a9 100644 --- a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt +++ b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt @@ -106,10 +106,13 @@ fun `Metis - Conversation Overview`() { ): Flow = flowOf( ConversationPreferenceService.Preferences( favouritesExpanded = true, - channelsExpanded = true, + generalsExpanded = true, groupChatsExpanded = true, personalConversationsExpanded = true, - hiddenExpanded = false + hiddenExpanded = false, + examsExpanded = true, + exercisesExpanded = true, + lecturesExpanded = true, ) ) @@ -140,7 +143,9 @@ fun `Metis - Conversation Overview`() { viewModel = viewModel, onNavigateToConversation = {}, onRequestCreatePersonalConversation = {}, - onRequestAddChannel = {} + onRequestAddChannel = {}, + onRequestBrowseChannel = {}, + canCreateChannel = false, ) }, onNavigateBack = { }, @@ -248,7 +253,9 @@ fun `Metis - Conversation Channel`() { onDeletePost = { CompletableDeferred() }, onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, bottomItem = null, - onClickViewPost = {} + onClickViewPost = {}, + onRequestRetrySend = {}, + title = "" ) } ) diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt index 5990ab744..7da78614e 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ArtemisWebView.kt @@ -10,6 +10,7 @@ import android.webkit.WebSettings import android.webkit.WebView import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -116,7 +117,7 @@ internal fun ArtemisWebView( Box(modifier = modifier) { WebView( - modifier = Modifier, + modifier = Modifier.fillMaxSize(), client = remember(value) { ThemeClient(value) }, state = webViewState, onCreated = { diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt index 5815bb673..623108980 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/ExerciseViewUi.kt @@ -1,6 +1,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt index 268a0c190..1bd9ba8fd 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenBody.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.HelpCenter @@ -74,7 +72,6 @@ internal fun ExerciseScreenBody( exerciseOverviewTab( Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) .padding(horizontal = 8.dp) ) diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt index 3914aac2b..31c6dba97 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/ExerciseScreenTopAppBar.kt @@ -2,6 +2,7 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home import androidx.annotation.StringRes import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -9,11 +10,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -22,7 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -38,6 +41,7 @@ import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.google.accompanist.placeholder.material.placeholder @@ -134,8 +138,22 @@ internal fun TopBarExerciseInformation( ) { val dueDate = exercise.bind { it.dueDate }.orElse(null) val assessmentDueData = exercise.bind { it.assessmentDueDate }.orElse(null) - - // Prepare ui that is movable between long and short toolbars + val releaseData = exercise.bind { it.releaseDate }.orElse(null) + + var maxWidth: Int by remember { mutableIntStateOf(0) } + val updateMaxWidth = { new: Int -> maxWidth = new } + + val dueDateTopBarTextInformation = + @Composable { date: Instant, hintRes: @receiver:StringRes Int -> + TopBarTextInformation( + modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp), + hintColumnWidth = maxWidth, + hint = stringResource(id = hintRes), + dataText = getRelativeTime(to = date).toString(), + dataColor = getDueDateColor(date), + updateHintColumnWidth = updateMaxWidth + ) + } val exerciseInfoUi = @Composable { EmptyDataStateUi( @@ -178,31 +196,27 @@ internal fun TopBarExerciseInformation( } Text( - modifier = Modifier.placeholder(exercise !is DataState.Success), + modifier = Modifier + .placeholder(exercise !is DataState.Success) + .padding(bottom = 4.dp), text = pointsHintText, style = MaterialTheme.typography.bodyLarge ) + + releaseData?.let { + dueDateTopBarTextInformation( + it, + R.string.exercise_view_overview_hint_assessment_release_date + ) + } + } val dueDateColumnUi = @Composable { contentModifier: Modifier -> - var maxWidth: Int by remember { mutableStateOf(0) } - val updateMaxWidth = { new: Int -> maxWidth = new } Column( modifier = contentModifier, - verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val dueDateTopBarTextInformation = - @Composable { date: Instant, hintRes: @receiver:StringRes Int -> - TopBarTextInformation( - modifier = Modifier.fillMaxWidth(), - hintColumnWidth = maxWidth, - hint = stringResource(id = hintRes), - dataText = getRelativeTime(to = date).toString(), - dataColor = getDueDateColor(date), - updateHintColumnWidth = updateMaxWidth - ) - } dueDate?.let { dueDateTopBarTextInformation( @@ -217,21 +231,40 @@ internal fun TopBarExerciseInformation( R.string.exercise_view_overview_hint_assessment_due_date ) } + + val complaintPossible = exercise.bind { exercise -> + exercise.allowComplaintsForAutomaticAssessments + }.orElse(false) + + Text( + modifier = Modifier + .placeholder(exercise !is DataState.Success) + .padding(bottom = 4.dp), + text = "Complaint possible: " + if (complaintPossible == true) "Yes" else "No", + style = MaterialTheme.typography.bodyLarge + ) + } } // Actual UI Column( - modifier = modifier, + modifier = modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - TitleText( + + Text( + text = "Exercise Details", + style = MaterialTheme.typography.titleMedium, modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = titleTextAlpha }, - exerciseDataState = exercise, - style = MaterialTheme.typography.headlineLarge, - maxLines = 2 + .padding(bottom = 1.dp) + .fillMaxWidth(), + textAlign = TextAlign.Start + ) + Divider( + color = Color.Black, + thickness = 3.dp, + modifier = Modifier.padding(vertical = 0.dp) ) // Here we make the distinction in the layout between long toolbar and short toolbar @@ -370,7 +403,7 @@ internal fun StaticTopAppBar( Column(modifier = modifier) { TopAppBar( modifier = Modifier.fillMaxWidth(), - title = {}, + title = { TitleText(modifier = modifier, maxLines = 1, exerciseDataState = exerciseDataState) }, navigationIcon = { TopAppBarNavigationIcon(onNavigateBack = onNavigateBack) }, @@ -382,7 +415,8 @@ internal fun StaticTopAppBar( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .border(2.dp, Color.Black, RoundedCornerShape(4.dp)), titleTextAlpha = 1f, exercise = exerciseDataState, isLongToolbar = isLongToolbar diff --git a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt index 2caba8fa8..b351c303f 100644 --- a/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt +++ b/feature/exercise-view/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/exerciseview/home/overview/ExerciseOverviewTab.kt @@ -2,9 +2,12 @@ package de.tum.informatics.www1.artemis.native_app.feature.exerciseview.home.ove import android.annotation.SuppressLint import android.webkit.WebView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.google.accompanist.web.WebViewState import de.tum.informatics.www1.artemis.native_app.core.model.exercise.Exercise @@ -15,7 +18,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ArtemisWe @SuppressLint("SetJavaScriptEnabled") @Composable internal fun ExerciseOverviewTab( - modifier: Modifier, + modifier: Modifier = Modifier, exercise: Exercise, webViewState: WebViewState?, serverUrl: String, @@ -24,24 +27,41 @@ internal fun ExerciseOverviewTab( webView: WebView?, actions: ExerciseActions ) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Spacer(modifier = Modifier) + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ParticipationStatusUi( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), exercise = exercise, actions = actions ) if (exercise !is QuizExercise && webViewState != null) { ArtemisWebView( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .weight(1f), webViewState = webViewState, webView = webView, serverUrl = serverUrl, authToken = authToken, setWebView = setWebView ) + } else { + Text( + text = "No problem statement available.", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + color = Color.Gray + ) } } } diff --git a/feature/exercise-view/src/main/res/values/exercise_view_strings.xml b/feature/exercise-view/src/main/res/values/exercise_view_strings.xml index d7cc8a39f..8580ba5ff 100644 --- a/feature/exercise-view/src/main/res/values/exercise_view_strings.xml +++ b/feature/exercise-view/src/main/res/values/exercise_view_strings.xml @@ -13,6 +13,7 @@ No points Submission due: Assessment due: + Release date: Your exercise status: diff --git a/feature/metis/conversation/build.gradle.kts b/feature/metis/conversation/build.gradle.kts index 6d2158d61..9058f8386 100644 --- a/feature/metis/conversation/build.gradle.kts +++ b/feature/metis/conversation/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation(libs.androidx.emoji2.views) implementation(libs.androidx.emoji2.views.helper) implementation(libs.androidx.emoji2.emojiPicker) + implementation(libs.koin.androidx.workmanager) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.dataStore.preferences) diff --git a/feature/metis/conversation/src/main/AndroidManifest.xml b/feature/metis/conversation/src/main/AndroidManifest.xml new file mode 100644 index 000000000..972f3b970 --- /dev/null +++ b/feature/metis/conversation/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ 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/conversation_module.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/conversation_module.kt index 7a80138a3..38fb47cc8 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/conversation_module.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/conversation_module.kt @@ -1,6 +1,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService 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.CreatePostServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisModificationService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService @@ -11,8 +13,11 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ser import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.impl.MetisStorageServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.impl.ReplyTextStorageServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ConversationViewModel +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.CreateClientSidePostWorker +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.SendConversationPostWorker import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.androidx.workmanager.dsl.workerOf import org.koin.dsl.module val conversationModule = module { @@ -23,6 +28,11 @@ val conversationModule = module { single { MetisStorageServiceImpl(get()) } single { ReplyTextStorageServiceImpl(androidContext()) } + single { CreatePostServiceImpl(androidContext()) } + + workerOf(::SendConversationPostWorker) + workerOf(::CreateClientSidePostWorker) + viewModel { params -> ConversationViewModel( params[0], @@ -38,6 +48,7 @@ val conversationModule = module { get(), get(), get(), + get(), get() ) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/CreatePostService.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/CreatePostService.kt new file mode 100644 index 000000000..f5d1b231c --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/CreatePostService.kt @@ -0,0 +1,50 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service + +import androidx.work.WorkContinuation +import kotlinx.coroutines.flow.Flow + +typealias CreatePostConfigurationBlock = WorkContinuation.(clientSidePostId: String) -> WorkContinuation + +interface CreatePostService { + + + fun createPost( + courseId: Long, + conversationId: Long, + content: String, + configure: CreatePostConfigurationBlock = { this } + ) + + fun retryCreatePost( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + configure: CreatePostConfigurationBlock = { this } + ) + + fun createAnswerPost( + courseId: Long, + conversationId: Long, + parentPostId: Long, + content: String, + configure: CreatePostConfigurationBlock = { this } + ) + + fun retryCreateAnswerPost( + courseId: Long, + conversationId: Long, + parentPostId: Long, + clientSidePostId: String, + content: String, + configure: CreatePostConfigurationBlock = { this } + ) + + fun observeCreatePostWorkStatus(clientSidePostId: String): Flow + + enum class Status { + PENDING, + FAILED, + FINISHED + } +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/impl/CreatePostServiceImpl.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/impl/CreatePostServiceImpl.kt new file mode 100644 index 000000000..ff44bc5b0 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/impl/CreatePostServiceImpl.kt @@ -0,0 +1,162 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl + +import android.annotation.SuppressLint +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import de.tum.informatics.www1.artemis.native_app.core.common.defaultInternetWorkRequest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostConfigurationBlock +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.BaseCreatePostWorker +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.CreateClientSidePostWorker +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.SendConversationPostWorker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.UUID + +internal class CreatePostServiceImpl(private val context: Context) : CreatePostService { + + override fun createPost( + courseId: Long, + conversationId: Long, + content: String, + configure: CreatePostConfigurationBlock + ) { + scheduleCreatePostWork( + courseId = courseId, + conversationId = conversationId, + content = content, + postType = BaseCreatePostWorker.PostType.POST, + parentPostId = null, + clientSidePostId = null, + configure = configure + ) + } + + override fun createAnswerPost( + courseId: Long, + conversationId: Long, + parentPostId: Long, + content: String, + configure: CreatePostConfigurationBlock + ) { + scheduleCreatePostWork( + courseId = courseId, + conversationId = conversationId, + content = content, + postType = BaseCreatePostWorker.PostType.ANSWER_POST, + parentPostId = parentPostId, + clientSidePostId = null, + configure = configure + ) + } + + override fun retryCreatePost( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + configure: CreatePostConfigurationBlock + ) { + scheduleCreatePostWork( + courseId = courseId, + conversationId = conversationId, + content = content, + postType = BaseCreatePostWorker.PostType.POST, + parentPostId = null, + clientSidePostId = clientSidePostId, + configure = configure + ) + } + + override fun retryCreateAnswerPost( + courseId: Long, + conversationId: Long, + parentPostId: Long, + clientSidePostId: String, + content: String, + configure: CreatePostConfigurationBlock + ) { + scheduleCreatePostWork( + courseId = courseId, + conversationId = conversationId, + content = content, + postType = BaseCreatePostWorker.PostType.POST, + parentPostId = parentPostId, + clientSidePostId = clientSidePostId, + configure = configure + ) + } + + override fun observeCreatePostWorkStatus(clientSidePostId: String): Flow { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(getWorkName(clientSidePostId)) + .map { info -> + val failed = + info.any { it.state == WorkInfo.State.FAILED || it.state == WorkInfo.State.CANCELLED } + val done = info.all { it.state == WorkInfo.State.SUCCEEDED } + + when { + failed -> CreatePostService.Status.FAILED + done -> CreatePostService.Status.FINISHED + else -> CreatePostService.Status.PENDING + } + } + } + + @SuppressLint("EnqueueWork") + private fun scheduleCreatePostWork( + courseId: Long, + conversationId: Long, + content: String, + postType: BaseCreatePostWorker.PostType, + parentPostId: Long?, + clientSidePostId: String?, + configure: CreatePostConfigurationBlock + ) { + val postId = clientSidePostId ?: UUID.randomUUID().toString() + + val inputData = BaseCreatePostWorker.createWorkInput( + courseId = courseId, + conversationId = conversationId, + clientSidePostId = postId, + content = content, + postType = postType, + parentPostId = parentPostId + ) + + val createPostRequest = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .build() + + val sendPostRequest = + defaultInternetWorkRequest(inputData = inputData) + + if (clientSidePostId == null) { + WorkManager + .getInstance(context) + .beginUniqueWork( + getWorkName(postId), + ExistingWorkPolicy.REPLACE, + createPostRequest + ) + .then(sendPostRequest) + .let { configure(it, postId) } + .enqueue() + } else { + WorkManager + .getInstance(context) + .beginUniqueWork( + getWorkName(clientSidePostId), + ExistingWorkPolicy.REPLACE, + sendPostRequest + ) + .let { configure(it, postId) } + .enqueue() + } + } + + private fun getWorkName(clientSidePostId: String) = "UploadPost-$clientSidePostId" +} \ 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/service/storage/MetisStorageService.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/MetisStorageService.kt index 180b11ec8..ebad0f32a 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/MetisStorageService.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/service/storage/MetisStorageService.kt @@ -2,6 +2,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.se import androidx.paging.PagingSource 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.StandalonePost 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.pojo.PostPojo @@ -16,14 +18,11 @@ interface MetisStorageService { /** * Permanently store the given posts. If a post with an identical id already exists, the existing post is updated. * The answer posts are also updated by either being inserted or updated. - * - * @param clearPreviousPosts if all posts matching the context should be cleared before anything being inserted. */ suspend fun insertOrUpdatePosts( host: String, metisContext: MetisContext, - posts: List, - clearPreviousPosts: Boolean + posts: List ) /** @@ -39,6 +38,29 @@ interface MetisStorageService { removeAllOlderPosts: Boolean ) + /** + * Insert a post which has not been sent to the server yet and, thus, does nto have a server side post id yet. + * @return the client side post id for future referencing or null if inserting the post failed. + */ + suspend fun insertClientSidePost( + host: String, + metisContext: MetisContext, + post: BasePost, + clientSidePostId: String + ) + + /** + * Assigns a post previously created using [insertClientSidePost] a server side post. Therefore, the post will be transformed to a regular + * post. Also updates all fields of the post in the db with the values of [post]. + */ + suspend fun upgradeClientSidePost(host: String, metisContext: MetisContext, clientSidePostId: String, post: StandalonePost) + + /** + * Assigns a post previously created using [insertClientSidePost] a server side post. Therefore, the post will be transformed to a regular + * post. Also updates all fields of the post in the db with the values of [post]. + */ + suspend fun upgradeClientSideAnswerPost(host: String, metisContext: MetisContext, clientSidePostId: String, post: AnswerPost) + /** * Update the post with the same id in the database. If this post does not exist, no action is taken. */ @@ -60,7 +82,11 @@ interface MetisStorageService { metisContext: MetisContext ): PagingSource - fun getLatestKnownPost(serverId: String, metisContext: MetisContext): Flow + fun getLatestKnownPost( + serverId: String, + metisContext: MetisContext, + allowClientSidePost: Boolean + ): Flow /** * Query the given post with the given client post id. 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 71e886147..5e46e455e 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 @@ -6,6 +6,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.M 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.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 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.Reaction @@ -71,7 +72,7 @@ internal class MetisStorageServiceImpl( postId = clientSidePostId, postingType = BasePostingEntity.PostingType.STANDALONE, authorId = author?.id ?: return null, - creationDate = creationDate ?: Instant.fromEpochSeconds(0), + creationDate = creationDate, content = content, authorRole = authorRole?.asDb ?: UserRole.USER, updatedDate = updatedDate @@ -144,7 +145,7 @@ internal class MetisStorageServiceImpl( private fun MetisContext.toPostMetisContext( serverId: String, clientSidePostId: String, - serverSidePostId: Long, + serverSidePostId: Long?, postingType: BasePostingEntity.PostingType ): MetisPostContextEntity = MetisPostContextEntity( @@ -162,18 +163,11 @@ internal class MetisStorageServiceImpl( override suspend fun insertOrUpdatePosts( host: String, metisContext: MetisContext, - posts: List, - clearPreviousPosts: Boolean + posts: List ) { val metisDao = databaseProvider.metisDao databaseProvider.database.withTransaction { - if (clearPreviousPosts) { - metisDao.clearAll( - host - ) - } - insertOrUpdateImpl(posts, metisDao, host, metisContext) } } @@ -196,11 +190,12 @@ internal class MetisStorageServiceImpl( ) insertOrUpdatePost( - metisDao, - host, - metisContext, - queryClientPostId, - sp, + metisDao = metisDao, + isNewPost = queryClientPostId == null, + host = host, + metisContext = metisContext, + clientSidePostId = queryClientPostId ?: UUID.randomUUID().toString(), + sp = sp, isLiveCreated = false ) } @@ -231,7 +226,7 @@ internal class MetisStorageServiceImpl( host = host, courseId = metisContext.courseId, conversationId = metisContext.conversationId, - serverIds = posts.map { it.serverPostId }, + serverIds = posts.mapNotNull { it.serverPostId }, startInstant = oldestPost.creationDate, endInstant = newestPost.creationDate ) @@ -247,6 +242,124 @@ internal class MetisStorageServiceImpl( } } + override suspend fun insertClientSidePost( + host: String, + metisContext: MetisContext, + post: BasePost, + clientSidePostId: String + ) { + val metisDao = databaseProvider.metisDao + + databaseProvider.database.withTransaction { + when (post) { + is StandalonePost -> { + insertOrUpdatePost( + metisDao = databaseProvider.metisDao, + isNewPost = true, + host = host, + metisContext = metisContext, + clientSidePostId = clientSidePostId, + sp = post, + isLiveCreated = false + ) + } + + is AnswerPost -> { + // First figure out the parent and then insert it normally + val parentClientPostId = metisDao.queryClientPostId( + serverId = host, + postId = post.post?.serverPostId ?: return@withTransaction null, + postingType = BasePostingEntity.PostingType.STANDALONE + ) ?: return@withTransaction null + + insertOrUpdateAnswerPost( + isNewPost = true, + answerPostClientSidePostId = clientSidePostId, + answerPost = post, + metisDao = metisDao, + host = host, + parentPostClientSidePostId = parentClientPostId, + metisContext = metisContext, + answerPostId = null + ) + } + } + } + } + + override suspend fun upgradeClientSidePost( + host: String, + metisContext: MetisContext, + clientSidePostId: String, + post: StandalonePost + ) { + val metisDao = databaseProvider.metisDao + + databaseProvider.database.withTransaction { + val doesPostAlreadyExist = 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. In that case, we can delete the client side post + if (doesPostAlreadyExist) { + // instead of upgrading, we delete the client side post + metisDao.deletePostingWithClientSideId(clientPostId = clientSidePostId) + return@withTransaction + } + + metisDao.upgradePost( + clientSidePostId = clientSidePostId, + serverSidePostId = post.id ?: return@withTransaction + ) + + insertOrUpdatePost( + metisDao = metisDao, + isNewPost = false, + host = host, + metisContext = metisContext, + clientSidePostId = clientSidePostId, + sp = post, + isLiveCreated = false + ) + } + } + + override suspend fun upgradeClientSideAnswerPost( + host: String, + metisContext: MetisContext, + clientSidePostId: String, + post: AnswerPost + ) { + val metisDao = databaseProvider.metisDao + + databaseProvider.database.withTransaction { + metisDao.upgradePost( + clientSidePostId = clientSidePostId, + serverSidePostId = post.id ?: return@withTransaction + ) + + val parentClientSidePostId = metisDao.queryClientPostId( + host, + post.post?.serverPostId ?: return@withTransaction, + BasePostingEntity.PostingType.STANDALONE + ) ?: return@withTransaction + + insertOrUpdateAnswerPost( + isNewPost = false, + answerPostClientSidePostId = clientSidePostId, + answerPost = post, + metisDao = metisDao, + host = host, + parentPostClientSidePostId = parentClientSidePostId, + metisContext = metisContext, + answerPostId = post.serverPostId + ) + } + } + override suspend fun updatePost( host: String, metisContext: MetisContext, @@ -268,6 +381,7 @@ internal class MetisStorageServiceImpl( insertOrUpdatePost( metisDao, + isNewPost = false, host, metisContext, clientSidePostId, @@ -290,7 +404,13 @@ internal class MetisStorageServiceImpl( serverId = host, postId = post.id ?: 0L, postingType = BasePostingEntity.PostingType.STANDALONE - ) ?: insertOrUpdatePost(metisDao, host, metisContext, null, post, true) + ) ?: insertOrUpdatePost( + metisDao = metisDao, isNewPost = true, + host = host, + metisContext = metisContext, + sp = post, + isLiveCreated = true + ) } } @@ -299,9 +419,10 @@ internal class MetisStorageServiceImpl( */ private suspend fun insertOrUpdatePost( metisDao: MetisDao, + isNewPost: Boolean, host: String, metisContext: MetisContext, - queryClientPostId: String?, + clientSidePostId: String = UUID.randomUUID().toString(), sp: StandalonePost, isLiveCreated: Boolean ): String? { @@ -311,8 +432,6 @@ internal class MetisStorageServiceImpl( displayName = sp.author?.name ?: return null ) - val clientSidePostId: String = queryClientPostId ?: UUID.randomUUID().toString() - val (standaloneBasePosting, standalonePosting) = sp.asDb( serverId = host, clientSidePostId = clientSidePostId, @@ -332,7 +451,7 @@ internal class MetisStorageServiceImpl( ) } - //First insert the users as they have no dependencies + // First insert the users as they have no dependencies metisDao.updateUsers(standalonePostReactionsUsers) metisDao.insertUsers(standalonePostReactionsUsers) @@ -343,13 +462,22 @@ internal class MetisStorageServiceImpl( metisContext.toPostMetisContext( serverId = host, clientSidePostId = clientSidePostId, - serverSidePostId = standalonePostId ?: return null, + serverSidePostId = standalonePostId, postingType = BasePostingEntity.PostingType.STANDALONE ) - if (queryClientPostId != null) { + if (isNewPost) { + metisDao.insertBasePost(standaloneBasePosting) + metisDao.insertPost(standalonePosting) + metisDao.insertReactions(standalonePostReactions) + metisDao.insertTags(tags) + + metisDao.insertPostMetisContext( + postMetisContext + ) + } else { val isPostPresent = metisDao.isPostPresentInContext( - queryClientPostId, + clientSidePostId, standalonePostId, courseId = postMetisContext.courseId, conversationId = metisContext.conversationId @@ -369,74 +497,86 @@ internal class MetisStorageServiceImpl( metisDao.insertTags(tags) metisDao.removeSuperfluousTags(clientSidePostId, tags.map { it.tag }) metisDao.removeSuperfluousAnswerPosts( - host, - clientSidePostId, - sp.answers.orEmpty().mapNotNull { it.id } - ) - } else { - metisDao.insertBasePost(standaloneBasePosting) - metisDao.insertPost(standalonePosting) - metisDao.insertReactions(standalonePostReactions) - metisDao.insertTags(tags) - - metisDao.insertPostMetisContext( - postMetisContext + host = host, + standalonePostClientId = clientSidePostId, + answerServerIds = sp.answers.orEmpty().mapNotNull { it.id } ) } for (ap in sp.answers.orEmpty()) { - val answerPostId = ap.id + val answerPostId = ap.id ?: continue val queryClientPostIdAnswer = metisDao.queryClientPostId( serverId = host, - postId = answerPostId ?: return null, + postId = answerPostId, postingType = BasePostingEntity.PostingType.ANSWER ) - val answerClientSidePostId = - queryClientPostIdAnswer ?: UUID.randomUUID().toString() - val authorRole = (if (queryClientPostIdAnswer != null && ap.authorRole == null) { - metisDao.queryPostAuthorRole(answerClientSidePostId) - } else ap.authorRole?.asDb)?.asNetwork + insertOrUpdateAnswerPost( + isNewPost = queryClientPostIdAnswer != null, + answerPostClientSidePostId = queryClientPostIdAnswer ?: UUID.randomUUID() + .toString(), + answerPost = ap, + metisDao = metisDao, + host = host, + parentPostClientSidePostId = clientSidePostId, + metisContext = metisContext, + answerPostId = answerPostId + ) + } + + return clientSidePostId + } + + private suspend fun insertOrUpdateAnswerPost( + isNewPost: Boolean, + answerPostClientSidePostId: String, + answerPost: AnswerPost, + metisDao: MetisDao, + host: String, + parentPostClientSidePostId: String, + metisContext: MetisContext, + answerPostId: Long? + ) { + val authorRole = (if (isNewPost && answerPost.authorRole == null) { + metisDao.queryPostAuthorRole(answerPostClientSidePostId) + } else answerPost.authorRole?.asDb)?.asNetwork - val (basePostingEntity, answerPostingEntity, metisUserEntity) = ap - .copy(authorRole = authorRole) - .asDb( + val (basePostingEntity, answerPostingEntity, metisUserEntity) = answerPost + .copy(authorRole = authorRole) + .asDb( + serverId = host, + clientSidePostId = answerPostClientSidePostId, + parentClientSidePostId = parentPostClientSidePostId + ) ?: return + + val answerPostReactionsWithUsers = + answerPost.reactions.orEmpty().mapNotNull { it.asDb(host, answerPostClientSidePostId) } + val answerPostReactions = answerPostReactionsWithUsers.map { it.first } + val answerPostReactionUsers = answerPostReactionsWithUsers.map { it.second } + + metisDao.insertOrUpdateUser(metisUserEntity) + metisDao.updateUsers(answerPostReactionUsers) + metisDao.insertUsers(answerPostReactionUsers) + + if (isNewPost) { + metisDao.insertBasePost(basePostingEntity) + metisDao.insertAnswerPosting(answerPostingEntity) + metisDao.insertReactions(answerPostReactions) + metisDao.insertPostMetisContext( + metisContext.toPostMetisContext( serverId = host, - clientSidePostId = answerClientSidePostId, - parentClientSidePostId = clientSidePostId - ) ?: return null - - val answerPostReactionsWithUsers = - ap.reactions.orEmpty().mapNotNull { it.asDb(host, answerClientSidePostId) } - val answerPostReactions = answerPostReactionsWithUsers.map { it.first } - val answerPostReactionUsers = answerPostReactionsWithUsers.map { it.second } - - metisDao.insertOrUpdateUser(metisUserEntity) - metisDao.updateUsers(answerPostReactionUsers) - metisDao.insertUsers(answerPostReactionUsers) - - if (queryClientPostIdAnswer != null) { - metisDao.updateBasePost(basePostingEntity) - metisDao.updateAnswerPosting(answerPostingEntity) - - metisDao.updateReactions(answerClientSidePostId, answerPostReactions) - } else { - metisDao.insertBasePost(basePostingEntity) - metisDao.insertAnswerPosting(answerPostingEntity) - metisDao.insertReactions(answerPostReactions) - metisDao.insertPostMetisContext( - metisContext.toPostMetisContext( - serverId = host, - clientSidePostId = answerClientSidePostId, - serverSidePostId = answerPostId, - postingType = BasePostingEntity.PostingType.ANSWER - ) + clientSidePostId = answerPostClientSidePostId, + serverSidePostId = answerPostId, + postingType = BasePostingEntity.PostingType.ANSWER ) - } - } + ) + } else { + metisDao.updateBasePost(basePostingEntity) + metisDao.updateAnswerPosting(answerPostingEntity) - return clientSidePostId + metisDao.updateReactions(answerPostClientSidePostId, answerPostReactions) + } } private suspend fun MetisDao.insertOrUpdateUser(user: MetisUserEntity) { @@ -475,12 +615,14 @@ internal class MetisStorageServiceImpl( override fun getLatestKnownPost( serverId: String, - metisContext: MetisContext + metisContext: MetisContext, + allowClientSidePost: Boolean ): Flow { return databaseProvider.metisDao.queryLatestKnownPost( serverId = serverId, courseId = metisContext.courseId, - conversationId = metisContext.conversationId + conversationId = metisContext.conversationId, + allowClientSidePost = allowClientSidePost ) } 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 19e91bcf1..a9df8b73f 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 @@ -27,6 +27,7 @@ import de.tum.informatics.www1.artemis.native_app.core.model.exercise.UnknownExe 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 +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService 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.asMetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisModificationService @@ -44,7 +45,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.M 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 -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.DisplayPriority import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost @@ -53,12 +53,14 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.d import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.hasModerationRights +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.pojo.AnswerPostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.ConversationService import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.getConversation import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.subscribeToConversationUpdates import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.MetisViewModel +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow @@ -73,12 +75,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlinx.datetime.Clock import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -95,6 +95,7 @@ internal open class ConversationViewModel( private val conversationService: ConversationService, private val replyTextStorageService: ReplyTextStorageService, private val courseService: CourseService, + private val createPostService: CreatePostService, accountDataService: AccountDataService, metisService: MetisService, private val coroutineContext: CoroutineContext = EmptyCoroutineContext @@ -177,7 +178,6 @@ internal open class ConversationViewModel( else -> minOf(chatListStatus, threadStatus) } } - .onEach { println("NEW STATUS $it") } .stateIn(viewModelScope, SharingStarted.Eagerly, DataStatus.Outdated) val conversation: StateFlow> = flatMapLatest( @@ -273,7 +273,11 @@ internal open class ConversationViewModel( ): Deferred { return viewModelScope.async(coroutineContext) { if (create) { - createReactionImpl(emojiId, post.asAffectedPost) + createReactionImpl( + emojiId, + post.getAsAffectedPost() + ?: return@async MetisModificationFailure.DELETE_REACTION + ) } else { val clientId = clientId.value.orNull() ?: return@async MetisModificationFailure.DELETE_REACTION @@ -318,46 +322,11 @@ internal open class ConversationViewModel( return if (success) null else MetisModificationFailure.DELETE_REACTION } - protected suspend fun createStandalonePostImpl(post: StandalonePost): MetisModificationFailure? { - val metisContext = metisContext - val response = metisModificationService.createPost( - context = metisContext, - post = post, - serverUrl = serverConfigurationService.serverUrl.first(), - authToken = accountService.authToken.first() - ) - - return when (response) { - is NetworkResponse.Failure -> MetisModificationFailure.CREATE_POST - is NetworkResponse.Response -> { - metisStorageService.insertLiveCreatedPost( - serverConfigurationService.host.first(), - metisContext, - response.data - ) - - null - } - } - } - - protected suspend fun createAnswerPostImpl(post: AnswerPost): MetisModificationFailure? { - val response = metisModificationService.createAnswerPost( - context = metisContext, - post = post, - serverUrl = serverConfigurationService.serverUrl.first(), - authToken = accountService.authToken.first() - ) - - return response.bind { null } - .or(MetisModificationFailure.CREATE_POST) - } - fun deletePost(post: IBasePost): Deferred { return viewModelScope.async(coroutineContext) { metisModificationService.deletePost( metisContext, - post.asAffectedPost, + post.getAsAffectedPost() ?: return@async MetisModificationFailure.DELETE_POST, serverConfigurationService.serverUrl.first(), accountService.authToken.first() ) @@ -578,13 +547,16 @@ internal open class ConversationViewModel( newMessageText.value = text } - private val IBasePost.asAffectedPost: MetisModificationService.AffectedPost - get() = when (this) { - is AnswerPost -> MetisModificationService.AffectedPost.Answer(serverPostId) - is IAnswerPost -> MetisModificationService.AffectedPost.Answer(serverPostId) - is StandalonePost -> MetisModificationService.AffectedPost.Standalone(serverPostId) - is IStandalonePost -> MetisModificationService.AffectedPost.Standalone(serverPostId) + private fun IBasePost.getAsAffectedPost(): MetisModificationService.AffectedPost? { + val sPostId = serverPostId ?: return null + + return when (this) { + is AnswerPost -> MetisModificationService.AffectedPost.Answer(sPostId) + is IAnswerPost -> MetisModificationService.AffectedPost.Answer(sPostId) + is StandalonePost -> MetisModificationService.AffectedPost.Standalone(sPostId) + is IStandalonePost -> MetisModificationService.AffectedPost.Standalone(sPostId) } + } private suspend fun loadConversation(): Conversation? { return conversationService.getConversation( @@ -633,51 +605,57 @@ internal open class ConversationViewModel( } fun createPost(): Deferred { - return viewModelScope.async(coroutineContext) { - val postText = newMessageText.value.text + createPostService.createPost(courseId, conversationId, newMessageText.value.text) - val conversation = - loadConversation() ?: return@async MetisModificationFailure.CREATE_POST - - val post = StandalonePost( - id = null, - title = null, - tags = null, - content = postText, - conversation = conversation, - creationDate = Clock.System.now(), - displayPriority = DisplayPriority.NONE - ) + return CompletableDeferred(value = null) + } + + fun retryCreatePost(standalonePostId: StandalonePostId) { + viewModelScope.launch(coroutineContext) { + val clientSidePostId = when (standalonePostId) { + is StandalonePostId.ClientSideId -> standalonePostId.clientSideId + is StandalonePostId.ServerSideId -> metisStorageService.getClientSidePostId( + serverConfigurationService.host.first(), + standalonePostId.serverSidePostId, + postingType = BasePostingEntity.PostingType.STANDALONE + ) + } ?: return@launch + + val post = metisStorageService.getStandalonePost(clientSidePostId).first() ?: return@launch - createStandalonePostImpl(post) + createPostService.retryCreatePost( + courseId = courseId, + conversationId = conversationId, + clientSidePostId = clientSidePostId, + content = post.content + ) } } fun createReply(): Deferred { return viewModelScope.async(coroutineContext) { - val postId = postId.value - if (postId == null) return@async MetisModificationFailure.CREATE_POST + val serverSideParentPostId = + getPostId() ?: return@async MetisModificationFailure.CREATE_POST + + createPostService.createAnswerPost( + courseId, + conversationId, + serverSideParentPostId, + newMessageText.value.text + ) - val conversation = - loadConversation() ?: return@async MetisModificationFailure.CREATE_POST - - val replyPost = AnswerPost( - creationDate = Clock.System.now(), - content = newMessageText.first().text, - post = StandalonePost( - id = when (postId) { - is StandalonePostId.ClientSideId -> metisStorageService.getServerSidePostId( - serverConfigurationService.host.first(), - postId.clientSideId - ) + null + } + } - is StandalonePostId.ServerSideId -> postId.serverSidePostId - }, - conversation = conversation - ) + fun retryCreateReply(clientPostId: String, content: String) { + viewModelScope.launch(coroutineContext) { + createPostService.retryCreatePost( + courseId = courseId, + conversationId = conversationId, + clientSidePostId = clientPostId, + content = content ) - - createAnswerPostImpl(replyPost) } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ConversationChatListUseCase.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ConversationChatListUseCase.kt index efa5aa0a9..9f34cd18f 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ConversationChatListUseCase.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ConversationChatListUseCase.kt @@ -8,8 +8,6 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.insertSeparators import androidx.paging.map -import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest -import de.tum.informatics.www1.artemis.native_app.core.common.transformLatest import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService @@ -36,13 +34,11 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -260,18 +256,10 @@ class ConversationChatListUseCase( val bottomPost: StateFlow = serverConfigurationService .host .flatMapLatest { host -> - metisStorageService.getLatestKnownPost(host, metisContext) + metisStorageService.getLatestKnownPost(host, metisContext, true) } .stateIn(viewModelScope, SharingStarted.Eagerly, null) - init { - viewModelScope.launch(coroutineContext) { - onRequestSoftReload.collect { - - } - } - } - fun updateQuery(new: String) { _query.value = new.ifEmpty { null } } @@ -306,7 +294,8 @@ class ConversationChatListUseCase( val authToken = accountService.authToken.first() Log.d(TAG, "Loading newly created posts") - val latestKnownPost = metisStorageService.getLatestKnownPost(host, metisContext).first() + val latestKnownPost = + metisStorageService.getLatestKnownPost(host, metisContext, false).first() return if (latestKnownPost == null) { Log.d(TAG, "There is no latest known post -> loading all posts.") @@ -367,11 +356,11 @@ class ConversationChatListUseCase( ) // We have loaded all missing posts. Insert into db. - metisStorageService.insertOrUpdatePosts( + metisStorageService.insertOrUpdatePostsAndRemoveDeletedPosts( host = host, metisContext = metisContext, posts = loadedPosts, - clearPreviousPosts = reachedEndOfPagination + removeAllOlderPosts = true ) return NetworkResponse.Response(Unit) @@ -476,12 +465,20 @@ class ConversationChatListUseCase( is NetworkResponse.Response -> networkResponse.data } - metisStorageService.insertOrUpdatePosts( - host = host, - metisContext = metisContext, - posts = loadedPosts, - clearPreviousPosts = clearPreviousPosts - ) + if (clearPreviousPosts) { + metisStorageService.insertOrUpdatePostsAndRemoveDeletedPosts( + host = host, + metisContext = metisContext, + posts = loadedPosts, + removeAllOlderPosts = true + ) + } else { + metisStorageService.insertOrUpdatePosts( + host = host, + metisContext = metisContext, + posts = loadedPosts + ) + } return NetworkResponse.Response(loadedPosts.size) } 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 05453946a..64da38bce 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -27,6 +28,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems +import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ConversationViewModel @@ -42,6 +44,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.S import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.PagingStateError +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ReportVisibleMetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisiblePostList import kotlinx.coroutines.Deferred @@ -54,7 +57,7 @@ import java.util.Date internal const val TEST_TAG_METIS_POST_LIST = "TEST_TAG_METIS_POST_LIST" -internal fun testTagForPost(postId: Long) = "post$postId" +internal fun testTagForPost(postId: StandalonePostId?) = "post$postId" @Composable internal fun MetisChatList( @@ -64,7 +67,8 @@ internal fun MetisChatList( listContentPadding: PaddingValues, state: LazyListState = rememberLazyListState(), isReplyEnabled: Boolean = true, - onClickViewPost: (StandalonePostId) -> Unit + onClickViewPost: (StandalonePostId) -> Unit, + title: String? = "Replying..." ) { ReportVisibleMetisContext(remember(viewModel.metisContext) { VisiblePostList(viewModel.metisContext) }) @@ -75,6 +79,14 @@ internal fun MetisChatList( val bottomItem: PostPojo? by viewModel.chatListUseCase.bottomPost.collectAsState() + val conversationDataState by viewModel.latestUpdatedConversation.collectAsState() + + val updatedTitle by remember(conversationDataState) { + derivedStateOf { + conversationDataState.bind { it.humanReadableName }.orElse("Conversation") + } + } + MetisChatList( modifier = modifier, initialReplyTextProvider = viewModel, @@ -91,7 +103,9 @@ internal fun MetisChatList( onEditPost = viewModel::editPost, onDeletePost = viewModel::deletePost, onRequestReactWithEmoji = viewModel::createOrDeleteReaction, - onClickViewPost = onClickViewPost + onClickViewPost = onClickViewPost, + onRequestRetrySend = viewModel::retryCreatePost, + title = updatedTitle ) } @@ -112,7 +126,9 @@ fun MetisChatList( onEditPost: (IStandalonePost, String) -> Deferred, onDeletePost: (IStandalonePost) -> Deferred, onRequestReactWithEmoji: (IStandalonePost, emojiId: String, create: Boolean) -> Deferred, - onClickViewPost: (StandalonePostId) -> Unit + onClickViewPost: (StandalonePostId) -> Unit, + onRequestRetrySend: (StandalonePostId) -> Unit, + title: String ) { MetisReplyHandler( initialReplyTextProvider = initialReplyTextProvider, @@ -168,7 +184,8 @@ fun MetisChatList( hasModerationRights = hasModerationRights, onRequestEdit = onEditPostDelegate, onRequestDelete = onDeletePostDelegate, - onRequestReactWithEmoji = onRequestReactWithEmojiDelegate + onRequestReactWithEmoji = onRequestReactWithEmojiDelegate, + onRequestRetrySend = onRequestRetrySend ) } } @@ -178,7 +195,8 @@ fun MetisChatList( ReplyTextField( modifier = Modifier.fillMaxWidth(), replyMode = replyMode, - updateFailureState = updateFailureStateDelegate + updateFailureState = updateFailureStateDelegate, + title = title, ) } } @@ -196,10 +214,9 @@ private fun ChatList( onClickViewPost: (StandalonePostId) -> Unit, onRequestEdit: (IStandalonePost) -> Unit, onRequestDelete: (IStandalonePost) -> Unit, - onRequestReactWithEmoji: (IStandalonePost, emojiId: String, create: Boolean) -> Unit + onRequestReactWithEmoji: (IStandalonePost, emojiId: String, create: Boolean) -> Unit, + onRequestRetrySend: (StandalonePostId) -> Unit ) { - println("POSTS: ${posts}") - LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -235,6 +252,11 @@ private fun ChatList( }, onReplyInThread = { onClickViewPost(post?.standalonePostId ?: return@rememberPostActions) + }, + onRequestRetrySend = { + onRequestRetrySend( + post?.standalonePostId ?: return@rememberPostActions + ) } ) @@ -243,7 +265,7 @@ private fun ChatList( .fillMaxWidth() .let { if (post != null) { - it.testTag(testTagForPost(post.serverPostId)) + it.testTag(testTagForPost(post.standalonePostId)) } else it }, post = post, @@ -265,8 +287,10 @@ private fun ChatList( } ), onClick = { - if (post != null) { - onClickViewPost(post.standalonePostId) + val standalonePostId = post?.standalonePostId + + if (post?.serverPostId != null && standalonePostId != null) { + onClickViewPost(standalonePostId) } } ) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisRemoteMediator.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisRemoteMediator.kt index fc6c541d2..63b2607e5 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisRemoteMediator.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisRemoteMediator.kt @@ -14,7 +14,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.M import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisSortingStrategy 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.db.pojo.PostPojo -import java.lang.RuntimeException @OptIn(ExperimentalPagingApi::class) internal class MetisRemoteMediator( @@ -69,8 +68,7 @@ internal class MetisRemoteMediator( metisStorageService.insertOrUpdatePosts( host = host, metisContext = context, - posts = loadedPosts, - clearPreviousPosts = false + posts = loadedPosts ) return MediatorResult.Success( diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/PostsDataState.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/PostsDataState.kt index 52222465b..93d03b6dc 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/PostsDataState.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/PostsDataState.kt @@ -79,7 +79,7 @@ fun LazyPagingItems.asPostsDataState(): PostsDataState = when { posts = this, appendState = when (loadState.append) { LoadState.Loading -> PostsDataState.Loading - is LoadState.Error -> PostsDataState.Error(::readln) + is LoadState.Error -> PostsDataState.Error(::retry) is LoadState.NotLoading -> PostsDataState.NotLoading } ) 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 2286673df..702c30a19 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 @@ -9,9 +9,10 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.d data class PostActions( val requestEditPost: (() -> Unit)? = null, val requestDeletePost: (() -> Unit)? = null, - val onClickReaction: (emojiId: String, create: Boolean) -> Unit = { _, _ -> }, + val onClickReaction: ((emojiId: String, create: Boolean) -> Unit)? = null, val onCopyText: () -> Unit = {}, - val onReplyInThread: (() -> Unit)? = null + val onReplyInThread: (() -> Unit)? = null, + val onRequestRetrySend: () -> Unit = {} ) { val canPerformAnyAction: Boolean get() = requestDeletePost != null || requestEditPost != null } @@ -24,7 +25,8 @@ fun rememberPostActions( onRequestEdit: () -> Unit, onRequestDelete: () -> Unit, onClickReaction: (emojiId: String, create: Boolean) -> Unit, - onReplyInThread: (() -> Unit)? + onReplyInThread: (() -> Unit)?, + onRequestRetrySend: () -> Unit ): PostActions { val clipboardManager = LocalClipboardManager.current @@ -36,19 +38,22 @@ fun rememberPostActions( onRequestDelete, onClickReaction, onReplyInThread, + onRequestRetrySend, clipboardManager ) { if (post != null) { + val doesPostExistOnServer = post.serverPostId != null val hasEditPostRights = hasModerationRights || post.authorId == clientId PostActions( - requestEditPost = if (hasEditPostRights) onRequestEdit else null, + requestEditPost = if (doesPostExistOnServer && hasEditPostRights) onRequestEdit else null, requestDeletePost = if (hasEditPostRights) onRequestDelete else null, - onClickReaction = onClickReaction, + onClickReaction = if (doesPostExistOnServer) onClickReaction else null, onCopyText = { clipboardManager.setText(AnnotatedString(post.content.orEmpty())) }, - onReplyInThread = onReplyInThread + onReplyInThread = if (doesPostExistOnServer) onReplyInThread else null, + onRequestRetrySend = onRequestRetrySend ) } else { PostActions() 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 13feb3a31..2e09e64dd 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt @@ -16,10 +16,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddReaction import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Reply import androidx.compose.material3.Divider import androidx.compose.material3.Icon @@ -80,20 +80,22 @@ internal fun PostContextBottomSheet( .fillMaxWidth() .padding(horizontal = Spacings.ScreenHorizontalSpacing) ) { - EmojiReactionBar( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - presentReactions = post.reactions.orEmpty(), - clientId = clientId, - onReactWithEmoji = { - onDismissRequest() - postActions.onClickReaction(it, true) - }, - onRequestViewMoreEmojis = { - displayAllEmojis = true - } - ) + postActions.onClickReaction?.let { onClickReaction -> + EmojiReactionBar( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + presentReactions = post.reactions.orEmpty(), + clientId = clientId, + onReactWithEmoji = { emojiId -> + onDismissRequest() + onClickReaction(emojiId, true) + }, + onRequestViewMoreEmojis = { + displayAllEmojis = true + } + ) + } if (postActions.canPerformAnyAction) { Divider() @@ -147,13 +149,15 @@ internal fun PostContextBottomSheet( } } } else { - EmojiDialog( - onDismissRequest = onDismissRequest, - onSelectEmoji = { - onDismissRequest() - postActions.onClickReaction(it, true) - } - ) + postActions.onClickReaction?.let { onClickReaction -> + EmojiDialog( + onDismissRequest = onDismissRequest, + onSelectEmoji = { emojiId -> + onDismissRequest() + onClickReaction(emojiId, true) + } + ) + } } } @@ -196,7 +200,7 @@ private fun EmojiReactionBar( disabled = false ) { Icon( - imageVector = Icons.Default.MoreHoriz, + imageVector = Icons.Default.AddReaction, contentDescription = null ) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt index 8a9b3398a..36f0823f9 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,20 +46,23 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.date.getRelativeTime import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.MarkdownText import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.getUnicodeForEmojiId import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IReaction import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.AnswerPostPojo -import io.github.fornewid.placeholder.foundation.placeholder import io.github.fornewid.placeholder.material3.placeholder import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import org.koin.compose.koinInject private val EditedGray: Color @Composable get() = Color.Gray +private val UnsentMessageTextColor: Color + @Composable get() = Color.Gray + sealed class PostItemViewType { data class ChatListItem( @@ -82,9 +86,10 @@ internal fun PostItem( postItemViewType: PostItemViewType, clientId: Long, displayHeader: Boolean, - onClickOnReaction: (emojiId: String, create: Boolean) -> Unit, + onClickOnReaction: ((emojiId: String, create: Boolean) -> Unit)?, onClick: () -> Unit, - onLongClick: () -> Unit + onLongClick: () -> Unit, + onRequestRetrySend: () -> Unit ) { val isPlaceholder = post == null val isExpanded = when (postItemViewType) { @@ -92,17 +97,37 @@ internal fun PostItem( else -> false } + // Retrieve post status + val clientPostId = post?.clientPostId + val postStatus = when { + post == null || post.serverPostId != null || clientPostId == null -> CreatePostService.Status.FINISHED + else -> { + val createPostService: CreatePostService = koinInject() + createPostService.observeCreatePostWorkStatus(clientPostId) + .collectAsState(initial = CreatePostService.Status.PENDING) + .value + } + } + Column( modifier = modifier - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) + .let { + if (postStatus == CreatePostService.Status.FAILED) { + it + .background(color = MaterialTheme.colorScheme.errorContainer) + .clickable(onClick = onRequestRetrySend) + } else Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + } .padding(PaddingValues(horizontal = Spacings.ScreenHorizontalSpacing)), verticalArrangement = Arrangement.spacedBy(8.dp) ) { PostHeadline( modifier = Modifier.fillMaxWidth(), + postStatus = postStatus, authorRole = post?.authorRole, authorName = post?.authorName, creationDate = post?.creationDate, @@ -120,11 +145,14 @@ internal fun PostItem( PlaceholderContent } else post?.content.orEmpty() }, - modifier = Modifier.fillMaxWidth().placeholder(visible = isPlaceholder), + modifier = Modifier + .fillMaxWidth() + .placeholder(visible = isPlaceholder), maxLines = 5, style = MaterialTheme.typography.bodyMedium, onClick = onClick, - onLongClick = onLongClick + onLongClick = onLongClick, + color = if (post?.serverPostId == null) UnsentMessageTextColor else Color.Unspecified ) if (post?.updatedDate != null) { @@ -158,6 +186,7 @@ private fun PostHeadline( authorRole: UserRole?, authorName: String?, creationDate: Instant?, + postStatus: CreatePostService.Status, expanded: Boolean = false, displayHeader: Boolean = true, content: @Composable () -> Unit @@ -185,13 +214,23 @@ private fun PostHeadline( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - HeadlineAuthorIcon(authorRole, displayIcon = displayHeader) + val doDisplayHeader = displayHeader || postStatus == CreatePostService.Status.FAILED + + HeadlineAuthorIcon(authorRole, displayIcon = doDisplayHeader) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - if (displayHeader) { + if (postStatus == CreatePostService.Status.FAILED) { + Text( + text = stringResource(id = R.string.post_sending_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + } + + if (doDisplayHeader) { HeadlineAuthorInfo( modifier = Modifier.fillMaxWidth(), authorName = authorName, @@ -291,7 +330,7 @@ private fun StandalonePostFooter( clientId: Long, reactions: List, postItemViewType: PostItemViewType, - onClickReaction: (emojiId: String, create: Boolean) -> Unit + onClickReaction: ((emojiId: String, create: Boolean) -> Unit)? ) { val reactionCount: Map = remember(reactions, clientId) { reactions.groupBy { it.emojiId }.mapValues { groupedReactions -> @@ -315,7 +354,7 @@ private fun StandalonePostFooter( emojiId = emoji, reactionCount = reactionData.reactionCount, onClick = { - onClickReaction(emoji, !reactionData.hasClientReacted) + onClickReaction?.invoke(emoji, !reactionData.hasClientReacted) } ) } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt index 140b73c91..03811a46f 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostWithBottomSheet.kt @@ -33,7 +33,8 @@ internal fun PostWithBottomSheet( onClick = onClick, onLongClick = { displayBottomSheet = true - } + }, + onRequestRetrySend = postActions.onRequestRetrySend ) if (displayBottomSheet && post != null) { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt index 8289458f5..83cc9c20a 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt @@ -2,36 +2,15 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -40,16 +19,18 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.getTextBeforeSelection import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion -import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.ReplyState import kotlinx.coroutines.CompletableDeferred @@ -68,7 +49,8 @@ private const val DisabledContentAlpha = 0.75f internal fun ReplyTextField( modifier: Modifier, replyMode: ReplyMode, - updateFailureState: (MetisModificationFailure?) -> Unit + updateFailureState: (MetisModificationFailure?) -> Unit, + title: String ) { val replyState: ReplyState = rememberReplyState(replyMode, updateFailureState) @@ -96,7 +78,8 @@ internal fun ReplyTextField( .fillMaxWidth() .testTag(TEST_TAG_CAN_CREATE_REPLY), replyMode = replyMode, - onReply = { targetReplyState.onCreateReply() } + onReply = { targetReplyState.onCreateReply() }, + title = "Message $title" ) } @@ -115,7 +98,8 @@ internal fun ReplyTextField( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), - onCancel = targetReplyState.onCancelSendReply + onCancel = targetReplyState.onCancelSendReply, + title = title ) } } @@ -125,7 +109,7 @@ internal fun ReplyTextField( } @Composable -private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit) { +private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit, title: String?) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically @@ -136,7 +120,7 @@ private fun SendingReplyUi(modifier: Modifier, onCancel: () -> Unit) { ) Text( - text = stringResource(id = R.string.create_answer_sending_reply), + text = title.toString(), textAlign = TextAlign.Center, modifier = Modifier.weight(1f) ) @@ -152,7 +136,8 @@ private fun CreateReplyUi( modifier: Modifier, replyMode: ReplyMode, focusRequester: FocusRequester = remember { FocusRequester() }, - onReply: () -> Unit + onReply: () -> Unit, + title: String? ) { var prevReplyContent by remember { mutableStateOf("") } var displayTextField: Boolean by remember { mutableStateOf(false) } @@ -173,12 +158,6 @@ private fun CreateReplyUi( requestDismissAutoCompletePopup = false } - // Quite hacky! - /* - We do not want to dismiss the popup when the user pressed on their keyboard, so we wait - 100 ms before we actually dismiss the popup. If in the meantime, the user entered a key again - we keep showing the popup. - */ LaunchedEffect(requestDismissAutoCompletePopup) { if (requestDismissAutoCompletePopup) { delay(100) @@ -186,95 +165,280 @@ private fun CreateReplyUi( } } - Box(modifier = modifier) { - if (displayTextField || currentTextFieldValue.text.isNotBlank()) { - val tagChars = LocalReplyAutoCompleteHintProvider.current.legalTagChars - val autoCompleteHints = manageAutoCompleteHints(currentTextFieldValue) - - var textFieldWidth by remember { mutableIntStateOf(0) } - var popupMaxHeight by remember { mutableIntStateOf(0) } - - if (autoCompleteHints.orEmpty().flatMap { it.items } - .isNotEmpty() && mayShowAutoCompletePopup) { - ReplyAutoCompletePopup( - autoCompleteCategories = autoCompleteHints.orEmpty(), - targetWidth = with(LocalDensity.current) { textFieldWidth.toDp() }, - maxHeight = with(LocalDensity.current) { popupMaxHeight.toDp() }, - popupPositionProvider = ReplyAutoCompletePopupPositionProvider, - performAutoComplete = { replacement -> - replyMode.onUpdate( - performAutoComplete( - currentTextFieldValue, - tagChars, - replacement + Column(modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth()) { + if (displayTextField || currentTextFieldValue.text.isNotBlank()) { + val tagChars = LocalReplyAutoCompleteHintProvider.current.legalTagChars + val autoCompleteHints = manageAutoCompleteHints(currentTextFieldValue) + + var textFieldWidth by remember { mutableIntStateOf(0) } + var popupMaxHeight by remember { mutableStateOf(0) } + + if (autoCompleteHints.orEmpty().flatMap { it.items } + .isNotEmpty() && mayShowAutoCompletePopup) { + ReplyAutoCompletePopup( + autoCompleteCategories = autoCompleteHints.orEmpty(), + targetWidth = with(LocalDensity.current) { textFieldWidth.toDp() }, + maxHeight = with(LocalDensity.current) { popupMaxHeight.toDp() }, + popupPositionProvider = ReplyAutoCompletePopupPositionProvider, + performAutoComplete = { replacement -> + replyMode.onUpdate( + performAutoComplete( + currentTextFieldValue, + tagChars, + replacement + ) ) - ) + }, + onDismissRequest = { + requestDismissAutoCompletePopup = true + } + ) + } + + MarkdownTextField( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { textFieldWidth = it.width } + .padding(vertical = 8.dp, horizontal = 8.dp) + .onGloballyPositioned { coordinates -> + val textFieldWindowTopLeft = coordinates.localToWindow(Offset.Zero) + popupMaxHeight = textFieldWindowTopLeft.y.toInt() + } + .testTag(TEST_TAG_REPLY_TEXT_FIELD), + textFieldValue = currentTextFieldValue, + onTextChanged = replyMode::onUpdate, + focusRequester = focusRequester, + onFocusLost = { + if (displayTextField && currentTextFieldValue.text.isEmpty()) { + displayTextField = false + } + }, + sendButton = { + IconButton( + modifier = Modifier.testTag(TEST_TAG_REPLY_SEND_BUTTON), + onClick = onReply, + enabled = currentTextFieldValue.text.isNotBlank() + ) { + Icon( + imageVector = when (replyMode) { + is ReplyMode.EditMessage -> Icons.Default.Edit + is ReplyMode.NewMessage -> Icons.Default.Send + }, + contentDescription = null + ) + } }, - onDismissRequest = { - requestDismissAutoCompletePopup = true + topRightButton = { + if (replyMode is ReplyMode.EditMessage) { + IconButton(onClick = replyMode.onCancelEditMessage) { + Icon(imageVector = Icons.Default.Cancel, contentDescription = null) + } + } } ) - } - MarkdownTextField( - modifier = Modifier - .fillMaxWidth() - .onSizeChanged { textFieldWidth = it.width } - .padding(vertical = 8.dp, horizontal = 8.dp) - .onGloballyPositioned { coordinates -> - val textFieldWindowTopLeft = coordinates.localToWindow(Offset.Zero) - popupMaxHeight = textFieldWindowTopLeft.y.toInt() - } - .testTag(TEST_TAG_REPLY_TEXT_FIELD), - textFieldValue = currentTextFieldValue, - onTextChanged = replyMode::onUpdate, - focusRequester = focusRequester, - onFocusLost = { - if (displayTextField && currentTextFieldValue.text.isEmpty()) { - displayTextField = false - } - }, - sendButton = { - IconButton( - modifier = Modifier.testTag(TEST_TAG_REPLY_SEND_BUTTON), - onClick = onReply, - enabled = currentTextFieldValue.text.isNotBlank() - ) { - Icon( - imageVector = when (replyMode) { - is ReplyMode.EditMessage -> Icons.Default.Edit - is ReplyMode.NewMessage -> Icons.Default.Send - }, - contentDescription = null - ) - } - }, - topRightButton = { - if (replyMode is ReplyMode.EditMessage) { - IconButton(onClick = replyMode.onCancelEditMessage) { - Icon(imageVector = Icons.Default.Cancel, contentDescription = null) - } + LaunchedEffect(requestFocus) { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false } } + } else { + UnfocusedPreviewReplyTextField({ + displayTextField = true + requestFocus = true + }, title = title) + } + } + + if (displayTextField || currentTextFieldValue.text.isNotBlank()) { + FormattingOptions( + currentTextFieldValue = currentTextFieldValue, + onTextChanged = replyMode::onUpdate ) + } + } +} - LaunchedEffect(requestFocus) { - if (requestFocus) { - focusRequester.requestFocus() - requestFocus = false - } - } +enum class MarkdownStyle(val startTag: String, val endTag: String) { + Bold("**", "**"), + Italic("*", "*"), + Underline("", ""), + InlineCode("`", "`"), + CodeBlock("```", "```"), + Blockquote("> ", "") +} + +@Composable +private fun FormattingOptions( + currentTextFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start + ) { + // Bold Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Bold, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "B", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) + ) + } + + // Italic Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Italic, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "I", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + ) + } + + // Underline Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Underline, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "U", + style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.Underline) + ) + } + + // Inline Code Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.InlineCode, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + + // Code Block Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.CodeBlock, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "{ }", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + } + + // Blockquote Button + IconButton(onClick = { + applyMarkdownStyle( + style = MarkdownStyle.Blockquote, + currentTextFieldValue = currentTextFieldValue, + onTextChanged = onTextChanged + ) + }) { + Text( + text = "\"", + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + ) + } + } +} + +private fun applyMarkdownStyle( + style: MarkdownStyle, + currentTextFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit +) { + val selection = currentTextFieldValue.selection + val text = currentTextFieldValue.text + + val startTag = style.startTag + val endTag = style.endTag + + if (selection.collapsed) { + // No text selected + if (style == MarkdownStyle.CodeBlock) { + // Insert code block with newlines + val newText = text.substring(0, selection.start) + + "$startTag\n\n$endTag" + + text.substring(selection.end) + val newCursorPosition = selection.start + startTag.length + 1 + onTextChanged( + TextFieldValue( + text = newText, + selection = TextRange(newCursorPosition, newCursorPosition) + ) + ) } else { - UnfocusedPreviewReplyTextField { - displayTextField = true - requestFocus = true - } + // Other styles + val newText = text.substring(0, selection.start) + startTag + endTag + text.substring(selection.end) + val newCursorPosition = selection.start + startTag.length + onTextChanged( + TextFieldValue( + text = newText, + selection = TextRange(newCursorPosition, newCursorPosition) + ) + ) + } + } else { + val selectedText = text.substring(selection.start, selection.end) + if (style == MarkdownStyle.CodeBlock) { + val newText = text.substring(0, selection.start) + + "$startTag\n$selectedText\n$endTag" + + text.substring(selection.end) + val newSelection = TextRange( + selection.start + startTag.length + 1, + selection.end + startTag.length + 1 + ) + onTextChanged( + TextFieldValue( + text = newText, + selection = newSelection + ) + ) + } else { + val newText = text.substring(0, selection.start) + + startTag + + selectedText + + endTag + + text.substring(selection.end) + val newSelection = TextRange(selection.end + startTag.length + endTag.length) + onTextChanged( + TextFieldValue( + text = newText, + selection = newSelection + ) + ) } } } + @Composable -private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit) { +private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit, title: String?) { Row( modifier = Modifier .fillMaxWidth() @@ -283,7 +447,7 @@ private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(id = R.string.create_answer_click_to_write), + text = title.toString(), modifier = Modifier .padding(vertical = 8.dp) .weight(1f) @@ -331,12 +495,14 @@ private fun rememberReplyState( updateFailureState: (MetisModificationFailure?) -> Unit ): ReplyState { var isCreatingReplyJob: Deferred? by remember { mutableStateOf(null) } - var hasSentReply by remember { mutableStateOf(false) } + var displaySendSuccess by remember { mutableStateOf(false) } AwaitDeferredCompletion(job = isCreatingReplyJob) { failure -> if (failure == null) { replyMode.onUpdate(TextFieldValue(text = "")) - hasSentReply = true + + // Only show for edit. + displaySendSuccess = replyMode is ReplyMode.EditMessage } else { updateFailureState(failure) } @@ -344,22 +510,22 @@ private fun rememberReplyState( isCreatingReplyJob = null } - LaunchedEffect(key1 = hasSentReply) { - if (hasSentReply) { + LaunchedEffect(key1 = displaySendSuccess) { + if (displaySendSuccess) { delay(1.seconds) - hasSentReply = false + displaySendSuccess = false } } - return remember(isCreatingReplyJob, hasSentReply, replyMode) { + return remember(isCreatingReplyJob, displaySendSuccess, replyMode) { when { isCreatingReplyJob != null -> ReplyState.IsSendingReply { isCreatingReplyJob?.cancel() isCreatingReplyJob = null } - hasSentReply -> ReplyState.HasSentReply + displaySendSuccess -> ReplyState.HasSentReply else -> ReplyState.CanCreate { isCreatingReplyJob = when (replyMode) { is ReplyMode.EditMessage -> replyMode.onEditMessage() @@ -450,7 +616,8 @@ private fun ReplyTextFieldPreview() { ) { CompletableDeferred() }, - updateFailureState = {} + updateFailureState = {}, + title = "Replying.." ) } } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/ConversationThreadUseCase.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/ConversationThreadUseCase.kt index 42f678f36..18237fe26 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/ConversationThreadUseCase.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/ConversationThreadUseCase.kt @@ -112,8 +112,7 @@ internal class ConversationThreadUseCase( metisStorageService.insertOrUpdatePosts( host = host, metisContext = metisContext, - posts = listOf(post), - clearPreviousPosts = false + posts = listOf(post) ) val clientSidePostId = metisStorageService.getClientSidePostId( 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 ccdf75e41..1b84448f2 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -47,10 +48,11 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visibleme import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.AnswerPostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import kotlinx.coroutines.CompletableDeferred internal const val TEST_TAG_THREAD_LIST = "TEST_TAG_THREAD_LIST" -internal fun testTagForAnswerPost(answerPostId: Long) = "answerPost$answerPostId" +internal fun testTagForAnswerPost(answerPostId: String) = "answerPost$answerPostId" /** * Displays a single post with its replies. @@ -83,6 +85,12 @@ internal fun MetisThreadUi( val listState = rememberLazyListState() + val title by remember(conversationDataState) { + derivedStateOf { + conversationDataState.bind { it.humanReadableName }.orElse("Conversation") + } + } + ProvideEmojis { MetisReplyHandler( initialReplyTextProvider = viewModel, @@ -136,7 +144,8 @@ internal fun MetisThreadUi( onRequestReactWithEmoji = onRequestReactWithEmojiDelegate, onRequestEdit = onEditPostDelegate, onRequestDelete = onDeletePostDelegate, - state = listState + state = listState, + onRequestRetrySend = viewModel::retryCreateReply ) } } @@ -147,7 +156,8 @@ internal fun MetisThreadUi( .fillMaxWidth() .heightIn(max = this@BoxWithConstraints.maxHeight * 0.6f), replyMode = replyMode, - updateFailureState = updateFailureStateDelegate + updateFailureState = updateFailureStateDelegate, + title = title ) } } @@ -165,7 +175,8 @@ private fun PostAndRepliesList( clientId: Long, onRequestEdit: (IBasePost) -> Unit, onRequestDelete: (IBasePost) -> Unit, - onRequestReactWithEmoji: (IBasePost, emojiId: String, create: Boolean) -> Unit + onRequestReactWithEmoji: (IBasePost, emojiId: String, create: Boolean) -> Unit, + onRequestRetrySend: (clientSidePostId: String, content: String) -> Unit ) { val rememberPostActions: @Composable (IBasePost) -> PostActions = { affectedPost: IBasePost -> rememberPostActions( @@ -181,7 +192,13 @@ private fun PostAndRepliesList( create ) }, - onReplyInThread = null + onReplyInThread = null, + onRequestRetrySend = { + onRequestRetrySend( + affectedPost.clientPostId ?: return@rememberPostActions, + affectedPost.content.orEmpty() + ) + } ) } @@ -223,7 +240,7 @@ private fun PostAndRepliesList( PostWithBottomSheet( modifier = Modifier .fillMaxWidth() - .testTag(testTagForAnswerPost(answerPost.serverPostId)), + .testTag(testTagForAnswerPost(answerPost.clientPostId)), post = answerPost, postActions = postActions, postItemViewType = PostItemViewType.ThreadAnswerItem, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/BaseCreatePostWorker.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/BaseCreatePostWorker.kt new file mode 100644 index 000000000..8b9514204 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/BaseCreatePostWorker.kt @@ -0,0 +1,86 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work + +import android.content.Context +import androidx.compose.runtime.clearCompositionErrors +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters + +abstract class BaseCreatePostWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + + companion object { + const val KEY_POST_TYPE = "type" + const val KEY_COURSE_ID = "course_id" + const val KEY_CONVERSATION_ID = "conversation_id" + const val KEY_CONTENT = "content" + const val KEY_PARENT_POST_ID = "parent_post_id" + + /** + * Id of the post created on client side + */ + const val KEY_CLIENT_SIDE_POST_ID = "client_side_post_id" + + fun createWorkInput( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + postType: PostType, + parentPostId: Long? + ): Data { + return Data.Builder() + .putLong(KEY_COURSE_ID, courseId) + .putLong(KEY_CONVERSATION_ID, conversationId) + .putString(KEY_CLIENT_SIDE_POST_ID, clientSidePostId) + .putString(KEY_CONTENT, content) + .putString(KEY_POST_TYPE, postType.name) + .apply { + if (parentPostId != null) putLong(KEY_PARENT_POST_ID, parentPostId) + } + .build() + } + } + + final override suspend fun doWork(): Result { + val courseId: Long = inputData.getLong(KEY_COURSE_ID, 0) + val conversationId: Long = + inputData.getLong(KEY_CONVERSATION_ID, 0) + val content = + inputData.getString(KEY_CONTENT) ?: return Result.failure() + + val postType = BaseCreatePostWorker.PostType.valueOf( + inputData.getString(KEY_POST_TYPE) ?: return Result.failure() + ) + + val parentPostId = if (KEY_PARENT_POST_ID in inputData.keyValueMap) + inputData.getLong(KEY_PARENT_POST_ID, 0) + else null + + val clientSidePostId = + inputData.getString(KEY_CLIENT_SIDE_POST_ID) ?: return Result.failure() + + return doWork( + courseId = courseId, + conversationId = conversationId, + clientSidePostId = clientSidePostId, + content = content, + postType = postType, + parentPostId = parentPostId + ) + } + + abstract suspend fun doWork( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + postType: PostType, + parentPostId: Long? + ): Result + + enum class PostType { + POST, + ANSWER_POST + } +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/CreateClientSidePostWorker.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/CreateClientSidePostWorker.kt new file mode 100644 index 000000000..2eee7919f --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/CreateClientSidePostWorker.kt @@ -0,0 +1,98 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work + +import android.content.Context +import android.util.Log +import androidx.work.Data +import androidx.work.WorkerParameters +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService +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.model.account.Account +import de.tum.informatics.www1.artemis.native_app.core.model.account.User +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.dto.AnswerPost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock + +/** + * Worker that inserts a client post into the database. + * This worker only fails when the input is wrong. + */ +class CreateClientSidePostWorker( + appContext: Context, + params: WorkerParameters, + private val accountService: AccountService, + private val accountDataService: AccountDataService, + private val metisStorageService: MetisStorageService, + private val serverConfigurationService: ServerConfigurationService, +) : BaseCreatePostWorker(appContext, params) { + + companion object { + private const val TAG = "CreateClientSidePostWorker" + } + + override suspend fun doWork( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + postType: PostType, + parentPostId: Long? + ): Result { + val serverUrl = serverConfigurationService.serverUrl.first() + val authToken = accountService.authToken.first() + + val authorAccount = accountDataService.getCachedAccountData(serverUrl, authToken) + ?: Account() // Super edge case, just use nothing here. + + val author = User( + username = authorAccount.username, + name = authorAccount.name, + id = authorAccount.id, + firstName = authorAccount.firstName, + lastName = authorAccount.lastName + ) + + when (postType) { + PostType.POST -> { + metisStorageService.insertClientSidePost( + host = serverConfigurationService.host.first(), + metisContext = MetisContext.Conversation(courseId, conversationId), + post = StandalonePost( + id = null, + title = null, + tags = null, + author = author, + authorRole = null, // We do not know the role of the user here! + content = content, + creationDate = Clock.System.now() + ), + clientSidePostId = clientSidePostId + ) + } + + PostType.ANSWER_POST -> { + metisStorageService.insertClientSidePost( + host = serverConfigurationService.host.first(), + metisContext = MetisContext.Conversation(courseId, conversationId), + post = AnswerPost( + id = null, + content = content, + author = author, + authorRole = null, + post = StandalonePost(id = parentPostId), + creationDate = Clock.System.now() + ), + clientSidePostId = clientSidePostId + ) + } + } + + Log.d(TAG, "Created client side post with id $clientSidePostId") + + return Result.success() + } +} diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/SendConversationPostWorker.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/SendConversationPostWorker.kt new file mode 100644 index 000000000..bc5fc85b7 --- /dev/null +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/work/SendConversationPostWorker.kt @@ -0,0 +1,228 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkerParameters +import de.tum.informatics.www1.artemis.native_app.core.common.ArtemisNotificationChannel +import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse +import de.tum.informatics.www1.artemis.native_app.core.data.onSuccess +import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService +import de.tum.informatics.www1.artemis.native_app.core.datastore.ArtemisNotificationManager +import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisModificationService +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.dto.AnswerPost +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 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.ConversationService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.getConversation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock + +/** + * Worker that uploads a post or answer post to the Artemis server. + * If the input is invalid, the worker fails. + * If the input is valid, but the reply could not be uploaded, the worker will schedule a retry. + * If the upload failed 5 times, the worker will fail and pop a notification to notify the user about the failure. + */ +class SendConversationPostWorker( + appContext: Context, + params: WorkerParameters, + private val metisModificationService: MetisModificationService, + private val metisStorageService: MetisStorageService, + private val serverConfigurationService: ServerConfigurationService, + private val accountService: AccountService, + private val conversationService: ConversationService +) : BaseCreatePostWorker(appContext, params) { + + companion object { + private const val TAG = "ReplyWorker" + } + + override suspend fun doWork( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + postType: PostType, + parentPostId: Long? + ): Result { + Log.d(TAG, "Starting send post to server. ClientSidePostId=$clientSidePostId") + + val getErrorReturnType: suspend () -> Result = if (runAttemptCount > 5) { + { + popFailureNotification(postType, content) + Result.failure() + } + } else { + { Result.retry() } + } + + return when (val authData = accountService.authenticationData.first()) { + is AccountService.AuthenticationData.LoggedIn -> { + val serverUrl = serverConfigurationService.serverUrl.first() + val host = serverConfigurationService.host.first() + val metisContext = MetisContext.Conversation(courseId, conversationId) + + when (postType) { + PostType.POST -> { + uploadPost( + courseId = courseId, + conversationId = conversationId, + content = content, + serverUrl = serverUrl, + authToken = authData.authToken + ).onSuccess { post -> + metisStorageService.upgradeClientSidePost( + host = host, + metisContext = metisContext, + clientSidePostId = clientSidePostId, + post = post + ) + } + } + + PostType.ANSWER_POST -> { + // Must not be null! + if (parentPostId == null) return Result.failure() + + uploadAnswerPost( + courseId = courseId, + conversationId = conversationId, + postId = parentPostId, + content = content, + serverUrl = serverUrl, + authToken = authData.authToken + ).onSuccess { post -> + metisStorageService.upgradeClientSideAnswerPost( + host = host, + metisContext = metisContext, + clientSidePostId = clientSidePostId, + post = post + ) + } + } + }.map(mapSuccess = { Result.success() }, mapFailure = { getErrorReturnType() }) + } + + AccountService.AuthenticationData.NotLoggedIn -> getErrorReturnType() + } + } + + private suspend fun uploadPost( + courseId: Long, + conversationId: Long, + content: String, + serverUrl: String, + authToken: String + ): NetworkResponse { + return loadConversation( + courseId = courseId, + conversationId = conversationId, + authToken = authToken, + serverUrl = serverUrl + ).then { conversation -> + metisModificationService.createPost( + context = MetisContext.Conversation(courseId, conversationId), + post = StandalonePost( + id = null, + title = null, + tags = null, + content = content, + conversation = conversation, + creationDate = Clock.System.now(), + displayPriority = DisplayPriority.NONE + ), + serverUrl = serverUrl, + authToken = authToken + ) + } + } + + private suspend fun uploadAnswerPost( + courseId: Long, + conversationId: Long, + postId: Long, + content: String, + serverUrl: String, + authToken: String + ): NetworkResponse { + return loadConversation( + courseId = courseId, + conversationId = conversationId, + authToken = authToken, + serverUrl = serverUrl + ).then { conversation -> + metisModificationService.createAnswerPost( + context = MetisContext.Conversation(courseId, conversationId), + post = AnswerPost( + content = content, + post = StandalonePost( + id = postId, + conversation = conversation + ), + creationDate = Clock.System.now() + ), + serverUrl = serverUrl, + authToken = authToken + ) + } + } + + private suspend fun loadConversation( + courseId: Long, + conversationId: Long, + authToken: String, + serverUrl: String + ): NetworkResponse = + conversationService.getConversation( + courseId = courseId, + conversationId = conversationId, + authToken = authToken, + serverUrl = serverUrl + ) + + private suspend fun popFailureNotification(type: PostType, content: String) { + val id = ArtemisNotificationManager.getNextNotificationId(applicationContext) + + val title = when (type) { + PostType.POST -> R.string.push_notification_send_post_failed_title + PostType.ANSWER_POST -> R.string.push_notification_send_answer_post_failed_title + } + + val message = when (type) { + PostType.POST -> R.string.push_notification_send_post_failed_message + PostType.ANSWER_POST -> R.string.push_notification_send_answer_post_failed_message + } + + val notification = + NotificationCompat.Builder( + applicationContext, + ArtemisNotificationChannel.MiscNotificationChannel.id + ) + .setSmallIcon(R.drawable.baseline_sms_failed_24) + .setContentTitle(applicationContext.getString(title)) + .setContentText( + applicationContext.getString( + message, + content + ) + ) + .setAutoCancel(true) + .build() + + try { + NotificationManagerCompat + .from(applicationContext) + .notify(id, notification) + } catch (e: SecurityException) { + Log.e(TAG, "Could not push reply notification due to missing permission") + } + } +} diff --git a/feature/metis/conversation/src/main/res/drawable/baseline_sms_failed_24.xml b/feature/metis/conversation/src/main/res/drawable/baseline_sms_failed_24.xml new file mode 100644 index 000000000..d23b6139d --- /dev/null +++ b/feature/metis/conversation/src/main/res/drawable/baseline_sms_failed_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/metis/conversation/src/main/res/values/notification_strings.xml b/feature/metis/conversation/src/main/res/values/notification_strings.xml new file mode 100644 index 000000000..d68e3427b --- /dev/null +++ b/feature/metis/conversation/src/main/res/values/notification_strings.xml @@ -0,0 +1,7 @@ + + + Your message has not been sent + Your reply has not been sent + Affected message: %1$s + Affected reply: %1$s + \ No newline at end of file diff --git a/feature/metis/conversation/src/main/res/values/post_strings.xml b/feature/metis/conversation/src/main/res/values/post_strings.xml index bd208614e..f0aa848d0 100644 --- a/feature/metis/conversation/src/main/res/values/post_strings.xml +++ b/feature/metis/conversation/src/main/res/values/post_strings.xml @@ -6,4 +6,6 @@ Reply in thread (edited) + + Sending post failed. Click to try again. \ 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/ConversationAnswerMessagesE2eTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt index a4ed31206..f8fd7a0d7 100644 --- a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt @@ -47,7 +47,7 @@ class ConversationAnswerMessagesE2eTest : ConversationMessagesBaseTest() { } val downloadedPost = metisService - .getPost(metisContext, post.serverPostId, testServerUrl, accessToken) + .getPost(metisContext, post.serverPostId!!, testServerUrl, accessToken) .orThrow("Could not download relevant post") Logger.info("Downloaded post = $downloadedPost") diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesE2eTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesE2eTest.kt index c3e7cb871..8de66cd36 100644 --- a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesE2eTest.kt +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationMessagesE2eTest.kt @@ -218,7 +218,7 @@ class ConversationMessagesE2eTest : ConversationMessagesBaseTest() { ).orThrow("Could not edit message") val editedPost = metisService - .getPost(metisContext, basePost.serverPostId, testServerUrl, accessToken) + .getPost(metisContext, basePost.serverPostId!!, testServerUrl, accessToken) .orThrow("Could not download edited post") assertEquals(newText, editedPost.content, "Edited post does not have the updated text content") diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt index ba920472e..c62ef87e5 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ConversationCollections.kt @@ -10,21 +10,38 @@ data class ConversationCollections( val channels: ConversationCollection, val groupChats: ConversationCollection, val directChats: ConversationCollection, - val hidden: ConversationCollection + val hidden: ConversationCollection, + val exerciseChannels: ConversationCollection, + val lectureChannels: ConversationCollection, + val examChannels: ConversationCollection ) { + val conversations: List + get() = favorites.conversations + + channels.conversations + + groupChats.conversations + + directChats.conversations + + hidden.conversations + + exerciseChannels.conversations + + lectureChannels.conversations + + examChannels.conversations + fun filtered(query: String): ConversationCollections { return ConversationCollections( channels = channels.filter { it.filterPredicate(query) }, groupChats = groupChats.filter { it.filterPredicate(query) }, directChats = directChats.filter { it.filterPredicate(query) }, favorites = favorites.filter { it.filterPredicate(query) }, - hidden = hidden.filter { it.filterPredicate(query) } + hidden = hidden.filter { it.filterPredicate(query) }, + exerciseChannels = exerciseChannels.filter { it.filterPredicate(query) }, + lectureChannels = lectureChannels.filter { it.filterPredicate(query) }, + examChannels = examChannels.filter { it.filterPredicate(query) } ) } data class ConversationCollection( val conversations: List, - val isExpanded: Boolean + val isExpanded: Boolean, + val showPrefix: Boolean = true ) { fun filter(predicate: (Conversation) -> Boolean) = copy(conversations = conversations.filter(predicate)) diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt index cc0185922..e068ce5a2 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/ConversationPreferenceService.kt @@ -10,7 +10,10 @@ interface ConversationPreferenceService { data class Preferences( val favouritesExpanded: Boolean, - val channelsExpanded: Boolean, + val generalsExpanded: Boolean, + val examsExpanded: Boolean, + val exercisesExpanded: Boolean, + val lecturesExpanded: Boolean, val groupChatsExpanded: Boolean, val personalConversationsExpanded: Boolean, val hiddenExpanded: Boolean diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt index d878cb4b9..6334f144d 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/service/storage/impl/ConversationPreferenceStorageServiceImpl.kt @@ -18,6 +18,9 @@ internal class ConversationPreferenceStorageServiceImpl(private val context: Con private const val KEY_GROUP_CHATS_EXPANDED = "group_chats" private const val KEY_PERSONAL_CONVERSATIONS_EXPANDED = "personal_conv" private const val KEY_HIDDEN_EXPANDED = "hidden" + private const val KEY_EXAMS_EXPANDED = "exams" + private const val KEY_EXERCISES_EXPANDED = "exercises" + private const val KEY_LECTURES_EXPANDED = "lectures" } private val Context.dataStore by preferencesDataStore("conversation_preferences") @@ -28,20 +31,26 @@ internal class ConversationPreferenceStorageServiceImpl(private val context: Con ): Flow = context.dataStore.data.map { data -> ConversationPreferenceService.Preferences( favouritesExpanded = data[getKey(serverUrl, courseId, KEY_FAVOURITES_EXPANDED)] ?: true, - channelsExpanded = data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] ?: true, + generalsExpanded = data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] ?: true, groupChatsExpanded = data[getKey(serverUrl, courseId, KEY_GROUP_CHATS_EXPANDED)] ?: true, personalConversationsExpanded = data[getKey(serverUrl, courseId, KEY_PERSONAL_CONVERSATIONS_EXPANDED)] ?: true, - hiddenExpanded = data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] ?: false + hiddenExpanded = data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] ?: false, + examsExpanded = data[getKey(serverUrl, courseId, KEY_EXAMS_EXPANDED)] ?: true, + exercisesExpanded = data[getKey(serverUrl, courseId, KEY_EXERCISES_EXPANDED)] ?: true, + lecturesExpanded = data[getKey(serverUrl, courseId, KEY_LECTURES_EXPANDED)] ?: true, ) } override suspend fun updatePreferences(serverUrl: String, courseId: Long, preferences: ConversationPreferenceService.Preferences) { context.dataStore.edit { data -> data[getKey(serverUrl, courseId, KEY_FAVOURITES_EXPANDED)] = preferences.favouritesExpanded - data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] = preferences.channelsExpanded + data[getKey(serverUrl, courseId, KEY_CHANNELS_EXPANDED)] = preferences.generalsExpanded data[getKey(serverUrl, courseId, KEY_GROUP_CHATS_EXPANDED)] = preferences.groupChatsExpanded data[getKey(serverUrl, courseId, KEY_PERSONAL_CONVERSATIONS_EXPANDED)] = preferences.personalConversationsExpanded data[getKey(serverUrl, courseId, KEY_HIDDEN_EXPANDED)] = preferences.hiddenExpanded + data[getKey(serverUrl, courseId, KEY_EXAMS_EXPANDED)] = preferences.examsExpanded + data[getKey(serverUrl, courseId, KEY_EXERCISES_EXPANDED)] = preferences.exercisesExpanded + data[getKey(serverUrl, courseId, KEY_LECTURES_EXPANDED)] = preferences.lecturesExpanded } } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt index 900ac4c09..8f8e20ecb 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsScreen.kt @@ -1,21 +1,24 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.browse_channels -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Create -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,9 +30,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder +import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.ui.AwaitDeferredCompletion import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog @@ -37,7 +38,6 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateU import de.tum.informatics.www1.artemis.native_app.core.ui.compose.NavigationBackButton import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.common.ChannelIcons -import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.courseNavGraphBuilderExtensions import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import kotlinx.coroutines.Deferred import org.koin.androidx.compose.koinViewModel @@ -50,14 +50,12 @@ fun BrowseChannelsScreen( modifier: Modifier, courseId: Long, onNavigateToConversation: (conversationId: Long) -> Unit, - onNavigateToCreateChannel: () -> Unit, onNavigateBack: () -> Unit ) { BrowseChannelsScreen( modifier = modifier, viewModel = koinViewModel { parametersOf(courseId) }, onNavigateToConversation = onNavigateToConversation, - onNavigateToCreateChannel = onNavigateToCreateChannel, onNavigateBack = onNavigateBack ) } @@ -67,10 +65,12 @@ internal fun BrowseChannelsScreen( modifier: Modifier, viewModel: BrowseChannelsViewModel, onNavigateToConversation: (conversationId: Long) -> Unit, - onNavigateToCreateChannel: () -> Unit, onNavigateBack: () -> Unit ) { - val canCreateChannel: Boolean by viewModel.canCreateChannel.collectAsState() + + LaunchedEffect(Unit) { + viewModel.requestReload() + } val channelsDataState by viewModel.channels.collectAsState() @@ -98,13 +98,7 @@ internal fun BrowseChannelsScreen( navigationIcon = { NavigationBackButton(onNavigateBack) } ) }, - floatingActionButton = { - if (canCreateChannel) { - FloatingActionButton(onClick = onNavigateToCreateChannel) { - Icon(imageVector = Icons.Default.Create, contentDescription = null) - } - } - } + ) { padding -> BasicDataStateUi( modifier = Modifier @@ -159,20 +153,57 @@ private fun ChannelChatItem(channelChat: ChannelChat, onClick: () -> Unit) { ListItem( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) .testTag(testTagForBrowsedChannelItem(channelChat.id)), leadingContent = { ChannelIcons(channelChat) }, headlineContent = { Text(channelChat.name) }, supportingContent = { - Text( - text = pluralStringResource( - id = R.plurals.browse_channel_channel_item_member_count, - count = channelChat.numberOfMembers, - channelChat.numberOfMembers + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (channelChat.isMember) { + Text( + text = stringResource(id = R.string.joined_channel), + modifier = Modifier + .padding(end = 8.dp) + .background( + MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall + ) + } + + Text( + text = pluralStringResource( + id = R.plurals.browse_channel_channel_item_member_count, + count = channelChat.numberOfMembers, + channelChat.numberOfMembers + ) ) - ) + } + }, + trailingContent = { + if (!channelChat.isMember) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier + .wrapContentSize() + ) { + Text(text = stringResource(id = R.string.join_button_title)) + } + } } ) } + + diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt index 42d97faf3..18fb801cf 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/browse_channels/BrowseChannelsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest import de.tum.informatics.www1.artemis.native_app.core.data.DataState +import de.tum.informatics.www1.artemis.native_app.core.data.onSuccess import de.tum.informatics.www1.artemis.native_app.core.data.retryOnInternet import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService import de.tum.informatics.www1.artemis.native_app.core.data.service.network.CourseService @@ -12,16 +13,15 @@ import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken import de.tum.informatics.www1.artemis.native_app.core.device.NetworkStatusProvider -import de.tum.informatics.www1.artemis.native_app.core.model.account.isAtLeastTutorInCourse +import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.service.network.ChannelService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -30,7 +30,7 @@ internal class BrowseChannelsViewModel( private val courseId: Long, private val accountService: AccountService, private val serverConfigurationService: ServerConfigurationService, - private val channelService: de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.service.network.ChannelService, + private val channelService: ChannelService, private val networkStatusProvider: NetworkStatusProvider, accountDataService: AccountDataService, courseService: CourseService, @@ -39,7 +39,7 @@ internal class BrowseChannelsViewModel( private val requestRefresh = MutableSharedFlow(extraBufferCapacity = 1) - val channels: StateFlow>> = flatMapLatest( + val channels: StateFlow>> = flatMapLatest( serverConfigurationService.serverUrl, accountService.authToken, requestRefresh.onStart { emit(Unit) } @@ -47,50 +47,42 @@ internal class BrowseChannelsViewModel( retryOnInternet(networkStatusProvider.currentNetworkStatus) { channelService .getChannels(courseId, serverUrl, authToken) - .bind { channels -> channels.filter { !it.isMember } } + .bind { channels -> channels } } } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) - val canCreateChannel: StateFlow = flatMapLatest( - serverConfigurationService.serverUrl, - accountService.authToken, - requestRefresh.onStart { emit(Unit) } - ) { serverUrl, authToken, _ -> - retryOnInternet(networkStatusProvider.currentNetworkStatus) { - courseService.getCourse(courseId, serverUrl, authToken) - .then { courseWithScore -> - accountDataService - .getAccountData(serverUrl, authToken) - .bind { it.isAtLeastTutorInCourse(courseWithScore.course) } - } - }.map { it.orElse(false) } - - } - .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, false) /** * Returns the id of the channel on registration success or null if any error occurred */ - fun registerInChannel(channelChat: de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.ChannelChat): Deferred { + fun registerInChannel(channelChat: ChannelChat): Deferred { return viewModelScope.async(coroutineContext) { val username = when (val authData = accountService.authenticationData.first()) { is AccountService.AuthenticationData.LoggedIn -> authData.username AccountService.AuthenticationData.NotLoggedIn -> return@async null } - channelService.registerInChannel( + val result = channelService.registerInChannel( courseId = courseId, conversationId = channelChat.id, username = username, serverUrl = serverConfigurationService.serverUrl.first(), authToken = accountService.authToken.first() ) - .bind { if (it) channelChat.id else null } + + result.onSuccess { successful -> + if (successful) { + requestReload() + } + } + + result.bind { if (it) channelChat.id else null } .orNull() } } + fun requestReload() { requestRefresh.tryEmit(Unit) } diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt index bf67a6efb..5a1a495ed 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelScreen.kt @@ -4,18 +4,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Create -import androidx.compose.material3.Icon +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -26,15 +25,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.core.ui.alert.TextAlertDialog -import de.tum.informatics.www1.artemis.native_app.core.ui.compose.JobAnimatedFloatingActionButton import de.tum.informatics.www1.artemis.native_app.core.ui.compose.NavigationBackButton import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.PotentiallyIllegalTextField +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -76,7 +75,7 @@ internal fun CreateChannelScreen( val isNameIllegal by viewModel.isNameIllegal.collectAsState() val isDescriptionIllegal by viewModel.isDescriptionIllegal.collectAsState() - val isPublic by viewModel.isPublic.collectAsState() + val isPrivate by viewModel.isPrivate.collectAsState() val isAnnouncement by viewModel.isAnnouncement.collectAsState() val canCreate by viewModel.canCreate.collectAsState() @@ -93,22 +92,7 @@ internal fun CreateChannelScreen( } ) }, - floatingActionButton = { - JobAnimatedFloatingActionButton( - modifier = Modifier.testTag(TEST_TAG_CREATE_CHANNEL_BUTTON), - enabled = canCreate, - startJob = viewModel::createChannel, - onJobCompleted = { channel -> - if (channel != null) { - onConversationCreated(channel.id) - } else { - isDisplayingErrorDialog = true - } - } - ) { - Icon(imageVector = Icons.Default.Create, contentDescription = null) - } - } + ) { paddingValues -> Column( modifier = Modifier @@ -120,7 +104,8 @@ internal fun CreateChannelScreen( ) { Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.create_channel_description) + text = stringResource(id = R.string.create_channel_description), + style = MaterialTheme.typography.bodySmall ) PotentiallyIllegalTextField( @@ -155,27 +140,40 @@ internal fun CreateChannelScreen( modifier = Modifier.fillMaxWidth(), title = stringResource(id = R.string.create_channel_channel_accessibility_type), description = stringResource(id = R.string.create_channel_channel_accessibility_type_hint), - choiceOne = stringResource(id = R.string.create_channel_channel_accessibility_type_public), - choiceTwo = stringResource(id = R.string.create_channel_channel_accessibility_type_private), - choice = isPublic, - choiceOneButtonTestTag = TEST_TAG_SET_PUBLIC_BUTTON, - choiceTwoButtonTestTag = TEST_TAG_SET_PRIVATE_BUTTON, - updateChoice = viewModel::updatePublic + isChecked = isPrivate, + onCheckedChange = { viewModel.updatePublic(it) } ) BinarySelection( modifier = Modifier.fillMaxWidth(), title = stringResource(id = R.string.create_channel_channel_announcement_type), description = stringResource(id = R.string.create_channel_channel_announcement_type_hint), - choiceOne = stringResource(id = R.string.create_channel_channel_announcement_type_announcement), - choiceTwo = stringResource(id = R.string.create_channel_channel_announcement_type_unrestricted), - choice = isAnnouncement, - choiceOneButtonTestTag = TEST_TAG_SET_ANNOUNCEMENT_BUTTON, - choiceTwoButtonTestTag = TEST_TAG_SET_UNRESTRICTED_BUTTON, - updateChoice = viewModel::updateAnnouncement + isChecked = isAnnouncement, + onCheckedChange = { viewModel.updateAnnouncement(it) } ) - Box(modifier = Modifier.height(Spacings.FabContentBottomPadding)) + Button( + onClick = { + viewModel.viewModelScope.launch { + val channel = viewModel.createChannel().await() + if (channel != null) { + onConversationCreated(channel.id) + } else { + isDisplayingErrorDialog = true + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + enabled = viewModel.canCreate.collectAsState().value, + + ) { + Text(text = "Create Channel") + } + + Spacer(modifier = Modifier.height(16.dp)) + } } @@ -196,68 +194,33 @@ private fun BinarySelection( modifier: Modifier, title: String, description: String, - choiceOne: String, - choiceTwo: String, - choice: Boolean, - choiceOneButtonTestTag: String, - choiceTwoButtonTestTag: String, - updateChoice: (Boolean) -> Unit + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( + Row( modifier = Modifier.fillMaxWidth(), - text = title, - style = MaterialTheme.typography.titleSmall - ) - - Column { - RadioButtonWithText( - modifier = Modifier.fillMaxWidth(), - buttonTestTag = choiceOneButtonTestTag, - isChecked = choice, - onClick = { updateChoice(true) }, - text = choiceOne + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + style = MaterialTheme.typography.titleMedium ) - - RadioButtonWithText( - modifier = Modifier.fillMaxWidth(), - buttonTestTag = choiceTwoButtonTestTag, - isChecked = !choice, - onClick = { - updateChoice(false) - }, - text = choiceTwo + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange ) } Text( modifier = Modifier.fillMaxWidth(), text = description, + style = MaterialTheme.typography.bodySmall ) } } -@Composable -private fun RadioButtonWithText( - modifier: Modifier, - buttonTestTag: String, - isChecked: Boolean, - onClick: () -> Unit, - text: String -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - modifier = Modifier.testTag(buttonTestTag), - selected = isChecked, - onClick = onClick - ) - - Text(text = text) - } -} diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt index eaed389ef..95fa3e8b9 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/create_channel/CreateChannelViewModel.kt @@ -31,16 +31,16 @@ internal class CreateChannelViewModel( private companion object { private const val KEY_NAME = "name" private const val KEY_DESCRIPTION = "description" - private const val KEY_IS_PUBLIC = "is_public" + private const val KEY_IS_PRIVATE = "is_private" private const val KEY_IS_ANNOUNCEMENT = "announcement" } val name: StateFlow = savedStateHandle.getStateFlow(KEY_NAME, "") val description: StateFlow = savedStateHandle.getStateFlow(KEY_DESCRIPTION, "") - val isPublic: StateFlow = savedStateHandle.getStateFlow(KEY_IS_PUBLIC, true) + val isPrivate: StateFlow = savedStateHandle.getStateFlow(KEY_IS_PRIVATE, false) val isAnnouncement: StateFlow = - savedStateHandle.getStateFlow(KEY_IS_ANNOUNCEMENT, true) + savedStateHandle.getStateFlow(KEY_IS_ANNOUNCEMENT, false) val isNameIllegal: StateFlow = name .mapIsChannelNameIllegal() @@ -65,7 +65,7 @@ internal class CreateChannelViewModel( courseId = courseId, name = name.value, description = description.value, - isPublic = isPublic.value, + isPublic = !isPrivate.value, isAnnouncement = isAnnouncement.value, authToken = authToken, serverUrl = serverUrl @@ -81,8 +81,8 @@ internal class CreateChannelViewModel( savedStateHandle[KEY_DESCRIPTION] = description } - fun updatePublic(isPublic: Boolean) { - savedStateHandle[KEY_IS_PUBLIC] = isPublic + fun updatePublic(isPrivate: Boolean) { + savedStateHandle[KEY_IS_PRIVATE] = isPrivate } fun updateAnnouncement(isAnnouncement: Boolean) { diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt index cc7db7fd7..f0e97b241 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationList.kt @@ -2,46 +2,29 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversat import androidx.annotation.StringRes import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowRight -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.Groups2 -import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.* import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ConversationCollections import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.R @@ -51,20 +34,25 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.d import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.GroupChat import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.OneToOneChat -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableTitle +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName internal const val TEST_TAG_CONVERSATION_LIST = "conversation list" - internal const val TEST_TAG_HEADER_EXPAND_ICON = "expand icon" internal const val SECTION_FAVORITES_KEY = "favorites" internal const val SECTION_HIDDEN_KEY = "hidden" internal const val SECTION_CHANNELS_KEY = "channels" internal const val SECTION_GROUPS_KEY = "groups" +internal const val SECTION_EXERCISES_KEY = "exercises" +internal const val SECTION_EXAMS_KEY = "exams" +internal const val SECTION_LECTURES_KEY = "lectures" internal const val SECTION_DIRECT_MESSAGES_KEY = "direct-messages" internal const val KEY_SUFFIX_FAVORITES = "_f" internal const val KEY_SUFFIX_CHANNELS = "_c" +internal const val KEY_SUFFIX_EXAMS = "_exa" +internal const val KEY_SUFFIX_EXERCISES = "_exe" +internal const val KEY_SUFFIX_LECTURES = "_l" internal const val KEY_SUFFIX_GROUPS = "_g" internal const val KEY_SUFFIX_PERSONAL = "_p" internal const val KEY_SUFFIX_HIDDEN = "_h" @@ -79,26 +67,31 @@ internal fun ConversationList( onNavigateToConversation: (conversationId: Long) -> Unit, onToggleMarkAsFavourite: (conversationId: Long, favorite: Boolean) -> Unit, onToggleHidden: (conversationId: Long, hidden: Boolean) -> Unit, + onToggleMuted: (conversationId: Long, muted: Boolean) -> Unit, onRequestCreatePersonalConversation: () -> Unit, onRequestAddChannel: () -> Unit, trailingContent: LazyListScope.() -> Unit ) { - val listWithHeader: LazyListScope.(ConversationCollections.ConversationCollection<*>, String, String, Int, ConversationSectionHeaderAction, () -> Unit) -> Unit = - { collection, key, suffix, textRes, action, toggleIsExpanded -> + + val listWithHeader: LazyListScope.(ConversationCollections.ConversationCollection<*>, String, String, Int, ConversationSectionHeaderAction, () -> Unit, @Composable () -> Unit) -> Unit = + { collection, key, suffix, textRes, action, toggleIsExpanded, icon -> conversationSectionHeader( key = key, text = textRes, onClickAddAction = action, isExpanded = collection.isExpanded, - toggleIsExpanded = toggleIsExpanded + toggleIsExpanded = toggleIsExpanded, + icon = icon ) conversationList( keySuffix = suffix, conversations = collection, + showPrefix = collection.showPrefix, onNavigateToConversation = onNavigateToConversation, onToggleMarkAsFavourite = onToggleMarkAsFavourite, - onToggleHidden = onToggleHidden + onToggleHidden = onToggleHidden, + onToggleMuted = onToggleMuted ) } @@ -110,7 +103,8 @@ internal fun ConversationList( KEY_SUFFIX_FAVORITES, R.string.conversation_overview_section_favorites, NoAction, - viewModel::toggleFavoritesExpanded + { viewModel.toggleFavoritesExpanded() }, + { Icon(imageVector = Icons.Default.Favorite, contentDescription = null) } ) } @@ -118,28 +112,65 @@ internal fun ConversationList( conversationCollections.channels, SECTION_CHANNELS_KEY, KEY_SUFFIX_CHANNELS, - R.string.conversation_overview_section_channels, + R.string.conversation_overview_section_general_channels, OnClickAction(onRequestAddChannel), - viewModel::toggleChannelsExpanded - ) + viewModel::toggleGeneralsExpanded + ) { Icon(imageVector = Icons.Default.ChatBubble, contentDescription = null) } - listWithHeader( - conversationCollections.groupChats, - SECTION_GROUPS_KEY, - KEY_SUFFIX_GROUPS, - R.string.conversation_overview_section_groups, - OnClickAction(onRequestCreatePersonalConversation), - viewModel::toggleGroupChatsExpanded - ) + if (conversationCollections.exerciseChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.exerciseChannels, + SECTION_EXERCISES_KEY, + KEY_SUFFIX_EXERCISES, + R.string.conversation_overview_section_exercise_channels, + NoAction, + viewModel::toggleExercisesExpanded + ) { Icon(imageVector = Icons.Default.List, contentDescription = null) } + } - listWithHeader( - conversationCollections.directChats, - SECTION_DIRECT_MESSAGES_KEY, - KEY_SUFFIX_PERSONAL, - R.string.conversation_overview_section_direct_messages, - OnClickAction(onRequestCreatePersonalConversation), - viewModel::togglePersonalConversationsExpanded - ) + if (conversationCollections.lectureChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.lectureChannels, + SECTION_LECTURES_KEY, + KEY_SUFFIX_LECTURES, + R.string.conversation_overview_section_lecture_channels, + NoAction, + viewModel::toggleLecturesExpanded + ) { Icon(imageVector = Icons.Default.InsertDriveFile, contentDescription = null) } + } + + if (conversationCollections.examChannels.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.examChannels, + SECTION_EXAMS_KEY, + KEY_SUFFIX_EXAMS, + R.string.conversation_overview_section_exam_channels, + NoAction, + viewModel::toggleExamsExpanded + ) { Icon(imageVector = Icons.Default.School, contentDescription = null) } + } + + if (conversationCollections.groupChats.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.groupChats, + SECTION_GROUPS_KEY, + KEY_SUFFIX_GROUPS, + R.string.conversation_overview_section_groups, + OnClickAction(onRequestCreatePersonalConversation), + viewModel::toggleGroupChatsExpanded + ) { Icon(imageVector = Icons.Default.Forum, contentDescription = null) } + } + + if (conversationCollections.directChats.conversations.isNotEmpty()) { + listWithHeader( + conversationCollections.directChats, + SECTION_DIRECT_MESSAGES_KEY, + KEY_SUFFIX_PERSONAL, + R.string.conversation_overview_section_direct_messages, + OnClickAction(onRequestCreatePersonalConversation), + viewModel::togglePersonalConversationsExpanded + ) { Icon(imageVector = Icons.Default.Message, contentDescription = null) } + } if (conversationCollections.hidden.conversations.isNotEmpty()) { listWithHeader( @@ -149,7 +180,7 @@ internal fun ConversationList( R.string.conversation_overview_section_hidden, NoAction, viewModel::toggleHiddenExpanded - ) + ) { Icon(imageVector = Icons.Default.NotInterested, contentDescription = null) } } trailingContent() @@ -161,7 +192,8 @@ private fun LazyListScope.conversationSectionHeader( @StringRes text: Int, isExpanded: Boolean, onClickAddAction: ConversationSectionHeaderAction, - toggleIsExpanded: () -> Unit + toggleIsExpanded: () -> Unit, + icon: @Composable () -> Unit ) { item(key = key) { Column( @@ -172,41 +204,37 @@ private fun LazyListScope.conversationSectionHeader( Divider() Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .fillMaxWidth() + .clickable { toggleIsExpanded() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + icon() + Text( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + text = stringResource(id = text), + style = MaterialTheme.typography.titleSmall + ) + } + IconButton( modifier = Modifier.testTag(TEST_TAG_HEADER_EXPAND_ICON), - onClick = { - toggleIsExpanded() - } + onClick = { toggleIsExpanded() } ) { Icon( imageVector = if (isExpanded) Icons.Default.ArrowDropDown else Icons.Default.ArrowRight, - contentDescription = null + contentDescription = null, + modifier = Modifier.size(32.dp) ) } - - Text( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp), - text = stringResource(id = text), - style = MaterialTheme.typography.titleSmall - ) - - if (onClickAddAction is OnClickAction) { - IconButton( - onClick = onClickAddAction.onClick - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null - ) - } - } else { - Box(modifier = Modifier.height(40.dp)) - } } Divider() @@ -217,19 +245,23 @@ private fun LazyListScope.conversationSectionHeader( private fun LazyListScope.conversationList( keySuffix: String, conversations: ConversationCollections.ConversationCollection, + showPrefix: Boolean, onNavigateToConversation: (conversationId: Long) -> Unit, onToggleMarkAsFavourite: (conversationId: Long, favorite: Boolean) -> Unit, onToggleHidden: (conversationId: Long, hidden: Boolean) -> Unit, + onToggleMuted: (conversationId: Long, muted: Boolean) -> Unit, ) { if (!conversations.isExpanded) return items( conversations.conversations, - key = { tagForConversation(it.id, keySuffix) }) { conversation -> + key = { tagForConversation(it.id, keySuffix) } + ) { conversation -> ConversationListItem( modifier = Modifier .fillMaxWidth() .testTag(tagForConversation(conversation.id, keySuffix)), conversation = conversation, + showPrefix = showPrefix, onNavigateToConversation = { onNavigateToConversation(conversation.id) }, onToggleMarkAsFavourite = { onToggleMarkAsFavourite( @@ -238,108 +270,125 @@ private fun LazyListScope.conversationList( ) }, onToggleHidden = { onToggleHidden(conversation.id, !conversation.isHidden) }, - content = { contentModifier -> - val unreadMessagesCount = conversation.unreadMessagesCount ?: 0 - - when (conversation) { - is ChannelChat -> { - val channelName = if (conversation.isArchived) { - stringResource( - id = R.string.conversation_overview_archived_channel_name, - conversation.name - ) - } else conversation.name - - ListItem( - modifier = contentModifier, - leadingContent = { - PrimaryChannelIcon(channelChat = conversation) - }, - headlineContent = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text(text = channelName, maxLines = 1) - - ExtraChannelIcons(channelChat = conversation) - } - }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - - is GroupChat -> { - ListItem( - modifier = contentModifier, - headlineContent = { Text(conversation.humanReadableTitle) }, - leadingContent = { - Icon(imageVector = Icons.Default.Groups2, contentDescription = null) - }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - - is OneToOneChat -> { - ListItem( - modifier = contentModifier, - headlineContent = { Text(conversation.humanReadableTitle) }, - trailingContent = { - UnreadMessages(unreadMessagesCount = unreadMessagesCount) - } - ) - } - } - } + onToggleMuted = { onToggleMuted(conversation.id, !conversation.isMuted) }, ) } } -@Composable -private fun UnreadMessages(modifier: Modifier = Modifier, unreadMessagesCount: Long) { - if (unreadMessagesCount > 0) { - Box( - modifier = modifier - .size(24.dp) - .aspectRatio(1f) - .background( - MaterialTheme.colorScheme.primaryContainer, - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = unreadMessagesCount.toString(), - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } -} - @Composable private fun ConversationListItem( modifier: Modifier = Modifier, conversation: Conversation, + showPrefix: Boolean, onNavigateToConversation: () -> Unit, onToggleMarkAsFavourite: () -> Unit, onToggleHidden: () -> Unit, - content: @Composable (Modifier) -> Unit + onToggleMuted: () -> Unit, ) { var isContextDialogShown by remember { mutableStateOf(false) } val onDismissRequest = { isContextDialogShown = false } + val unreadMessagesCount = conversation.unreadMessagesCount ?: 0 + + val headlineColor = + LocalContentColor.current.copy(alpha = if (conversation.isMuted) 0.6f else 1f) + + val displayName = when (conversation) { + is ChannelChat -> { + val channelName = if (conversation.isArchived) { + stringResource( + id = R.string.conversation_overview_archived_channel_name, + conversation.name + ) + } else conversation.name + + if (showPrefix) { + channelName + } else { + channelName.removeSectionPrefix() + } + } + is GroupChat, is OneToOneChat -> { + val humanReadableTitle = conversation.humanReadableName + if (showPrefix) { + humanReadableTitle + } else { + humanReadableTitle.removeSectionPrefix() + } + } + else -> conversation.humanReadableName + } + Box(modifier = modifier) { - content( - Modifier.combinedClickable( - onClick = onNavigateToConversation, - onLongClick = { isContextDialogShown = true } - ) - ) + when (conversation) { + is ChannelChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + leadingContent = { + PrimaryChannelIcon(channelChat = conversation) + }, + headlineContent = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text(text = displayName, maxLines = 1, color = headlineColor) + + ExtraChannelIcons(channelChat = conversation) + } + }, + trailingContent = { + UnreadMessages( + modifier = Modifier.padding(end = 24.dp), + unreadMessagesCount = unreadMessagesCount + ) + } + ) + } + + is GroupChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + headlineContent = { + Text( + displayName, + color = headlineColor + ) + }, + leadingContent = { + Icon(imageVector = Icons.Default.Groups2, contentDescription = null) + }, + trailingContent = { + UnreadMessages(unreadMessagesCount = unreadMessagesCount) + } + ) + } + + is OneToOneChat -> { + ListItem( + modifier = Modifier.clickable(onClick = onNavigateToConversation), + headlineContent = { + Text( + displayName, + color = headlineColor + ) + }, + trailingContent = { + UnreadMessages(unreadMessagesCount = unreadMessagesCount) + } + ) + } + } + + IconButton( + onClick = { isContextDialogShown = true }, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null) + } DropdownMenu( expanded = isContextDialogShown, - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, + modifier = Modifier.align(Alignment.TopEnd), + offset = DpOffset(x = (-10).dp, y = 0.dp), ) { DropdownMenuItem( leadingIcon = { @@ -365,7 +414,7 @@ private fun ConversationListItem( DropdownMenuItem( leadingIcon = { Icon( - imageVector = if (conversation.isHidden) Icons.Default.NotificationsActive else Icons.Default.NotificationsOff, + imageVector = if (conversation.isHidden) Icons.Default.Visibility else Icons.Default.VisibilityOff, contentDescription = null ) }, @@ -382,6 +431,48 @@ private fun ConversationListItem( onDismissRequest() } ) + + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = if (conversation.isMuted) Icons.Default.NotificationsActive else Icons.Default.NotificationsOff, + contentDescription = null + ) + }, + text = { + Text( + text = stringResource( + id = if (conversation.isMuted) R.string.conversation_overview_conversation_item_unmark_as_muted + else R.string.conversation_overview_conversation_item_mark_as_muted + ) + ) + }, + onClick = { + onToggleMuted() + onDismissRequest() + } + ) + } + } +} + +@Composable +private fun UnreadMessages(modifier: Modifier = Modifier, unreadMessagesCount: Long) { + if (unreadMessagesCount > 0) { + Box( + modifier = modifier + .size(24.dp) + .aspectRatio(1f) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = unreadMessagesCount.toString(), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) } } } @@ -391,3 +482,15 @@ private sealed interface ConversationSectionHeaderAction private data class OnClickAction(val onClick: () -> Unit) : ConversationSectionHeaderAction private object NoAction : ConversationSectionHeaderAction + +private fun String.removeSectionPrefix(): String { + val prefixes = listOf("exercise-", "lecture-", "exam-") + var result = this + for (prefix in prefixes) { + if (result.startsWith(prefix, ignoreCase = true)) { + result = result.removePrefix(prefix) + break + } + } + return result.trim() +} diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt index fc8c43f5f..1e1babddc 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewBody.kt @@ -9,14 +9,22 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddComment +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.WifiOff -import androidx.compose.material3.Button import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -32,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.data.DataState import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi @@ -50,14 +59,18 @@ fun ConversationOverviewBody( courseId: Long, onNavigateToConversation: (conversationId: Long) -> Unit, onRequestCreatePersonalConversation: () -> Unit, - onRequestAddChannel: () -> Unit + onRequestAddChannel: () -> Unit, + onRequestBrowseChannel: () -> Unit, + canCreateChannel: Boolean ) { ConversationOverviewBody( modifier = modifier, viewModel = koinViewModel { parametersOf(courseId) }, onNavigateToConversation = onNavigateToConversation, onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, - onRequestAddChannel = onRequestAddChannel + onRequestAddChannel = onRequestAddChannel, + onRequestBrowseChannel = onRequestBrowseChannel, + canCreateChannel = canCreateChannel ) } @@ -67,7 +80,9 @@ fun ConversationOverviewBody( viewModel: ConversationOverviewViewModel, onNavigateToConversation: (conversationId: Long) -> Unit, onRequestCreatePersonalConversation: () -> Unit, - onRequestAddChannel: () -> Unit + onRequestAddChannel: () -> Unit, + onRequestBrowseChannel: () -> Unit, + canCreateChannel: Boolean ) { var showCodeOfConduct by rememberSaveable { mutableStateOf(false) } val conversationCollectionsDataState: DataState by viewModel.conversations.collectAsState() @@ -80,75 +95,85 @@ fun ConversationOverviewBody( viewModel.requestReload() } - BasicDataStateUi( - modifier = modifier, - dataState = conversationCollectionsDataState, - loadingText = stringResource(id = R.string.conversation_overview_loading), - failureText = stringResource(id = R.string.conversation_overview_loading_failed), - retryButtonText = stringResource(id = R.string.conversation_overview_loading_try_again), - onClickRetry = viewModel::requestReload - ) { conversationCollection -> - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - AnimatedVisibility(modifier = Modifier.fillMaxWidth(), visible = !isConnected) { - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.align(Alignment.Center), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Default.WifiOff, contentDescription = null) - - Text( - text = stringResource(id = R.string.conversation_overview_not_connected_banner), - style = MaterialTheme.typography.bodyMedium - ) + Box(modifier = Modifier.fillMaxSize()) { + BasicDataStateUi( + modifier = modifier, + dataState = conversationCollectionsDataState, + loadingText = stringResource(id = R.string.conversation_overview_loading), + failureText = stringResource(id = R.string.conversation_overview_loading_failed), + retryButtonText = stringResource(id = R.string.conversation_overview_loading_try_again), + onClickRetry = viewModel::requestReload + ) { conversationCollection -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AnimatedVisibility(modifier = Modifier.fillMaxWidth(), visible = !isConnected) { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Icons.Default.WifiOff, contentDescription = null) + + Text( + text = stringResource(id = R.string.conversation_overview_not_connected_banner), + style = MaterialTheme.typography.bodyMedium + ) + } } } - } - ConversationSearch( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - query = query, - updateQuery = viewModel::onUpdateQuery - ) + ConversationSearch( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + query = query, + updateQuery = viewModel::onUpdateQuery + ) - ConversationList( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel, - conversationCollections = conversationCollection, - onNavigateToConversation = { conversationId -> - viewModel.setConversationMessagesRead(conversationId) - onNavigateToConversation(conversationId) - }, - onToggleMarkAsFavourite = viewModel::markConversationAsFavorite, - onToggleHidden = viewModel::markConversationAsHidden, - onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, - onRequestAddChannel = onRequestAddChannel, - trailingContent = { - item { Divider() } - - item(key = KEY_BUTTON_SHOW_COC) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - OutlinedButton( - modifier = Modifier.align(Alignment.Center), - onClick = { showCodeOfConduct = true } + ConversationList( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + conversationCollections = conversationCollection, + onNavigateToConversation = { conversationId -> + viewModel.setConversationMessagesRead(conversationId) + onNavigateToConversation(conversationId) + }, + onToggleMarkAsFavourite = viewModel::markConversationAsFavorite, + onToggleHidden = viewModel::markConversationAsHidden, + onToggleMuted = viewModel::markConversationAsMuted, + onRequestCreatePersonalConversation = onRequestCreatePersonalConversation, + onRequestAddChannel = onRequestAddChannel, + trailingContent = { + item { Divider() } + + item(key = KEY_BUTTON_SHOW_COC) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) ) { - Text(text = stringResource(id = R.string.conversation_overview_button_show_code_of_conduct)) + OutlinedButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showCodeOfConduct = true } + ) { + Text(text = stringResource(id = R.string.conversation_overview_button_show_code_of_conduct)) + } } } } - } - ) + ) + } } + + ConversationFabMenu( + onCreateChat = onRequestCreatePersonalConversation, + onBrowseChannels = onRequestBrowseChannel, + onCreateChannel = onRequestAddChannel, + canCreateChannel = canCreateChannel + ) } if (showCodeOfConduct) { @@ -167,6 +192,73 @@ fun ConversationOverviewBody( } } +@Composable +fun ConversationFabMenu( + onCreateChat: () -> Unit, + onBrowseChannels: () -> Unit, + onCreateChannel: () -> Unit, + canCreateChannel: Boolean +) { + var expanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.BottomEnd + ) { + Box { + FloatingActionButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(56.dp) + ) { + Icon(imageVector = Icons.Default.AddComment, contentDescription = "Add conversation") + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = (12).dp) + ) { + DropdownMenuItem( + onClick = { + expanded = false + onCreateChat() + }, + text = { Text(stringResource(id = R.string.create_chat_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.ChatBubble, contentDescription = null) + } + ) + DropdownMenuItem( + onClick = { + expanded = false + onBrowseChannels() + }, + text = { Text(stringResource(id = R.string.browse_channels_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.Tag, contentDescription = null) + } + ) + if (canCreateChannel) { + DropdownMenuItem( + onClick = { + expanded = false + onCreateChannel() + }, + text = { Text(stringResource(id = R.string.create_channel_title)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.AddComment, contentDescription = null) + } + ) + } + } + } + } +} + + + @Composable private fun ConversationSearch( modifier: Modifier, @@ -180,14 +272,33 @@ private fun ConversationSearch( shape = RoundedCornerShape(10) ) ) { - BasicHintTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), - hint = stringResource(id = R.string.conversation_overview_search_hint), - value = query, - onValueChange = updateQuery, - maxLines = 1 - ) + Row(modifier = Modifier.fillMaxWidth()) { + BasicHintTextField( + modifier = Modifier + .weight(1f) + .padding(8.dp), + hint = stringResource(id = R.string.conversation_overview_search_hint), + value = query, + onValueChange = updateQuery, + maxLines = 1 + ) + + if (query.isNotEmpty()) { + IconButton( + onClick = { updateQuery("") }, + modifier = Modifier + .align(Alignment.CenterVertically) + .size(24.dp) + .padding(end = 5.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } + } } } + diff --git a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt index 800df2363..83bf39bb8 100644 --- a/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt +++ b/feature/metis/manage-conversations/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/ui/conversation/overview/ConversationOverviewViewModel.kt @@ -183,25 +183,51 @@ class ConversationOverviewViewModel( private val conversationsAsCollections: StateFlow> = combine( updatedConversations, - currentPreferences - ) { conversationsDataState, preferences -> + currentPreferences, + query + ) { conversationsDataState, preferences, query -> conversationsDataState.bind { conversations -> + val isFiltering = query.isNotBlank() + de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ConversationCollections( channels = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.channelsExpanded), + .filter { !it.filterPredicate("exercise") && !it.filterPredicate("lecture") && !it.filterPredicate("exam") } + .asCollection(isFiltering || preferences.generalsExpanded), + groupChats = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.groupChatsExpanded), + .asCollection(isFiltering || preferences.groupChatsExpanded), + directChats = conversations.filterNotHiddenNorFavourite() - .asCollection(preferences.personalConversationsExpanded), + .asCollection(isFiltering || preferences.personalConversationsExpanded), + favorites = conversations.filter { it.isFavorite } .asCollection(preferences.favouritesExpanded), + hidden = conversations.filter { it.isHidden } - .asCollection(preferences.hiddenExpanded) + .asCollection(preferences.hiddenExpanded), + + exerciseChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("exercise") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.exercisesExpanded, showPrefix = false), + + lectureChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("lecture") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.lecturesExpanded, showPrefix = false), + + examChannels = conversations.filter { + it is ChannelChat && !it.isFavorite && !it.isHidden && it.filterPredicate("exam") + }.map { it as ChannelChat } + .asCollection(isFiltering || preferences.examsExpanded, showPrefix = false) ) } } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) + + + /** * Holds the latest conversations we could successfully load. */ @@ -313,6 +339,24 @@ class ConversationOverviewViewModel( } } + fun markConversationAsMuted(conversationId: Long, muted: Boolean): Deferred { + return viewModelScope.async(coroutineContext) { + conversationService.markConversationMuted( + courseId, + conversationId, + muted, + accountService.authToken.first(), + serverConfigurationService.serverUrl.first() + ) + .onSuccess { isSuccessful -> + if (isSuccessful) { + onRequestReload.tryEmit(Unit) + } + } + .or(false) + } + } + fun markConversationAsFavorite(conversationId: Long, favorite: Boolean): Deferred { return viewModelScope.async(coroutineContext) { conversationService.markConversationAsFavorite( @@ -341,8 +385,20 @@ class ConversationOverviewViewModel( expandOrCollapseSection { copy(favouritesExpanded = !favouritesExpanded) } } - fun toggleChannelsExpanded() { - expandOrCollapseSection { copy(channelsExpanded = !channelsExpanded) } + fun toggleGeneralsExpanded() { + expandOrCollapseSection { copy(generalsExpanded = !generalsExpanded) } + } + + fun toggleExamsExpanded() { + expandOrCollapseSection { copy(examsExpanded = !examsExpanded) } + } + + fun toggleExercisesExpanded() { + expandOrCollapseSection { copy(exercisesExpanded = !exercisesExpanded) } + } + + fun toggleLecturesExpanded() { + expandOrCollapseSection { copy(lecturesExpanded = !lecturesExpanded) } } fun toggleGroupChatsExpanded() { @@ -373,6 +429,7 @@ class ConversationOverviewViewModel( } private fun List.asCollection( - isExpanded: Boolean - ) = ConversationCollection(this, isExpanded) + isExpanded: Boolean, + showPrefix: Boolean = true + ) = ConversationCollection(this, isExpanded, showPrefix) } diff --git a/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml b/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml index c0a744ce5..333e738dc 100644 --- a/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/browse_channels_strings.xml @@ -6,6 +6,8 @@ Loading channels… Something went wrong while loading the channels. Try again + Joined + Join There are no channels in this course you have not already joined. diff --git a/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml b/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml index f625beac5..e11c842a7 100644 --- a/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/conversation_overview_strings.xml @@ -8,8 +8,14 @@ Hide Unhide + Mute + Unmute + Favorites - Channels + General + Exercises + Lectures + Exams Group Chats Direct Messages Hidden diff --git a/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml b/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml index e054fc9ad..cd6e1d2c2 100644 --- a/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml +++ b/feature/metis/manage-conversations/src/main/res/values/create_channel_strings.xml @@ -1,6 +1,7 @@ Create channel + Create chat A channel is a way to group people together around a project, a topic, or just for fun. You can create as many channels as you want. You will become the first channel moderator. You will not be able to leave the channel. Name @@ -10,12 +11,12 @@ Description (optional) What\'s this channel about? - Private Channel / Public Channel + Private Channel? Every user except instructors will need an invitation to join a private channel. Everybody can join a public channel. Public Private - Announcement Channel + Announcement Channel? Only instructors and channel moderators can create new messages in an announcement channel. Students can only read the messages and answer to them. Announcement Channel Unrestricted Channel diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt index d7b5fd502..a40d62c6d 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/BrowseChannelsE2eTest.kt @@ -114,7 +114,6 @@ class BrowseChannelsE2eTest : ConversationBaseTest() { modifier = Modifier.fillMaxSize(), viewModel = viewModel, onNavigateToConversation = onNavigateToConversation, - onNavigateToCreateChannel = { }, onNavigateBack = {} ) } diff --git a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt index 51559f7ae..ceab5a47b 100644 --- a/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt +++ b/feature/metis/manage-conversations/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/manageconversations/overview/ConversationOverviewE2eTest.kt @@ -188,6 +188,42 @@ class ConversationOverviewE2eTest : ConversationBaseTest() { ) } + @Test(timeout = DefaultTestTimeoutMillis) + fun `can mark conversation as muted`() { + val chat = runBlocking { createPersonalConversation() } + + markConversationImpl( + originalTag = getTagForConversation(chat), + newTag = tagForConversation(chat.id, KEY_SUFFIX_PERSONAL), + textToClick = context.getString(R.string.conversation_overview_conversation_item_mark_as_muted), + checkExists = { conversations.any { conv -> conv.id == chat.id && conv.isMuted } } + ) + } + + @Test(timeout = DefaultTestTimeoutMillis) + fun `can mark hidden conversation as not muted`() { + val chat = runBlocking { + val chat = createPersonalConversation() + + conversationService.markConversationMuted( + courseId = course.id!!, + conversationId = chat.id, + muted = true, + authToken = accessToken, + serverUrl = testServerUrl + ).orThrow("Could not mark conversation as hidden") + + chat + } + + markConversationImpl( + originalTag = tagForConversation(chat.id, KEY_SUFFIX_PERSONAL), + newTag = getTagForConversation(chat), + textToClick = context.getString(R.string.conversation_overview_conversation_item_unmark_as_muted), + checkExists = { conversations.none { conv -> conv.id == chat.id && conv.isMuted } } + ) + } + /** * Checks that updates to conversations are automatically received over the websocket connection. */ @@ -359,12 +395,14 @@ class ConversationOverviewE2eTest : ConversationBaseTest() { viewModel = viewModel, onNavigateToConversation = {}, onRequestCreatePersonalConversation = { }, - onRequestAddChannel = {} + onRequestAddChannel = {}, + onRequestBrowseChannel = {}, + canCreateChannel = false ) } composeTestRule.waitUntilAtLeastOneExists( - hasText(context.getString(R.string.conversation_overview_section_channels)), + hasText(context.getString(R.string.conversation_overview_section_general_channels)), DefaultTimeoutMillis ) diff --git a/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt b/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt index b62d96d15..b4778efae 100644 --- a/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt +++ b/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt @@ -140,4 +140,12 @@ open class ConversationServiceStub( authToken: String, serverUrl: String ): NetworkResponse = NetworkResponse.Failure(StubException) + + override suspend fun markConversationMuted( + courseId: Long, + conversationId: Long, + muted: Boolean, + authToken: String, + serverUrl: String + ): NetworkResponse = NetworkResponse.Failure(StubException) } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt index 7e94e3bcf..9f2c006ff 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt @@ -5,6 +5,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.A import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable data class AnswerPost( @@ -19,9 +20,15 @@ data class AnswerPost( override val resolvesPost: Boolean = false, val post: StandalonePost? = null ) : BasePost(), IAnswerPost { + + @Transient override val authorId: Long? = author?.id - override val serverPostId: Long = id ?: 0L + @Transient + override val serverPostId: Long? = id + + @Transient + override val clientPostId: String? = null constructor(answerPostDb: AnswerPostPojo, post: StandalonePost) : this( id = answerPostDb.serverPostId, diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IAnswerPost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IAnswerPost.kt index b5675f4e5..a12a31f74 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IAnswerPost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IAnswerPost.kt @@ -1,6 +1,5 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto interface IAnswerPost : IBasePost { - val serverPostId: Long val resolvesPost: Boolean } \ 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/IBasePost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IBasePost.kt index 440c3e82b..e22c4da51 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IBasePost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IBasePost.kt @@ -10,4 +10,7 @@ sealed interface IBasePost { val updatedDate: Instant? val content: String? val reactions: List? + + val serverPostId: Long? + val clientPostId: String? } \ 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/IStandalonePost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IStandalonePost.kt index 0c9b660ba..b9d58bcce 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IStandalonePost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/IStandalonePost.kt @@ -3,7 +3,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content. import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.StandalonePostId interface IStandalonePost : IBasePost { - val serverPostId: Long val title: String? val answers: List? val tags: List? @@ -14,5 +13,5 @@ interface IStandalonePost : IBasePost { */ val key: Any - val standalonePostId: StandalonePostId + val standalonePostId: StandalonePostId? } \ 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/StandalonePost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/StandalonePost.kt index 5ba44c96f..bf8dbd29f 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/StandalonePost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/StandalonePost.kt @@ -46,11 +46,14 @@ data class StandalonePost( override val authorId: Long? = author?.id @Transient - override val serverPostId: Long = id ?: 0L + override val serverPostId: Long? = id @Transient override val key: Any = id ?: hashCode() @Transient - override val standalonePostId = StandalonePostId.ServerSideId(serverPostId) + override val standalonePostId = id?.let(StandalonePostId::ServerSideId) + + @Transient + override val clientPostId: String? = null } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt index a7378ff62..bc3736712 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/ChannelChat.kt @@ -15,6 +15,7 @@ data class ChannelChat( override val unreadMessagesCount: Long? = 0L, override val isFavorite: Boolean = false, override val isHidden: Boolean = false, + override val isMuted: Boolean = false, override val isCreator: Boolean = false, override val isMember: Boolean = false, override val numberOfMembers: Int = 0, @@ -34,5 +35,7 @@ data class ChannelChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = query in name + override fun filterPredicate(query: String): Boolean { + return name.contains(query, ignoreCase = true) + } } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt index 93a8aac66..b9d9596af 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt @@ -13,6 +13,7 @@ sealed class Conversation { abstract val unreadMessagesCount: Long? abstract val isFavorite: Boolean abstract val isHidden: Boolean + abstract val isMuted: Boolean abstract val isCreator: Boolean abstract val isMember: Boolean abstract val numberOfMembers: Int diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt index 478da6d83..a1337e044 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/GroupChat.kt @@ -16,6 +16,7 @@ data class GroupChat( override val unreadMessagesCount: Long? = 0L, override val isFavorite: Boolean = false, override val isHidden: Boolean = false, + override val isMuted: Boolean = false, override val isCreator: Boolean = false, override val isMember: Boolean = false, override val numberOfMembers: Int = 0, @@ -28,6 +29,9 @@ data class GroupChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = - if (name != null) query in name else query in humanReadableName + override fun filterPredicate(query: String): Boolean { + return (name!=null && name.contains(query, ignoreCase = true)) || + (humanReadableName.contains(query, ignoreCase = true)) + + } } diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt index 7684efdcb..a756463eb 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/OneToOneChat.kt @@ -16,6 +16,7 @@ data class OneToOneChat( override val unreadMessagesCount: Long? = 0L, override val isFavorite: Boolean = false, override val isHidden: Boolean = false, + override val isMuted: Boolean = false, override val isCreator: Boolean = false, override val isMember: Boolean = false, override val numberOfMembers: Int = 0, @@ -26,5 +27,7 @@ data class OneToOneChat( override fun withUnreadMessagesCount(unreadMessagesCount: Long): Conversation = copy(unreadMessagesCount = unreadMessagesCount) - override fun filterPredicate(query: String): Boolean = query in humanReadableTitle + override fun filterPredicate(query: String): Boolean { + return humanReadableTitle.contains(query, ignoreCase = true) + } } 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 cf1c047e9..129ae15ca 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 @@ -15,6 +15,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entiti 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.StandalonePostTagEntity import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.entities.StandalonePostingEntity +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.flow.Flow import kotlinx.datetime.Instant @@ -59,6 +60,14 @@ interface MetisDao { @Query("select exists(select * from metis_post_context where client_post_id = :clientPostId and server_post_id = :serverPostId and course_id = :courseId and conversation_id = :conversationId)") suspend fun isPostPresentInContext( clientPostId: String, + serverPostId: Long?, + courseId: Long, + conversationId: Long + ): Boolean + + @Query("select exists(select * from metis_post_context where server_post_id = :serverPostId and course_id = :courseId and conversation_id = :conversationId and server_id = :serverId)") + suspend fun isPostPresentInContext( + serverId: String, serverPostId: Long, courseId: Long, conversationId: Long @@ -114,6 +123,9 @@ interface MetisDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertUsers(users: List) + @Query("update metis_post_context set server_post_id = :serverSidePostId where client_post_id = :clientSidePostId") + suspend fun upgradePost(clientSidePostId: String, serverSidePostId: Long) + @Query( """ delete from metis_post_context where @@ -128,9 +140,14 @@ interface MetisDao { postingType: BasePostingEntity.PostingType = BasePostingEntity.PostingType.STANDALONE ) + @Query("delete from metis_post_context where client_post_id = :clientPostId") + suspend fun deletePostingWithClientSideId( + clientPostId: String + ) + @Query(""" delete from metis_post_context where metis_post_context.server_id = :host and metis_post_context.course_id = :courseId and metis_post_context.conversation_id = :conversationId - and metis_post_context.server_post_id not in (:serverIds) and metis_post_context.type = :postingType + and metis_post_context.server_post_id not in (:serverIds) and metis_post_context.server_post_id is not null and metis_post_context.type = :postingType and exists ( select * from postings p where p.creation_date > :startInstant and p.creation_date < :endInstant and p.id = metis_post_context.client_post_id @@ -271,14 +288,16 @@ interface MetisDao { p.type = 'STANDALONE' and sp.post_id = p.id and u.server_id = mpc.server_id and - u.id = p.author_id + u.id = p.author_id and + (:allowClientSidePost or mpc.server_post_id is not null) order by p.creation_date desc limit 1 """) fun queryLatestKnownPost( serverId: String, courseId: Long, - conversationId: Long + conversationId: Long, + allowClientSidePost: Boolean ): Flow @Transaction diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/BasePostingEntity.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/BasePostingEntity.kt index c6c74079b..2736b6d2b 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/BasePostingEntity.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/BasePostingEntity.kt @@ -36,7 +36,7 @@ data class BasePostingEntity( @ColumnInfo(name = "content") val content: String?, @ColumnInfo(name = "author_role") - val authorRole: UserRole + val authorRole: UserRole? ) { enum class CourseWideContext { @ColumnInfo(name = "tech_support") diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/MetisPostContextEntity.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/MetisPostContextEntity.kt index 66135d888..bc85bdaab 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/MetisPostContextEntity.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/entities/MetisPostContextEntity.kt @@ -9,10 +9,12 @@ import androidx.room.Index * Defines the relation between the client-side unique post id and server side context. * This class exists to support a fully offline applications, where posts can be created even when no internet connection is available. * For this, a client id is utilized which allows the creation of posts even though no server side id is known yet. + * + * A post which has a [serverPostId] of null, is purely client side and has not been sent yet. */ @Entity( tableName = "metis_post_context", - primaryKeys = ["client_post_id", "server_post_id", "course_id", "conversation_id", "type"], + primaryKeys = ["client_post_id", "course_id", "conversation_id", "type"], foreignKeys = [ ForeignKey( entity = BasePostingEntity::class, @@ -31,7 +33,7 @@ data class MetisPostContextEntity( @ColumnInfo(name = "conversation_id") val conversationId: Long, @ColumnInfo(name = "server_post_id") // a standalone post and a reply may have the same id - val serverPostId: Long, + val serverPostId: Long?, @ColumnInfo(name = "client_post_id") val clientPostId: String, @ColumnInfo(name = "type") diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/AnswerPostPojo.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/AnswerPostPojo.kt index d2849ce17..079904d83 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/AnswerPostPojo.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/AnswerPostPojo.kt @@ -36,7 +36,7 @@ data class AnswerPostPojo( entity = MetisPostContextEntity::class, entityColumn = "client_post_id", parentColumn = "post_id", - projection = ["server_post_id"] + projection = ["client_post_id", "server_post_id"] ) val serverPostIdCache: ServerPostIdCache ) : IAnswerPost { @@ -59,7 +59,10 @@ data class AnswerPostPojo( override val authorId: Long = basePostingCache.authorId @Ignore - override val serverPostId: Long = serverPostIdCache.serverPostId + override val serverPostId: Long? = serverPostIdCache.serverPostId + + @Ignore + override val clientPostId: String = postId data class BasePostingCache( @ColumnInfo(name = "id") @@ -85,6 +88,6 @@ data class AnswerPostPojo( data class ServerPostIdCache( @ColumnInfo(name = "server_post_id") - val serverPostId: Long + val serverPostId: Long? ) } \ 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/db/pojo/PostPojo.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/PostPojo.kt index 665943299..a7626f246 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/PostPojo.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/db/pojo/PostPojo.kt @@ -16,9 +16,9 @@ import kotlinx.datetime.Instant data class PostPojo( @ColumnInfo(name = "client_post_id") - val clientPostId: String, + override val clientPostId: String, @ColumnInfo(name = "server_post_id") - override val serverPostId: Long, + override val serverPostId: Long?, @ColumnInfo(name = "title") override val title: String?, @ColumnInfo(name = "content") diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt index c60b1967a..f905a97ab 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt @@ -132,6 +132,14 @@ interface ConversationService { authToken: String, serverUrl: String ): NetworkResponse + + suspend fun markConversationMuted( + courseId: Long, + conversationId: Long, + muted: Boolean, + authToken: String, + serverUrl: String + ): NetworkResponse } suspend fun ConversationService.getConversation( diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt index e21a17256..3cec1c73b 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt @@ -372,6 +372,24 @@ class ConversationServiceImpl(private val ktorProvider: KtorProvider) : Conversa appendPathSegments("favorite") } + override suspend fun markConversationMuted( + courseId: Long, + conversationId: Long, + muted: Boolean, + authToken: String, + serverUrl: String + ) = performActionOnConversation( + courseId, + conversationId, + authToken = authToken, + serverUrl = serverUrl, + httpRequestBlock = { + parameter("isMuted", muted) + } + ) { + appendPathSegments("muted") + } + private suspend fun performActionOnUser( courseId: Long, conversation: Conversation, diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt index 21fbc2cd2..973363b8f 100644 --- a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/ConversationFacadeUi.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import de.tum.informatics.www1.artemis.native_app.feature.metis.codeofconduct.ui.CodeOfConductFacadeUi +import org.koin.compose.koinInject /** * Displays the conversation ui. If the code of conduct has not yet been accepted, displays a code @@ -22,7 +23,12 @@ fun ConversationFacadeUi( SinglePageConversationBody( modifier = Modifier.fillMaxSize(), courseId = courseId, - initialConfiguration = initialConfiguration + initialConfiguration = initialConfiguration, + accountService = koinInject(), + accountDataService = koinInject(), + courseService = koinInject(), + networkStatusProvider = koinInject(), + serverConfigurationService = koinInject() ) } ) diff --git a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt index 4feabe428..cfe7a811b 100644 --- a/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt +++ b/feature/metis/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/ui/SinglePageConversationBody.kt @@ -4,12 +4,23 @@ import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.core.common.flatMapLatest +import de.tum.informatics.www1.artemis.native_app.core.data.retryOnInternet +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.CourseService +import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService +import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService +import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken +import de.tum.informatics.www1.artemis.native_app.core.device.NetworkStatusProvider +import de.tum.informatics.www1.artemis.native_app.core.model.account.isAtLeastTutorInCourse import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ConversationScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.browse_channels.BrowseChannelsScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.create_channel.CreateChannelScreen @@ -19,13 +30,20 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversati import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.members.ConversationMembersScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.manageconversations.ui.conversation.settings.overview.ConversationSettingsScreen import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.StandalonePostId +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @Composable internal fun SinglePageConversationBody( modifier: Modifier, courseId: Long, - initialConfiguration: ConversationConfiguration = NothingOpened + initialConfiguration: ConversationConfiguration = NothingOpened, + accountService: AccountService, + serverConfigurationService: ServerConfigurationService, + courseService: CourseService, + accountDataService: AccountDataService, + networkStatusProvider: NetworkStatusProvider ) { var configuration: ConversationConfiguration by rememberSaveable(initialConfiguration) { mutableStateOf(initialConfiguration) @@ -38,10 +56,36 @@ internal fun SinglePageConversationBody( } } + var canCreateChannel by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(courseId) { + coroutineScope.launch { + val flow = flatMapLatest( + serverConfigurationService.serverUrl, + accountService.authToken + ) { serverUrl, authToken -> + retryOnInternet(networkStatusProvider.currentNetworkStatus) { + courseService.getCourse(courseId, serverUrl, authToken) + .then { courseWithScore -> + accountDataService + .getAccountData(serverUrl, authToken) + .bind { it.isAtLeastTutorInCourse(courseWithScore.course) } + } + }.map { it.orElse(false) } + } + + flow.collect { value -> + canCreateChannel = value + } + } + } + BackHandler(configuration != NothingOpened) { when (val config = configuration) { is ConversationSettings -> configuration = config.prevConfiguration is AddChannelConfiguration -> configuration = config.prevConfiguration + is BrowseChannelConfiguration -> configuration = config.prevConfiguration is CreatePersonalConversation -> configuration = config.prevConfiguration is OpenedConversation -> configuration = if (config.openedThread != null) config.copy(openedThread = null) else NothingOpened @@ -51,7 +95,7 @@ internal fun SinglePageConversationBody( } } - val ConversationOverview: @Composable (Modifier) -> Unit = { m -> + val conversationOverview: @Composable (Modifier) -> Unit = { m -> ConversationOverviewBody( modifier = m.padding(top = 16.dp), courseId = courseId, @@ -60,14 +104,20 @@ internal fun SinglePageConversationBody( configuration = CreatePersonalConversation(configuration) }, onRequestAddChannel = { - configuration = AddChannelConfiguration(false, configuration) - } + if (canCreateChannel) { + configuration = AddChannelConfiguration(configuration) + } + }, + onRequestBrowseChannel = { + configuration = BrowseChannelConfiguration(configuration) + }, + canCreateChannel = canCreateChannel ) } when (val config = configuration) { NothingOpened -> { - ConversationOverview(modifier) + conversationOverview(modifier) } is OpenedConversation -> { @@ -94,28 +144,26 @@ internal fun SinglePageConversationBody( prevConfiguration = config ) }, - conversationsOverview = { mod -> ConversationOverview(mod) } + conversationsOverview = { mod -> conversationOverview(mod) } + ) + } + + is BrowseChannelConfiguration -> { + BrowseChannelsScreen( + modifier = modifier, + courseId = courseId, + onNavigateToConversation = openConversation, + //onNavigateToCreateChannel = {}, + onNavigateBack = { configuration = config.prevConfiguration } ) } is AddChannelConfiguration -> { - if (config.isCreatingChannel) { + if (canCreateChannel) { CreateChannelScreen( modifier = modifier, courseId = courseId, onConversationCreated = openConversation, - onNavigateBack = { - configuration = AddChannelConfiguration(false, config.prevConfiguration) - } - ) - } else { - BrowseChannelsScreen( - modifier = modifier, - courseId = courseId, - onNavigateToConversation = openConversation, - onNavigateToCreateChannel = { - configuration = AddChannelConfiguration(true, config.prevConfiguration) - }, onNavigateBack = { configuration = config.prevConfiguration } ) } @@ -189,6 +237,7 @@ internal fun SinglePageConversationBody( } } + @Parcelize sealed interface ConversationConfiguration : Parcelable @@ -211,7 +260,12 @@ data class NavigateToUserConversation(val username: String) : ConversationConfig @Parcelize private data class AddChannelConfiguration( - val isCreatingChannel: Boolean, + val prevConfiguration: ConversationConfiguration +) : + ConversationConfiguration + +@Parcelize +private data class BrowseChannelConfiguration( val prevConfiguration: ConversationConfiguration ) : ConversationConfiguration diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisFirebaseMessagingService.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisFirebaseMessagingService.kt index bff6c5cf1..c44174832 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisFirebaseMessagingService.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisFirebaseMessagingService.kt @@ -1,33 +1,19 @@ package de.tum.informatics.www1.artemis.native_app.feature.push -import android.util.Base64 import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import de.tum.informatics.www1.artemis.native_app.core.common.CurrentActivityListener import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleMetisContext -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleMetisContextReporter -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleStandalonePostDetails -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.NotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.communicationType -import de.tum.informatics.www1.artemis.native_app.feature.push.service.NotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationCipher import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationConfigurationService import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationHandler import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationJobService -import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.NotificationTargetManager import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json import org.koin.android.ext.android.get import java.security.NoSuchAlgorithmException import javax.crypto.Cipher import javax.crypto.NoSuchPaddingException -import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec class ArtemisFirebaseMessagingService : FirebaseMessagingService() { diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationChannel.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationChannel.kt deleted file mode 100644 index 307ef756c..000000000 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/ArtemisNotificationChannel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.feature.push - -import android.app.NotificationManager -import androidx.annotation.StringRes - -enum class ArtemisNotificationChannel( - val id: String, - @StringRes val title: Int, - @StringRes val description: Int, - val importance: Int = NotificationManager.IMPORTANCE_DEFAULT -) { - MiscNotificationChannel( - "misc-notification-channel", - R.string.push_notification_channel_misc_name, - R.string.push_notification_channel_misc_description, - NotificationManager.IMPORTANCE_DEFAULT - ), - CourseAnnouncementNotificationChannel( - "course-announcement-notification-channel", - R.string.push_notification_channel_course_announcement_name, - R.string.push_notification_channel_course_announcement_description, - importance = NotificationManager.IMPORTANCE_HIGH - ), - CoursePostNotificationChannel( - "course-post-notification-channel", - R.string.push_notification_channel_course_post_name, - R.string.push_notification_channel_course_post_description, - NotificationManager.IMPORTANCE_HIGH - ), - ExercisePostNotificationChannel( - "exercise-post-notification-channel", - R.string.push_notification_channel_exercise_post_name, - R.string.push_notification_channel_exercise_post_description, - NotificationManager.IMPORTANCE_HIGH - ), - LecturePostNotificationChannel( - "lecture-post-notification-channel", - R.string.push_notification_channel_lecture_post_name, - R.string.push_notification_channel_lecture_post_description, - NotificationManager.IMPORTANCE_HIGH - ), - CommunicationNotificationChannel( - "communication-notification-channel", - R.string.push_notification_channel_communication_name, - R.string.push_notification_channel_communication_description, - NotificationManager.IMPORTANCE_HIGH - ) -} \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationMessageEntity.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationMessageEntity.kt index 17e20b3f2..044122267 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationMessageEntity.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationMessageEntity.kt @@ -12,15 +12,14 @@ import kotlinx.datetime.Instant foreignKeys = [ ForeignKey( entity = PushCommunicationEntity::class, - parentColumns = ["parent_id", "type"], - childColumns = ["communication_parent_id", "communication_type"], + parentColumns = ["parent_id"], + childColumns = ["communication_parent_id"], onDelete = ForeignKey.CASCADE ) ], indices = [ Index( "communication_parent_id", - "communication_type", name = "i_communication_parent_id_communication_type" ) ] @@ -31,8 +30,6 @@ data class CommunicationMessageEntity( val id: Long = 0, @ColumnInfo(name = "communication_parent_id") val communicationParentId: Long, - @ColumnInfo(name = "communication_type") - val communicationType: CommunicationType, @ColumnInfo(name = "title") val title: String?, @ColumnInfo(name = "text") diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationType.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationType.kt deleted file mode 100644 index 4cd205e34..000000000 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/CommunicationType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model - -import androidx.annotation.DrawableRes -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel -import de.tum.informatics.www1.artemis.native_app.feature.push.R - -enum class CommunicationType(val notificationChannel: ArtemisNotificationChannel, @DrawableRes val notificationIcon: Int) { - ANNOUNCEMENT(ArtemisNotificationChannel.CourseAnnouncementNotificationChannel, R.drawable.baseline_campaign_24), - QNA_COURSE(ArtemisNotificationChannel.CoursePostNotificationChannel, R.drawable.baseline_contact_support_24), - QNA_EXERCISE(ArtemisNotificationChannel.ExercisePostNotificationChannel, R.drawable.baseline_contact_support_24), - QNA_LECTURE(ArtemisNotificationChannel.LecturePostNotificationChannel, R.drawable.baseline_contact_support_24), - CONVERSATION(ArtemisNotificationChannel.CommunicationNotificationChannel, R.drawable.baseline_chat_bubble_24) -} \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationDao.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationDao.kt index bbc931738..af12d6fdb 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationDao.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationDao.kt @@ -1,5 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model +import android.util.Log import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -7,26 +8,27 @@ import androidx.room.Query import androidx.room.Transaction import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ConversationNotificationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ReplyPostCommunicationNotificationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.StandalonePostCommunicationNotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.communicationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.parentId import kotlinx.datetime.Instant @Dao interface PushCommunicationDao { - @Query("select exists(select * from push_communication where parent_id = :parentId and type = :type)") - suspend fun hasPushCommunication(parentId: Long, type: CommunicationType): Boolean + companion object { + private const val TAG = "PushCommunicationDao" + } + + @Query("select exists(select * from push_communication where parent_id = :parentId)") + suspend fun hasPushCommunication(parentId: Long): Boolean @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertPushCommunication(pushCommunicationEntity: PushCommunicationEntity) - @Query("update push_communication set course_title = :courseTitle, title = :title where parent_id = :parentId and type = :type") + @Query("update push_communication set course_title = :courseTitle, title = :title where parent_id = :parentId") suspend fun updatePushCommunication( parentId: Long, - type: CommunicationType, courseTitle: String, title: String? ) @@ -40,96 +42,72 @@ interface PushCommunicationDao { generateNotificationId: suspend () -> Int ) { val parentId = artemisNotification.parentId - val type = artemisNotification.communicationType - val courseTitle: String = artemisNotification.notificationPlaceholders[0] - val postTitle: String? = when (artemisNotification.type) { - is ConversationNotificationType -> null - else -> artemisNotification.notificationPlaceholders[1] - } + val message: CommunicationMessageEntity = try { - val postContent: String = when (artemisNotification.type) { - is ConversationNotificationType -> artemisNotification.notificationPlaceholders[1] - else -> artemisNotification.notificationPlaceholders[2] - } - // val postCreationDate: String = artemisNotification.notificationPlaceholders[3] - val postAuthor: String = when (artemisNotification.type) { - ConversationNotificationType.CONVERSATION_NEW_MESSAGE -> when (artemisNotification.notificationPlaceholders[5]) { - "channel" -> artemisNotification.notificationPlaceholders[4] - else -> artemisNotification.notificationPlaceholders[3] - } - - ConversationNotificationType.CONVERSATION_NEW_REPLY_MESSAGE -> artemisNotification.notificationPlaceholders[3] - else -> artemisNotification.notificationPlaceholders[4] - } + val courseTitle: String = artemisNotification.notificationPlaceholders[0] + val postTitle: String? = null - val containerTitle: String? = when (artemisNotification.type) { - StandalonePostCommunicationNotificationType.NEW_COURSE_POST, - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_COURSE_POST, - StandalonePostCommunicationNotificationType.NEW_ANNOUNCEMENT_POST -> null + val postContent: String = artemisNotification.notificationPlaceholders[1] - StandalonePostCommunicationNotificationType.NEW_EXERCISE_POST, StandalonePostCommunicationNotificationType.NEW_LECTURE_POST -> - artemisNotification.notificationPlaceholders[5] + val postAuthor: String = when (artemisNotification.type) { + is StandalonePostCommunicationNotificationType -> when (artemisNotification.notificationPlaceholders[5]) { + "channel" -> artemisNotification.notificationPlaceholders[4] + else -> artemisNotification.notificationPlaceholders[3] + } - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_EXERCISE_POST, - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_LECTURE_POST -> artemisNotification.notificationPlaceholders[8] + is ReplyPostCommunicationNotificationType -> artemisNotification.notificationPlaceholders[3] + } - ConversationNotificationType.CONVERSATION_NEW_MESSAGE -> { - when (artemisNotification.notificationPlaceholders[5]) { + val containerTitle: String = when (artemisNotification.type) { + is StandalonePostCommunicationNotificationType -> when (artemisNotification.notificationPlaceholders[5]) { "channel" -> artemisNotification.notificationPlaceholders[3] else -> artemisNotification.notificationPlaceholders[4] } - } - - ConversationNotificationType.CONVERSATION_NEW_REPLY_MESSAGE -> artemisNotification.notificationPlaceholders[7] - } - - if (hasPushCommunication(parentId, type)) { - updatePushCommunication(parentId, type, courseTitle, postTitle) - } else { - val pushCommunicationEntity = PushCommunicationEntity( - parentId = parentId, - type = type, - notificationId = generateNotificationId(), - courseTitle = courseTitle, - containerTitle = containerTitle, - title = postTitle, - target = artemisNotification.target - ) - insertPushCommunication(pushCommunicationEntity) - } + is ReplyPostCommunicationNotificationType -> artemisNotification.notificationPlaceholders[7] + } - val message: CommunicationMessageEntity = when (artemisNotification.type) { - is StandalonePostCommunicationNotificationType, ConversationNotificationType.CONVERSATION_NEW_MESSAGE -> CommunicationMessageEntity( - communicationParentId = parentId, - communicationType = type, - title = postTitle, - text = postContent, - authorName = postAuthor, - date = artemisNotification.date - ) + if (hasPushCommunication(parentId)) { + updatePushCommunication(parentId, courseTitle, postTitle) + } else { + val pushCommunicationEntity = PushCommunicationEntity( + parentId = parentId, + notificationId = generateNotificationId(), + courseTitle = courseTitle, + containerTitle = containerTitle, + title = postTitle, + target = artemisNotification.target + ) - is ReplyPostCommunicationNotificationType, ConversationNotificationType.CONVERSATION_NEW_REPLY_MESSAGE -> { - val answerPostContent = when (artemisNotification.type) { - is ReplyPostCommunicationNotificationType -> artemisNotification.notificationPlaceholders[5] - else -> artemisNotification.notificationPlaceholders[1] - } - // val answerPostCreationDate = artemisNotification.notificationPlaceholders[6] - val answerPostAuthor = when (artemisNotification.type) { - is ReplyPostCommunicationNotificationType -> artemisNotification.notificationPlaceholders[7] - else -> artemisNotification.notificationPlaceholders[3] - } + insertPushCommunication(pushCommunicationEntity) + } - CommunicationMessageEntity( + when (artemisNotification.type) { + is StandalonePostCommunicationNotificationType -> CommunicationMessageEntity( communicationParentId = parentId, - communicationType = type, - title = null, - text = answerPostContent, - authorName = answerPostAuthor, + title = postTitle, + text = postContent, + authorName = postAuthor, date = artemisNotification.date ) + + is ReplyPostCommunicationNotificationType -> { + val answerPostContent = artemisNotification.notificationPlaceholders[4] + val answerPostAuthor = artemisNotification.notificationPlaceholders[6] + + CommunicationMessageEntity( + communicationParentId = parentId, + title = null, + text = answerPostContent, + authorName = answerPostAuthor, + date = artemisNotification.date + ) + } } + } catch (e: Exception) { + Log.e(TAG, "Error while parsing artemis communication notification", e) + return } insertCommunicationMessage(message) @@ -138,16 +116,14 @@ interface PushCommunicationDao { @Transaction suspend fun insertSelfMessage( parentId: Long, - type: CommunicationType, authorName: String, body: String, date: Instant ) { - if (hasPushCommunication(parentId, type)) { + if (hasPushCommunication(parentId)) { insertCommunicationMessage( CommunicationMessageEntity( communicationParentId = parentId, - communicationType = type, title = null, text = body, authorName = authorName, @@ -157,15 +133,14 @@ interface PushCommunicationDao { } } - @Query("select * from push_communication where parent_id = :parentId and type = :type") - suspend fun getCommunication(parentId: Long, type: CommunicationType): PushCommunicationEntity + @Query("select * from push_communication where parent_id = :parentId") + suspend fun getCommunication(parentId: Long): PushCommunicationEntity - @Query("select * from push_communication_message where communication_parent_id = :parentId and communication_type = :type order by id asc") + @Query("select * from push_communication_message where communication_parent_id = :parentId order by id asc") suspend fun getCommunicationMessages( - parentId: Long, - type: CommunicationType + parentId: Long ): List - @Query("delete from push_communication where parent_id = :parentId and type = :type") - suspend fun deleteCommunication(parentId: Long, type: CommunicationType) + @Query("delete from push_communication where parent_id = :parentId") + suspend fun deleteCommunication(parentId: Long) } \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationEntity.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationEntity.kt index 27a63c397..36223c3c4 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationEntity.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/communication_notification_model/PushCommunicationEntity.kt @@ -5,19 +5,15 @@ import androidx.room.Entity /** * A communication grouping for push notifications. - * The [parentId] and [type] build the primary key. - * [parentId] may have multiple meanings based on the [type]: - * - [CommunicationType.QNA_COURSE], [CommunicationType.QNA_LECTURE], [CommunicationType.QNA_EXERCISE]: The id of the standalone post + * [parentId] is the id of the standalone post */ @Entity( tableName = "push_communication", - primaryKeys = ["parent_id", "type"] + primaryKeys = ["parent_id"] ) data class PushCommunicationEntity( @ColumnInfo(name = "parent_id") val parentId: Long, - @ColumnInfo(name = "type") - val type: CommunicationType, @ColumnInfo(name = "notification_id") val notificationId: Int, @ColumnInfo(name = "course_title") diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt index 502c2d21f..07d4e29d7 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/ArtemisNotification.kt @@ -1,6 +1,5 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.notification_model -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.NotificationTargetManager import kotlinx.datetime.Instant import kotlinx.serialization.DeserializationStrategy @@ -56,8 +55,7 @@ data class UnknownArtemisNotification( private val nameToTypeMapping: Map = ( StandalonePostCommunicationNotificationType.entries + ReplyPostCommunicationNotificationType.entries + - MiscNotificationType.entries + - ConversationNotificationType.entries + MiscNotificationType.entries ).associateBy { it.name } object ArtemisNotificationDeserializer : @@ -87,13 +85,5 @@ object CommunicationNotificationTypeDeserializer : } val ArtemisNotification.parentId: Long - get() = NotificationTargetManager.getCommunicationNotificationTarget( - type.communicationType, - target - ).postId + get() = NotificationTargetManager.getCommunicationNotificationTarget(target).postId -val ArtemisNotification.communicationType: CommunicationType - get() = when (type) { - is StandalonePostCommunicationNotificationType, is ReplyPostCommunicationNotificationType -> CommunicationType.QNA_COURSE - ConversationNotificationType.CONVERSATION_NEW_MESSAGE, ConversationNotificationType.CONVERSATION_NEW_REPLY_MESSAGE -> CommunicationType.CONVERSATION - } diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/NotificationType.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/NotificationType.kt index 817602458..841db8155 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/NotificationType.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/notification_model/NotificationType.kt @@ -2,7 +2,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.notification_mod import androidx.annotation.StringRes import de.tum.informatics.www1.artemis.native_app.feature.push.R -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import kotlinx.serialization.Serializable @Serializable @@ -16,34 +15,18 @@ enum class StandalonePostCommunicationNotificationType : CommunicationNotificati NEW_EXERCISE_POST, NEW_LECTURE_POST, NEW_COURSE_POST, - NEW_ANNOUNCEMENT_POST + NEW_ANNOUNCEMENT_POST, + CONVERSATION_NEW_MESSAGE } @Serializable enum class ReplyPostCommunicationNotificationType : CommunicationNotificationType { NEW_REPLY_FOR_EXERCISE_POST, NEW_REPLY_FOR_LECTURE_POST, - NEW_REPLY_FOR_COURSE_POST -} - -@Serializable -enum class ConversationNotificationType : CommunicationNotificationType { - CONVERSATION_NEW_MESSAGE, + NEW_REPLY_FOR_COURSE_POST, CONVERSATION_NEW_REPLY_MESSAGE } -val CommunicationNotificationType.communicationType: CommunicationType get() = when(this) { - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_EXERCISE_POST, - StandalonePostCommunicationNotificationType.NEW_EXERCISE_POST -> CommunicationType.QNA_EXERCISE - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_LECTURE_POST, - StandalonePostCommunicationNotificationType.NEW_LECTURE_POST -> CommunicationType.QNA_LECTURE - ReplyPostCommunicationNotificationType.NEW_REPLY_FOR_COURSE_POST, - StandalonePostCommunicationNotificationType.NEW_COURSE_POST -> CommunicationType.QNA_COURSE - StandalonePostCommunicationNotificationType.NEW_ANNOUNCEMENT_POST -> CommunicationType.ANNOUNCEMENT - is ConversationNotificationType -> CommunicationType.CONVERSATION -} - - @Serializable enum class MiscNotificationType(@StringRes val title: Int, @StringRes val body: Int) : NotificationType { EXERCISE_SUBMISSION_ASSESSED(R.string.push_notification_title_exerciseSubmissionAssessed, R.string.push_notification_text_exerciseSubmissionAssessed), @@ -79,4 +62,4 @@ enum class MiscNotificationType(@StringRes val title: Int, @StringRes val body: } @Serializable -object UnknownNotificationType : NotificationType \ No newline at end of file +data object UnknownNotificationType : NotificationType \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/push_module.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/push_module.kt index 3b1cc0536..a3b8f3520 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/push_module.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/push_module.kt @@ -15,7 +15,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.Work import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.CommunicationNotificationManagerImpl import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.MiscNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.NotificationManagerImpl -import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.ReplyWorker +import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.UpdateReplyNotificationWorker 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.PushNotificationSettingsViewModel @@ -49,7 +49,7 @@ val pushModule = module { workerOf(::UploadPushNotificationDeviceConfigurationWorker) workerOf(::UnsubscribeFromNotificationsWorker) - workerOf(::ReplyWorker) + workerOf(::UpdateReplyNotificationWorker) single { NotificationSettingsServiceImpl( diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt index 0f0345e46..3e326a225 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/CommunicationNotificationManager.kt @@ -1,6 +1,5 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.service -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType import kotlinx.datetime.Instant @@ -12,7 +11,6 @@ interface CommunicationNotificationManager { suspend fun addSelfMessage( parentId: Long, - type: CommunicationType, authorName: String, body: String, date: Instant @@ -22,12 +20,11 @@ interface CommunicationNotificationManager { * Reads the notification from the database and pops the notification */ suspend fun repopNotification( - parentId: Long, - communicationType: CommunicationType + parentId: Long ) /** * Deletes a communication and clears the notification from the notification tray. */ - suspend fun deleteCommunication(parentId: Long, type: CommunicationType) + suspend fun deleteCommunication(parentId: Long) } diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/PushNotificationHandlerImpl.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/PushNotificationHandlerImpl.kt index 247cbcb8c..9297b06b0 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/PushNotificationHandlerImpl.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/PushNotificationHandlerImpl.kt @@ -9,7 +9,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visibleme import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.NotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.communicationType import de.tum.informatics.www1.artemis.native_app.feature.push.service.NotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationHandler import de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager.NotificationTargetManager @@ -81,7 +80,6 @@ class PushNotificationHandlerImpl( val notificationType = notification.type if (notificationType is CommunicationNotificationType) { val metisTarget = NotificationTargetManager.getCommunicationNotificationTarget( - notificationType.communicationType, notification.target ) diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/WorkManagerPushNotificationJobService.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/WorkManagerPushNotificationJobService.kt index d65c791d1..4563f0b82 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/WorkManagerPushNotificationJobService.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/WorkManagerPushNotificationJobService.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager -import de.tum.informatics.www1.artemis.native_app.feature.push.defaultInternetWorkRequest +import de.tum.informatics.www1.artemis.native_app.core.common.defaultInternetWorkRequest import de.tum.informatics.www1.artemis.native_app.feature.push.service.PushNotificationJobService import kotlinx.coroutines.guava.await diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt index 30bc97733..1842ad6fd 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/CommunicationNotificationManagerImpl.kt @@ -9,17 +9,15 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.room.withTransaction +import de.tum.informatics.www1.artemis.native_app.core.common.ArtemisNotificationChannel import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PushNotificationArtemisMarkdownTransformer -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationManager +import de.tum.informatics.www1.artemis.native_app.core.datastore.ArtemisNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.PushCommunicationDatabaseProvider import de.tum.informatics.www1.artemis.native_app.feature.push.R import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationMessageEntity -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.PushCommunicationEntity import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.communicationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.parentId import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager import kotlinx.datetime.Instant @@ -42,16 +40,11 @@ internal class CommunicationNotificationManagerImpl( } val parentId = artemisNotification.parentId - val communicationType = artemisNotification.communicationType - val communication = dbProvider.pushCommunicationDao.getCommunication( - parentId, - communicationType - ) + val communication = dbProvider.pushCommunicationDao.getCommunication(parentId) val messages = dbProvider.pushCommunicationDao.getCommunicationMessages( - parentId, - communicationType + parentId ) communication to messages @@ -67,35 +60,24 @@ internal class CommunicationNotificationManagerImpl( */ override suspend fun addSelfMessage( parentId: Long, - type: CommunicationType, authorName: String, body: String, date: Instant ) { - dbProvider.pushCommunicationDao.insertSelfMessage(parentId, type, authorName, body, date) - repopNotification(parentId, type) + dbProvider.pushCommunicationDao.insertSelfMessage(parentId, authorName, body, date) + repopNotification(parentId) } override suspend fun repopNotification( - parentId: Long, - communicationType: CommunicationType + parentId: Long ) { val (communication, messages) = dbProvider.database.withTransaction { - if (!dbProvider.pushCommunicationDao.hasPushCommunication( - parentId, - communicationType - ) + if (!dbProvider.pushCommunicationDao.hasPushCommunication(parentId) ) return@withTransaction null to null - val communication = dbProvider.pushCommunicationDao.getCommunication( - parentId, - communicationType - ) + val communication = dbProvider.pushCommunicationDao.getCommunication(parentId) - val messages = dbProvider.pushCommunicationDao.getCommunicationMessages( - parentId, - communicationType - ) + val messages = dbProvider.pushCommunicationDao.getCommunicationMessages(parentId) communication to messages } @@ -109,16 +91,15 @@ internal class CommunicationNotificationManagerImpl( communication: PushCommunicationEntity, messages: List ) { - val notificationChannel: ArtemisNotificationChannel = communication.type.notificationChannel + val notificationChannel: ArtemisNotificationChannel = + ArtemisNotificationChannel.CommunicationNotificationChannel - val metisTarget = NotificationTargetManager.getCommunicationNotificationTarget( - communication.type, - communication.target - ) + val metisTarget = + NotificationTargetManager.getCommunicationNotificationTarget(communication.target) val notification = NotificationCompat.Builder(context, notificationChannel.id) .setStyle(buildMessagingStyle(communication, messages)) - .setSmallIcon(communication.type.notificationIcon) + .setSmallIcon(R.drawable.baseline_chat_bubble_24) .setAutoCancel(true) .setContentIntent( NotificationTargetManager.getMetisContentIntent( @@ -129,8 +110,7 @@ internal class CommunicationNotificationManagerImpl( .setDeleteIntent( constructDeletionIntent( context = context, - parentId = communication.parentId, - type = communication.type + parentId = communication.parentId ) ) .addAction( @@ -147,16 +127,13 @@ internal class CommunicationNotificationManagerImpl( } - override suspend fun deleteCommunication(parentId: Long, type: CommunicationType) { + override suspend fun deleteCommunication(parentId: Long) { val notificationId = dbProvider.database.withTransaction { - if (!dbProvider.pushCommunicationDao.hasPushCommunication( - parentId, - type - ) + if (!dbProvider.pushCommunicationDao.hasPushCommunication(parentId) ) return@withTransaction null - val communication = dbProvider.pushCommunicationDao.getCommunication(parentId, type) - dbProvider.pushCommunicationDao.deleteCommunication(parentId, type) + val communication = dbProvider.pushCommunicationDao.getCommunication(parentId) + dbProvider.pushCommunicationDao.deleteCommunication(parentId) communication.notificationId } @@ -171,12 +148,8 @@ internal class CommunicationNotificationManagerImpl( communication: PushCommunicationEntity, messages: List ): NotificationCompat.MessagingStyle { - val person = when (communication.type) { - CommunicationType.QNA_COURSE, CommunicationType.QNA_EXERCISE, CommunicationType.QNA_LECTURE, CommunicationType.ANNOUNCEMENT, CommunicationType.CONVERSATION -> { - val firstMessage = messages.first() - Person.Builder().setName(firstMessage.authorName).build() - } - } + val firstMessage = messages.first() + val person = Person.Builder().setName(firstMessage.authorName).build() val style = NotificationCompat.MessagingStyle(person) messages.forEach { message -> @@ -191,46 +164,20 @@ internal class CommunicationNotificationManagerImpl( ) } - when (communication.type) { - CommunicationType.QNA_COURSE, CommunicationType.QNA_EXERCISE, CommunicationType.QNA_LECTURE, CommunicationType.ANNOUNCEMENT, CommunicationType.CONVERSATION -> { - style.isGroupConversation = true - style.conversationTitle = when (communication.type) { - CommunicationType.QNA_COURSE, CommunicationType.ANNOUNCEMENT -> context.getString( - R.string.conversation_title_course_post, - communication.courseTitle, - communication.title - ) - - CommunicationType.QNA_EXERCISE -> context.getString( - R.string.conversation_title_exercise_post, - communication.courseTitle, - communication.containerTitle, - communication.title - ) - - CommunicationType.QNA_LECTURE -> context.getString( - R.string.conversation_title_lecture_post, - communication.courseTitle, - communication.containerTitle, - communication.title - ) - - CommunicationType.CONVERSATION -> if (communication.title != null) { - context.getString( - R.string.conversation_title_conversation_thread, - communication.courseTitle, - communication.containerTitle, - communication.title - ) - } else { - context.getString( - R.string.conversation_title_conversation, - communication.courseTitle, - communication.containerTitle - ) - } - } - } + style.isGroupConversation = true + style.conversationTitle = if (communication.title != null) { + context.getString( + R.string.conversation_title_conversation_thread, + communication.courseTitle, + communication.containerTitle, + communication.title + ) + } else { + context.getString( + R.string.conversation_title_conversation, + communication.courseTitle, + communication.containerTitle + ) } return style @@ -238,14 +185,12 @@ internal class CommunicationNotificationManagerImpl( private fun constructDeletionIntent( context: Context, - parentId: Long, - type: CommunicationType + parentId: Long ): PendingIntent = PendingIntent.getBroadcast( context, - (type.ordinal shl 16) + (parentId % Int.MAX_VALUE).toInt(), + (parentId % Int.MAX_VALUE).toInt(), Intent(context, DeleteNotificationReceiver::class.java) - .putExtra(DeleteNotificationReceiver.PARENT_ID, parentId) - .putExtra(DeleteNotificationReceiver.COMMUNICATION_TYPE, type.name), + .putExtra(DeleteNotificationReceiver.PARENT_ID, parentId), PendingIntent.FLAG_IMMUTABLE ) @@ -266,10 +211,6 @@ internal class CommunicationNotificationManagerImpl( ReplyReceiver.PARENT_ID, communication.parentId ) - putExtra( - ReplyReceiver.COMMUNICATION_TYPE, - communication.type.name - ) } val flags = if (Build.VERSION.SDK_INT >= 31) { @@ -301,7 +242,7 @@ internal class CommunicationNotificationManagerImpl( return NotificationCompat.Action.Builder( R.drawable.baseline_mark_chat_read_24, context.getString(R.string.push_notification_action_mark_as_read), - constructDeletionIntent(context, communication.parentId, communication.type) + constructDeletionIntent(context, communication.parentId) ) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) .build() diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/DeleteNotificationReceiver.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/DeleteNotificationReceiver.kt index 340d982ca..955a851f5 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/DeleteNotificationReceiver.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/DeleteNotificationReceiver.kt @@ -3,7 +3,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.not import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent @@ -13,18 +12,15 @@ class DeleteNotificationReceiver : BroadcastReceiver(), KoinComponent { companion object { const val PARENT_ID = "parent_id" - const val COMMUNICATION_TYPE = "communication_type" } override fun onReceive(context: Context, intent: Intent) { val parentId = intent.getLongExtra(PARENT_ID, 0) - val communicationType = - CommunicationType.valueOf(intent.getStringExtra(COMMUNICATION_TYPE) ?: return) val communicationNotificationManager: CommunicationNotificationManager = get() runBlocking { - communicationNotificationManager.deleteCommunication(parentId, communicationType) + communicationNotificationManager.deleteCommunication(parentId) } } } \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/MiscNotificationManager.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/MiscNotificationManager.kt index 45254102b..c980533c8 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/MiscNotificationManager.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/MiscNotificationManager.kt @@ -3,8 +3,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.not import android.content.Context import androidx.core.app.NotificationCompat import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationBuilder -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationManager +import de.tum.informatics.www1.artemis.native_app.core.common.ArtemisNotificationChannel +import de.tum.informatics.www1.artemis.native_app.core.datastore.ArtemisNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.R import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.MiscNotificationType diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt index 5a3934465..6fcac7dc7 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationManagerImpl.kt @@ -2,8 +2,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.not import android.content.Context import androidx.core.app.NotificationCompat -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationManager +import de.tum.informatics.www1.artemis.native_app.core.common.ArtemisNotificationChannel +import de.tum.informatics.www1.artemis.native_app.core.datastore.ArtemisNotificationManager import de.tum.informatics.www1.artemis.native_app.feature.push.R import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.ArtemisNotification import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationArtemisNotification diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationTargetManager.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationTargetManager.kt index 047e23223..c37a0bb7f 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationTargetManager.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/NotificationTargetManager.kt @@ -4,11 +4,9 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.CommunicationNotificationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.MiscNotificationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.NotificationType -import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.communicationType import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.target.CommunicationPostTarget import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.target.CoursePostTarget import de.tum.informatics.www1.artemis.native_app.feature.push.notification_model.target.ExercisePostTarget @@ -92,7 +90,7 @@ internal object NotificationTargetManager { private fun getNotificationTarget(type: NotificationType, target: String): NotificationTarget { return when (type) { - is CommunicationNotificationType -> getCommunicationNotificationTarget(type.communicationType, target) + is CommunicationNotificationType -> getCommunicationNotificationTarget(target) MiscNotificationType.QUIZ_EXERCISE_STARTED -> { json.decodeFromString(target) @@ -103,25 +101,9 @@ internal object NotificationTargetManager { } fun getCommunicationNotificationTarget( - type: CommunicationType, target: String ): MetisTarget { - return when (type) { - CommunicationType.QNA_COURSE, CommunicationType.ANNOUNCEMENT -> { - json.decodeFromString(target) - } - - CommunicationType.QNA_LECTURE -> { - json.decodeFromString(target) - } - - CommunicationType.QNA_EXERCISE -> { - json.decodeFromString(target) - } - CommunicationType.CONVERSATION -> { - json.decodeFromString(target) - } - } + return json.decodeFromString(target) } private fun buildOpenAppIntent(context: Context): PendingIntent = PendingIntent.getActivity( diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyReceiver.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyReceiver.kt index 0e2def209..274da5a99 100644 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyReceiver.kt +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyReceiver.kt @@ -1,13 +1,17 @@ package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.RemoteInput -import androidx.work.Data -import androidx.work.WorkManager -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType -import de.tum.informatics.www1.artemis.native_app.feature.push.defaultInternetWorkRequest +import androidx.room.Update +import androidx.room.withTransaction +import androidx.work.OneTimeWorkRequestBuilder +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.CreatePostService +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.work.BaseCreatePostWorker +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext +import de.tum.informatics.www1.artemis.native_app.feature.push.PushCommunicationDatabaseProvider import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent @@ -22,9 +26,9 @@ class ReplyReceiver : BroadcastReceiver(), KoinComponent { companion object { const val REPLY_INTENT_KEY = "reply_text_key" const val PARENT_ID = "parent_id" - const val COMMUNICATION_TYPE = "communication_type" } + @SuppressLint("EnqueueWork") override fun onReceive(context: Context, intent: Intent) { RemoteInput.getResultsFromIntent(intent)?.let { remoteInput -> val response = @@ -32,29 +36,51 @@ class ReplyReceiver : BroadcastReceiver(), KoinComponent { .toString() val parentId = intent.getLongExtra(PARENT_ID, 0) - val communicationType = intent.getStringExtra(COMMUNICATION_TYPE) ?: return@let - - if (response.isNotBlank()) { - val workRequest = defaultInternetWorkRequest( - Data.Builder() - .putLong(ReplyWorker.KEY_PARENT_ID, parentId) - .putString(ReplyWorker.KEY_COMMUNICATION_TYPE, communicationType) - .putString(ReplyWorker.KEY_REPLY_CONTENT, response) - .build() - ) - - WorkManager.getInstance(context) - .enqueue(workRequest) + + val pushCommunicationDatabaseProvider: PushCommunicationDatabaseProvider = get() + + val (metisContext: MetisContext, postId: Long) = runBlocking { + pushCommunicationDatabaseProvider.database.withTransaction { + val communication = + pushCommunicationDatabaseProvider.pushCommunicationDao.getCommunication(parentId) + + val metisTarget = NotificationTargetManager.getCommunicationNotificationTarget( + communication.target + ) + metisTarget.metisContext to metisTarget.postId + } + } + + if (response.isNotBlank() && metisContext is MetisContext.Conversation) { + val createPostService: CreatePostService = get() + createPostService.createAnswerPost( + metisContext.courseId, + metisContext.conversationId, + postId, + response + ) { clientSidePostId -> + then( + OneTimeWorkRequestBuilder() + .setInputData( + BaseCreatePostWorker.createWorkInput( + metisContext.courseId, + metisContext.conversationId, + clientSidePostId, + response, + postType = BaseCreatePostWorker.PostType.ANSWER_POST, + parentPostId = postId + ) + ) + .build() + ) + } } val communicationNotificationManager: CommunicationNotificationManager = get() // Repop the notification to tell the OS we handled the notification runBlocking { - communicationNotificationManager.repopNotification( - parentId = parentId, - communicationType = CommunicationType.valueOf(communicationType) - ) + communicationNotificationManager.repopNotification(parentId = parentId) } } } diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyWorker.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyWorker.kt deleted file mode 100644 index 604cccf24..000000000 --- a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/ReplyWorker.kt +++ /dev/null @@ -1,167 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager - -import android.content.Context -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.room.withTransaction -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse -import de.tum.informatics.www1.artemis.native_app.core.data.onSuccess -import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService -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.feature.metis.shared.content.MetisContext -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.getConversation -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationChannel -import de.tum.informatics.www1.artemis.native_app.feature.push.ArtemisNotificationManager -import de.tum.informatics.www1.artemis.native_app.feature.push.PushCommunicationDatabaseProvider -import de.tum.informatics.www1.artemis.native_app.feature.push.R -import de.tum.informatics.www1.artemis.native_app.feature.push.communication_notification_model.CommunicationType -import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager -import kotlinx.coroutines.flow.first -import kotlinx.datetime.Clock - -/** - * Worker that sends a reply to a Metis posting to the server. - * If the input is invalid, the worker fails. - * If the input is valid, but the reply could not be uploaded, the worker will schedule a retry. - * If the upload failed 5 times, the worker will fail and pop a notification to notify the user about the failure. - */ -internal class ReplyWorker( - appContext: Context, - params: WorkerParameters, - private val metisModificationService: de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisModificationService, - private val serverConfigurationService: ServerConfigurationService, - private val accountService: AccountService, - private val pushCommunicationDatabaseProvider: PushCommunicationDatabaseProvider, - private val communicationNotificationManager: CommunicationNotificationManager, - private val accountDataService: AccountDataService, - private val conversationService: de.tum.informatics.www1.artemis.native_app.feature.metis.shared.service.network.ConversationService -) : - CoroutineWorker(appContext, params) { - - companion object { - const val KEY_PARENT_ID = "parent_id" - const val KEY_COMMUNICATION_TYPE = "communication_type" - const val KEY_REPLY_CONTENT = "reply_content" - - private const val TAG = "ReplyWorker" - } - - override suspend fun doWork(): Result { - val parentId: Long = inputData.getLong(KEY_PARENT_ID, 0) - val communicationType: CommunicationType = CommunicationType.valueOf( - inputData.getString( - KEY_COMMUNICATION_TYPE - ) ?: return Result.failure() - ) - - val (metisContext: MetisContext, postId: Long) = pushCommunicationDatabaseProvider.database.withTransaction { - val communication = - pushCommunicationDatabaseProvider.pushCommunicationDao.getCommunication( - parentId, - communicationType - ) - - val metisTarget = NotificationTargetManager.getCommunicationNotificationTarget( - communication.type, - communication.target - ) - metisTarget.metisContext to metisTarget.postId - } - - val replyContent = inputData.getString(KEY_REPLY_CONTENT) ?: return Result.failure() - - val errorReturnType = if (runAttemptCount > 5) { - popFailureNotification(replyContent) - Result.failure() - } else Result.retry() - - return when (val authData = accountService.authenticationData.first()) { - is AccountService.AuthenticationData.LoggedIn -> { - val serverUrl = serverConfigurationService.serverUrl.first() - - val conversation = when (metisContext) { - is MetisContext.Conversation -> conversationService - .getConversation( - metisContext.courseId, - metisContext.conversationId, - authData.authToken, - serverUrl - ).orNull() ?: return errorReturnType - - else -> null - } - - val time = Clock.System.now() - - metisModificationService.createAnswerPost( - metisContext, - de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost( - content = replyContent, - post = de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost( - id = postId, - conversation = conversation - ), - creationDate = Clock.System.now() - ), - serverUrl = serverUrl, - authToken = authData.authToken - ) - .onSuccess { - // We can add to the notification that the user has responded. However, this does not have super high priority - val accountData = accountDataService.getAccountData( - serverUrl, - authData.authToken - ) - - if (accountData is NetworkResponse.Response) { - val authorName = - "${accountData.data.firstName} ${accountData.data.lastName}" - communicationNotificationManager.addSelfMessage( - parentId = parentId, - type = communicationType, - authorName = authorName, - body = replyContent, - date = time - ) - } - } - .bind { Result.success() } - .or(errorReturnType) - } - - AccountService.AuthenticationData.NotLoggedIn -> Result.failure() - } - } - - private suspend fun popFailureNotification(replyContent: String) { - val id = ArtemisNotificationManager.getNextNotificationId(applicationContext) - - val notification = - NotificationCompat.Builder( - applicationContext, - ArtemisNotificationChannel.MiscNotificationChannel.id - ) - .setSmallIcon(R.drawable.push_notification_icon) - .setContentTitle(applicationContext.getString(R.string.push_notification_send_reply_failed_title)) - .setContentText( - applicationContext.getString( - R.string.push_notification_send_reply_failed_message, - replyContent - ) - ) - .setAutoCancel(true) - .build() - - try { - NotificationManagerCompat - .from(applicationContext) - .notify(id, notification) - } catch (e: SecurityException) { - Log.e(TAG, "Could not push reply notification due to missing permission") - } - } -} \ No newline at end of file diff --git a/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/UpdateReplyNotificationWorker.kt b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/UpdateReplyNotificationWorker.kt new file mode 100644 index 000000000..60f044778 --- /dev/null +++ b/feature/push/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/push/service/impl/notification_manager/UpdateReplyNotificationWorker.kt @@ -0,0 +1,59 @@ +package de.tum.informatics.www1.artemis.native_app.feature.push.service.impl.notification_manager + +import android.content.Context +import androidx.work.WorkerParameters +import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse +import de.tum.informatics.www1.artemis.native_app.core.data.service.network.AccountDataService +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.feature.metis.conversation.work.BaseCreatePostWorker +import de.tum.informatics.www1.artemis.native_app.feature.push.service.CommunicationNotificationManager +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock + +class UpdateReplyNotificationWorker( + appContext: Context, + params: WorkerParameters, + private val serverConfigurationService: ServerConfigurationService, + private val accountService: AccountService, + private val accountDataService: AccountDataService, + private val communicationNotificationManager: CommunicationNotificationManager, +) : BaseCreatePostWorker(appContext, params) { + + override suspend fun doWork( + courseId: Long, + conversationId: Long, + clientSidePostId: String, + content: String, + postType: PostType, + parentPostId: Long? + ): Result { + val serverUrl = serverConfigurationService.serverUrl.first() + val authToken = accountService.authToken.first() + + // We can add to the notification that the user has responded. However, this does not have super high priority + val accountData = accountDataService.getAccountData( + serverUrl = serverUrl, + bearerToken = authToken + ) + + return when (accountData) { + is NetworkResponse.Response -> { + val authorName = + "${accountData.data.firstName} ${accountData.data.lastName}" + + communicationNotificationManager.addSelfMessage( + parentId = conversationId, + authorName = authorName, + body = content, + date = Clock.System.now() + ) + + Result.success() + } + + is NetworkResponse.Failure -> Result.failure() + } + } +} diff --git a/feature/push/src/main/res/values/push_notification_strings.xml b/feature/push/src/main/res/values/push_notification_strings.xml index 4117e07ab..7e4735e24 100644 --- a/feature/push/src/main/res/values/push_notification_strings.xml +++ b/feature/push/src/main/res/values/push_notification_strings.xml @@ -1,28 +1,8 @@ - Miscellaneous - All non-communication related notifications. - - Course Announcement - Instructors can create important course-wide announcements. - - Course Posts - New posts in courses and replies to course posts. - - Exercise Posts - New posts in exercises and replies to exercise posts. - - Lecture Posts - New posts in lectures and replies to lecture posts. - - Communication - New messages in channels, group chats and personal chats. - Mark as read Reply - Could not send reply - Affected reply: %1$s Attachment updated Exercise released diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d30a067a9..cc79d696a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ androidxPaging = "3.2.1" androidxPagingCompose = "3.2.1" coil = "2.4.0" emoji2 = "1.4.0" -kotlin = "1.9.20" +kotlin = "1.9.22" kotlinxCoroutines = "1.7.3" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.1" @@ -32,7 +32,7 @@ ossLicensesPlugin = "0.10.6" placeholderMaterial = "1.0.1" room = "2.6.0" sentry-android = "6.22.0" -work = "2.8.1" +work = "2.9.0" # Used indirecly in the build config -> Do not remove without double checking. androidxComposeCompiler = "1.5.4" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84a0b92f9..1e2fbf0d4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists