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