From 2f6e3d8697cc74ca7e8c45b2d4c0a48944441fe5 Mon Sep 17 00:00:00 2001 From: domonkosadam <53480952+domonkosadam@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:47:38 +0200 Subject: [PATCH] [MBL-17622][Parent] Inbox Details screen (#2565) refs: MBL-17622 affects: Parent release note: none --- .../ParentInboxDetailsInteractionTest.kt | 118 ++++ .../compose/ParentInboxComposeRepository.kt | 37 ++ .../features/inbox/list/ParentInboxRouter.kt | 10 +- .../parentapp/util/navigation/Navigation.kt | 45 +- .../compose/StudentInboxComposeRepository.kt | 27 + .../features/inbox/list/StudentInboxRouter.kt | 7 +- .../compose/TeacherInboxComposeRepository.kt | 27 + .../features/inbox/list/TeacherInboxRouter.kt | 5 + .../InboxDetailsInteractionTest.kt | 321 +++++++++ .../common/pages/compose/InboxComposePage.kt | 43 +- .../common/pages/compose/InboxDetailsPage.kt | 172 +++++ .../canvas/espresso/mockCanvas/MockCanvas.kt | 69 +- .../instructure/canvasapi2/apis/InboxApi.kt | 21 + .../canvasapi2/models/CanvasContext.kt | 9 +- .../src/main/res/drawable/ic_forward.xml | 10 + .../src/main/res/drawable/ic_reply.xml | 10 + .../src/main/res/drawable/ic_reply_all.xml | 10 + libs/pandares/src/main/res/values/strings.xml | 14 + .../inbox/compose/InboxComposeScreenTest.kt | 154 ++++- .../inbox/details/InboxDetailsScreenTest.kt | 342 ++++++++++ .../compose/composables/LabelSwitchRow.kt | 6 + .../compose/composables/LabelTextFieldRow.kt | 8 +- .../compose/composables/MultipleValuesRow.kt | 12 +- .../compose/composables/OverflowMenu.kt | 3 +- .../composables/TextFieldWithHeader.kt | 10 + .../instructure/pandautils/di/InboxModule.kt | 8 + .../inbox/compose/AttachmentCardItem.kt | 30 - .../inbox/compose/InboxComposeFragment.kt | 21 +- .../inbox/compose/InboxComposeRepository.kt | 17 + .../inbox/compose/InboxComposeUiState.kt | 12 + .../inbox/compose/InboxComposeViewModel.kt | 88 ++- .../compose/composables/ContextValueRow.kt | 6 +- .../compose/composables/InboxComposeScreen.kt | 278 ++++++-- .../composables/InboxComposeScreenWrapper.kt | 3 +- .../compose/composables/RecipientChip.kt | 5 + .../composables/RecipientPickerScreen.kt | 58 +- .../inbox/details/InboxDetailsFragment.kt | 133 ++++ .../inbox/details/InboxDetailsRepository.kt | 75 +++ .../inbox/details/InboxDetailsUiState.kt | 67 ++ .../inbox/details/InboxDetailsViewModel.kt | 244 +++++++ .../details/composables/InboxDetailsScreen.kt | 481 ++++++++++++++ .../details/composables/MessageMenuItem.kt | 52 ++ .../features/inbox/list/InboxFragment.kt | 4 + .../features/inbox/list/InboxRouter.kt | 3 + .../composables => utils}/AttachmentCard.kt | 103 +-- .../inbox/utils/AttachmentCardItem.kt | 46 ++ .../inbox/utils/InboxComposeOptions.kt | 179 +++++ .../inbox/utils/InboxMessageUiState.kt | 37 ++ .../features/inbox/utils/InboxMessageView.kt | 348 ++++++++++ .../pandautils/utils/StringExtensions.kt | 41 ++ .../compose/InboxComposeViewModelTest.kt | 110 +++- .../details/InboxDetailsRepositoryTest.kt | 207 ++++++ .../details/InboxDetailsViewModelTest.kt | 612 ++++++++++++++++++ .../inbox/utils/InboxComposeOptionsTest.kt | 226 +++++++ 54 files changed, 4790 insertions(+), 194 deletions(-) create mode 100644 apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/ParentInboxDetailsInteractionTest.kt create mode 100644 automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/InboxDetailsInteractionTest.kt create mode 100644 automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt create mode 100644 libs/pandares/src/main/res/drawable/ic_forward.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_reply.xml create mode 100644 libs/pandares/src/main/res/drawable/ic_reply_all.xml create mode 100644 libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/details/InboxDetailsScreenTest.kt delete mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/AttachmentCardItem.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsFragment.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepository.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsUiState.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModel.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/InboxDetailsScreen.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/MessageMenuItem.kt rename libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/{compose/composables => utils}/AttachmentCard.kt (66%) create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCardItem.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptions.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageUiState.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageView.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepositoryTest.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptionsTest.kt diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/ParentInboxDetailsInteractionTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/ParentInboxDetailsInteractionTest.kt new file mode 100644 index 0000000000..ab150402fd --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/ParentInboxDetailsInteractionTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.parentapp.ui.interaction + +import androidx.compose.ui.platform.ComposeView +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils +import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck +import com.instructure.canvas.espresso.common.interaction.InboxDetailsInteractionTest +import com.instructure.canvas.espresso.common.pages.InboxPage +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addConversation +import com.instructure.canvas.espresso.mockCanvas.addConversationWithMultipleMessages +import com.instructure.canvas.espresso.mockCanvas.addConversations +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.User +import com.instructure.parentapp.BuildConfig +import com.instructure.parentapp.features.login.LoginActivity +import com.instructure.parentapp.ui.pages.DashboardPage +import com.instructure.parentapp.utils.ParentActivityTestRule +import com.instructure.parentapp.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.hamcrest.Matchers + +@HiltAndroidTest +class ParentInboxDetailsInteractionTest: InboxDetailsInteractionTest() { + override val isTesting = BuildConfig.IS_TESTING + + override val activityRule = ParentActivityTestRule(LoginActivity::class.java) + + private val dashboardPage = DashboardPage() + private val inboxPage = InboxPage() + + override fun enableAndConfigureAccessibilityChecks() { + extraAccessibilitySupressions = Matchers.allOf( + AccessibilityCheckResultUtils.matchesCheck( + SpeakableTextPresentCheck::class.java + ), + AccessibilityCheckResultUtils.matchesViews( + ViewMatchers.withParent( + ViewMatchers.withClassName( + Matchers.equalTo(ComposeView::class.java.name) + ) + ) + ) + ) + + super.enableAndConfigureAccessibilityChecks() + } + + override fun goToInboxDetails(data: MockCanvas, conversationSubject: String) { + val parent = data.parents.first() + val token = data.tokenFor(parent)!! + tokenLogin(data.domain, token, parent) + + dashboardPage.openNavigationDrawer() + dashboardPage.clickInbox() + + inboxPage.openConversation(conversationSubject) + } + + override fun goToInboxDetails(data: MockCanvas, conversation: Conversation) { + val parent = data.parents.first() + val token = data.tokenFor(parent)!! + tokenLogin(data.domain, token, parent) + + dashboardPage.openNavigationDrawer() + dashboardPage.clickInbox() + + inboxPage.openConversation(conversation) + } + + override fun initData(): MockCanvas { + val data = MockCanvas.init( + parentCount = 1, + studentCount = 1, + teacherCount = 2, + courseCount = 1, + favoriteCourseCount = 1, + ) + MockCanvas.data.addConversations(conversationCount = 2, userId = 2, contextCode = "course_1", contextName = "Course 1") + MockCanvas.data.addConversationWithMultipleMessages(getTeachers().first().id, listOf(getLoggedInUser().id), 5) + + return data + } + + override fun getLoggedInUser(): User = MockCanvas.data.parents[0] + + override fun getTeachers(): List = MockCanvas.data.teachers + + override fun getConversations(data: MockCanvas): List { + return data.conversations.values.toList() + } + + override fun addNewConversation( + data: MockCanvas, + authorId: Long, + recipients: List, + messageSubject: String, + messageBody: String, + ): Conversation { + return data.addConversation(authorId, recipients, messageBody, messageSubject) + } +} \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/compose/ParentInboxComposeRepository.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/compose/ParentInboxComposeRepository.kt index 7e7d09d0c3..12441533a6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/compose/ParentInboxComposeRepository.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/compose/ParentInboxComposeRepository.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.parentapp.features.inbox.compose import com.instructure.canvasapi2.apis.CourseAPI @@ -11,6 +26,7 @@ import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.depaginate @@ -72,6 +88,27 @@ class ParentInboxComposeRepository( ) } + override suspend fun addMessage( + conversationId: Long, + recipients: List, + message: String, + includedMessages: List, + attachments: List, + context: CanvasContext, + ): DataResult { + val restParams = RestParams() + + return inboxAPI.addMessage( + conversationId = conversationId, + recipientIds = recipients.mapNotNull { it.stringId }, + body = message, + includedMessageIds = includedMessages.map { it.id }.toLongArray(), + attachmentIds = attachments.map { it.id }.toLongArray(), + contextCode = context.contextId, + params = restParams + ) + } + override suspend fun canSendToAll(context: CanvasContext): DataResult { val restParams = RestParams() val permissionResponse = courseAPI.getCoursePermissions(context.id, listOf(CanvasContextPermission.SEND_MESSAGES_ALL), restParams) diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt index 0d1ade3ed8..0273d650d4 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt @@ -22,6 +22,7 @@ import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.models.Conversation import com.instructure.pandautils.features.inbox.list.InboxRouter +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.parentapp.util.navigation.Navigation import org.greenrobot.eventbus.Subscribe @@ -30,7 +31,7 @@ import org.greenrobot.eventbus.Subscribe class ParentInboxRouter(private val activity: FragmentActivity, private val navigation: Navigation) : InboxRouter { override fun openConversation(conversation: Conversation, scope: InboxApi.Scope) { - // TODO: Implement + navigation.navigate(activity, navigation.inboxDetailsRoute(conversation.id)) } override fun attachNavigationIcon(toolbar: Toolbar) { @@ -40,7 +41,12 @@ class ParentInboxRouter(private val activity: FragmentActivity, private val navi } override fun routeToNewMessage() { - val route = navigation.inboxCompose + val route = navigation.inboxComposeRoute(InboxComposeOptions.buildNewMessage()) + navigation.navigate(activity, route) + } + + override fun routeToCompose(options: InboxComposeOptions) { + val route = navigation.inboxComposeRoute(options) navigation.navigate(activity, route) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt index 2ce651ac23..877c8850a8 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt @@ -19,7 +19,9 @@ import com.instructure.pandautils.features.calendarevent.details.EventFragment import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoFragment import com.instructure.pandautils.features.calendartodo.details.ToDoFragment import com.instructure.pandautils.features.inbox.compose.InboxComposeFragment +import com.instructure.pandautils.features.inbox.details.InboxDetailsFragment import com.instructure.pandautils.features.inbox.list.InboxFragment +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions import com.instructure.pandautils.features.settings.SettingsFragment import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.fromJson @@ -50,11 +52,16 @@ class Navigation(apiPrefs: ApiPrefs) { val calendar = "$baseUrl/calendar" val alerts = "$baseUrl/alerts" val inbox = "$baseUrl/conversations" - val inboxCompose = "$baseUrl/conversations/compose" val manageStudents = "$baseUrl/manage-students" val qrPairing = "$baseUrl/qr-pairing" val settings = "$baseUrl/settings" + private val inboxCompose = "$baseUrl/conversations/compose/{${InboxComposeOptions.COMPOSE_PARAMETERS}}" + fun inboxComposeRoute(options: InboxComposeOptions) = "$baseUrl/conversations/compose/${InboxComposeOptionsParametersType.serializeAsValue(options)}" + + private val inboxDetails = "$baseUrl/conversations/{${InboxDetailsFragment.CONVERSATION_ID}}" + fun inboxDetailsRoute(conversationId: Long) = "$baseUrl/conversations/$conversationId" + private val calendarEvent = "$baseUrl/{${EventFragment.CONTEXT_TYPE}}/{${EventFragment.CONTEXT_ID}}/calendar_events/{${EventFragment.SCHEDULE_ITEM_ID}}" private val createEvent = "$baseUrl/create-event/{${CreateUpdateEventFragment.INITIAL_DATE}}" @@ -99,7 +106,21 @@ class Navigation(apiPrefs: ApiPrefs) { } } fragment(inbox) - fragment(inboxCompose) + fragment(inboxCompose) { + argument(InboxComposeOptions.COMPOSE_PARAMETERS) { + type = InboxComposeOptionsParametersType + nullable = false + } + } + fragment(inboxDetails) { + argument(InboxDetailsFragment.CONVERSATION_ID) { + type = NavType.LongType + nullable = false + } + deepLink { + uriPattern = inboxDetails + } + } fragment(manageStudents) fragment(qrPairing) fragment(settings) @@ -240,6 +261,26 @@ private val ScheduleItemParametersType = object : NavType( } } +private val InboxComposeOptionsParametersType = object : NavType( + isNullableAllowed = false +) { + override fun put(bundle: Bundle, key: String, value: InboxComposeOptions) { + bundle.putParcelable(key, value) + } + + override fun get(bundle: Bundle, key: String): InboxComposeOptions? { + return bundle.getParcelable(key) as? InboxComposeOptions + } + + override fun serializeAsValue(value: InboxComposeOptions): String { + return Uri.encode(value.toJson()) + } + + override fun parseValue(value: String): InboxComposeOptions { + return value.fromJson() + } +} + private val UserParametersType = object : NavType(isNullableAllowed = false) { override fun put(bundle: Bundle, key: String, value: User) { bundle.putParcelable(key, value) diff --git a/apps/student/src/main/java/com/instructure/student/features/inbox/compose/StudentInboxComposeRepository.kt b/apps/student/src/main/java/com/instructure/student/features/inbox/compose/StudentInboxComposeRepository.kt index a0fb637c65..4af64b1e0b 100644 --- a/apps/student/src/main/java/com/instructure/student/features/inbox/compose/StudentInboxComposeRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/inbox/compose/StudentInboxComposeRepository.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.student.features.inbox.compose import com.instructure.canvasapi2.models.Attachment @@ -5,6 +20,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.features.inbox.compose.InboxComposeRepository @@ -37,6 +53,17 @@ class StudentInboxComposeRepository: InboxComposeRepository { TODO("Not yet implemented") } + override suspend fun addMessage( + conversationId: Long, + recipients: List, + message: String, + includedMessages: List, + attachments: List, + context: CanvasContext + ): DataResult { + TODO("Not yet implemented") + } + override suspend fun canSendToAll(context: CanvasContext): DataResult { TODO("Not yet implemented") } diff --git a/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt b/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt index 78bf069727..02684732cf 100644 --- a/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt @@ -21,8 +21,9 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.models.Conversation -import com.instructure.pandautils.features.inbox.list.InboxRouter import com.instructure.pandautils.features.inbox.list.InboxFragment +import com.instructure.pandautils.features.inbox.list.InboxRouter +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions import com.instructure.student.activity.NavigationActivity import com.instructure.student.events.ConversationUpdatedEvent import com.instructure.student.fragment.InboxComposeMessageFragment @@ -48,6 +49,10 @@ class StudentInboxRouter(private val activity: FragmentActivity, private val fra RouteMatcher.route(activity, route) } + override fun routeToCompose(options: InboxComposeOptions) { + TODO("Not yet implemented") + } + override fun avatarClicked(conversation: Conversation, scope: InboxApi.Scope) { openConversation(conversation, scope) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/compose/TeacherInboxComposeRepository.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/compose/TeacherInboxComposeRepository.kt index e86e823bd9..88df1c49bc 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/compose/TeacherInboxComposeRepository.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/compose/TeacherInboxComposeRepository.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.teacher.features.inbox.compose import com.instructure.canvasapi2.models.Attachment @@ -5,6 +20,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.features.inbox.compose.InboxComposeRepository @@ -37,6 +53,17 @@ class TeacherInboxComposeRepository: InboxComposeRepository { TODO("Not yet implemented") } + override suspend fun addMessage( + conversationId: Long, + recipients: List, + message: String, + includedMessages: List, + attachments: List, + context: CanvasContext + ): DataResult { + TODO("Not yet implemented") + } + override suspend fun canSendToAll(context: CanvasContext): DataResult { TODO("Not yet implemented") } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt index 734fd1dac0..38927081e4 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt @@ -28,6 +28,7 @@ import com.instructure.canvasapi2.utils.parcelCopy import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.features.inbox.list.InboxRouter +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions import com.instructure.teacher.R import com.instructure.teacher.activities.InitActivity import com.instructure.teacher.adapters.StudentContextFragment @@ -67,6 +68,10 @@ class TeacherInboxRouter(private val activity: FragmentActivity, private val fra RouteMatcher.route(activity, Route(AddMessageFragment::class.java, null, args)) } + override fun routeToCompose(options: InboxComposeOptions) { + TODO("Not yet implemented") + } + override fun avatarClicked(conversation: Conversation, scope: InboxApi.Scope) { val canvasContext = CanvasContext.fromContextCode(conversation.contextCode) val isAvatarClickable = conversation.participants.size == 1 || conversation.participants.size == 2 diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/InboxDetailsInteractionTest.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/InboxDetailsInteractionTest.kt new file mode 100644 index 0000000000..f323634f4a --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/InboxDetailsInteractionTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.canvas.espresso.common.interaction + +import com.instructure.canvas.espresso.CanvasComposeTest +import com.instructure.canvas.espresso.common.pages.InboxPage +import com.instructure.canvas.espresso.common.pages.compose.InboxComposePage +import com.instructure.canvas.espresso.common.pages.compose.InboxDetailsPage +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.utils.Randomizer +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.User +import org.junit.Test + +abstract class InboxDetailsInteractionTest: CanvasComposeTest() { + + private val inboxPage = InboxPage() + private val inboxDetailsPage = InboxDetailsPage(composeTestRule) + private val inboxComposePage = InboxComposePage(composeTestRule) + + @Test + fun testIfConversationDisplayedCorrectly() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.assertTitle("Message") + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation) + } + + @Test + fun testMessageReplyTextButton() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressReplyTextButtonForMessage(message) + + assertReplyComposeScreenDisplayed(conversation) + } + + @Test + fun testMessageReplyIconButton() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressReplyIconButtonForMessage(message) + + assertReplyComposeScreenDisplayed(conversation) + } + + @Test + fun testMessageOverflowMenuReplyButton() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressOverflowMenuItemForMessage(conversation, message, "Reply") + + inboxDetailsPage.assertConversationSubject("Re: ${conversation.subject}") + assertReplyComposeScreenDisplayed(conversation) + } + + @Test + fun testMessageOverflowMenuReplyAllButton() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressOverflowMenuItemForMessage(conversation, message, "Reply All") + + inboxDetailsPage.assertConversationSubject("Re: ${conversation.subject}") + assertReplyAllComposeScreenDisplayed(conversation) + } + + @Test + fun testMessageOverflowMenuForwardButton() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressOverflowMenuItemForMessage(conversation, message, "Forward") + + inboxDetailsPage.assertConversationSubject("Fwd: ${conversation.subject}") + assertForwardComposeScreenDisplayed(conversation) + } + + @Test + fun testMessageOverflowMenuDeleteMessageButtonWithCancel() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages.first() + inboxDetailsPage.pressOverflowMenuItemForMessage(conversation, message, "Delete") + inboxDetailsPage.assertDeleteMessageAlertDialog() + inboxDetailsPage.pressAlertButton("Cancel") + + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation) + } + + @Test + fun testMessageOverflowMenuDeleteMessageButtonWithConfirm() { + val data = initData() + val conversation = getConversations(data).last() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + val message = conversation.messages[2] + inboxDetailsPage.pressOverflowMenuItemForMessage(conversation, message, "Delete") + inboxDetailsPage.assertDeleteMessageAlertDialog() + inboxDetailsPage.pressAlertButton("Delete") + + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation.copy(messages = conversation.messages.filter { it.id != message.id })) + } + + @Test + fun testConversationStar() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.assertStarred(false) + inboxDetailsPage.pressStarButton(true) + inboxDetailsPage.assertStarred(true) + + inboxDetailsPage.pressBackButton() + inboxPage.assertConversationStarred(conversation.subject!!) + inboxPage.filterInbox("Starred") + inboxPage.assertConversationDisplayed(conversation.subject!!) + inboxPage.openConversation(conversation.subject!!) + + inboxDetailsPage.pressStarButton(false) + + inboxDetailsPage.assertStarred(false) + } + + @Test + fun testConversationOverflowMenuReply() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Reply") + + assertReplyComposeScreenDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuReplyAll() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Reply All") + + assertReplyAllComposeScreenDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuForward() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Forward") + + assertForwardComposeScreenDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuMarkAsUnread() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Mark as Unread") + + inboxDetailsPage.pressOverflowMenuItemForConversation("Mark as Read") + + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuArchive() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Archive") + + inboxDetailsPage.pressBackButton() + inboxPage.assertConversationNotDisplayed(conversation.subject!!) + inboxPage.filterInbox("Archived") + inboxPage.assertConversationDisplayed(conversation.subject!!) + inboxPage.openConversation(conversation.subject!!) + + inboxDetailsPage.pressOverflowMenuItemForConversation("Unarchive") + + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuDeleteWithCancel() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Delete") + inboxDetailsPage.assertDeleteConversationAlertDialog() + + inboxDetailsPage.pressAlertButton("Cancel") + + inboxDetailsPage.assertConversationSubject(conversation.subject!!) + inboxDetailsPage.assertAllMessagesDisplayed(conversation) + } + + @Test + fun testConversationOverflowMenuDeleteWithConfirm() { + val data = initData() + val conversation = getConversations(data).first() + goToInboxDetails(data, conversation) + composeTestRule.waitForIdle() + + inboxDetailsPage.pressOverflowMenuItemForConversation("Delete") + inboxDetailsPage.assertDeleteConversationAlertDialog() + + inboxDetailsPage.pressAlertButton("Delete") + + inboxPage.assertConversationNotDisplayed(conversation.subject!!) + } + + private fun assertReplyComposeScreenDisplayed(conversation: Conversation) { + inboxComposePage.assertTitle("Reply") + inboxComposePage.assertContextSelected(conversation.contextName!!) + + conversation.participants.filter { it.id == conversation.messages.first().authorId }.map { it.name!!}.forEach { + inboxComposePage.assertRecipientSelected(it) + } + + inboxComposePage.assertPreviousMessagesDisplayed(conversation, conversation.messages) + } + + private fun assertReplyAllComposeScreenDisplayed(conversation: Conversation) { + inboxComposePage.assertTitle("Reply All") + inboxComposePage.assertContextSelected(conversation.contextName!!) + + conversation.participants.filter { it.id != getLoggedInUser().id }.map { it.name!!}.forEach { + inboxComposePage.assertRecipientSelected(it) + } + + inboxComposePage.assertPreviousMessagesDisplayed(conversation, conversation.messages) + } + + private fun assertForwardComposeScreenDisplayed(conversation: Conversation) { + inboxComposePage.assertTitle("Forward") + inboxComposePage.assertContextSelected(conversation.contextName!!) + + inboxComposePage.assertPreviousMessagesDisplayed(conversation, conversation.messages) + } + + override fun displaysPageObjects() = Unit + + abstract fun goToInboxDetails(data: MockCanvas, conversation: Conversation) + + abstract fun goToInboxDetails(data: MockCanvas, conversationSubject: String) + + abstract fun initData(): MockCanvas + + abstract fun getLoggedInUser(): User + + abstract fun getTeachers(): List + + abstract fun getConversations(data: MockCanvas): List + + abstract fun addNewConversation( + data: MockCanvas, + authorId: Long, + recipients: List, + messageSubject: String = Randomizer.randomConversationSubject(), + messageBody: String = Randomizer.randomConversationBody(), + ): Conversation +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxComposePage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxComposePage.kt index ff1c21301b..a95bc55b8c 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxComposePage.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxComposePage.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.canvas.espresso.common.pages.compose import androidx.compose.ui.test.assert @@ -6,7 +21,10 @@ import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotEnabled @@ -16,6 +34,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message class InboxComposePage(private val composeTestRule: ComposeTestRule) { fun assertTitle(title: String) { @@ -42,7 +62,8 @@ class InboxComposePage(private val composeTestRule: ComposeTestRule) { fun assertRecipientSelected(recipientName: String) { composeTestRule.waitForIdle() - composeTestRule.onNodeWithText(recipientName).assertIsDisplayed() + composeTestRule.onNode(hasTestTag("recipientChip").and(hasAnyDescendant(hasText(recipientName)))) + .assertIsDisplayed() } fun assertRecipientNotSelected(recipientName: String) { @@ -84,6 +105,26 @@ class InboxComposePage(private val composeTestRule: ComposeTestRule) { composeTestRule.onNodeWithText("Exit").assertIsDisplayed() } + fun assertPreviousMessagesDisplayed(conversation: Conversation, includedMessages: List) { + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Previous Messages") + .assertIsDisplayed() + + includedMessages.forEach { message -> + composeTestRule.onNodeWithText(message.body!!) + .assertIsDisplayed() + + val authorMatcher = hasTestTag("previousMessageView") + composeTestRule.onNode(authorMatcher) + .assertIsDisplayed() + + val recipientsMatcher = hasTestTag("previousMessageView").and(hasAnyDescendant(hasText(conversation.participants.first { it.id == message.authorId }.name!!))) + composeTestRule.onNode(recipientsMatcher) + .assertIsDisplayed() + } + } + fun typeSubject(subject: String) { composeTestRule.waitForIdle() composeTestRule.onNodeWithTag("labelTextFieldRowTextField").performClick() diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt new file mode 100644 index 0000000000..3195aff64d --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.canvas.espresso.common.pages.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onParent +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message + +class InboxDetailsPage(private val composeTestRule: ComposeTestRule) { + + fun assertTitle(title: String) { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(title) + .isDisplayed() + } + + fun assertConversationSubject(subject: String) { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(subject) + .isDisplayed() + } + + fun assertMessageDisplayed(message: Message) { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(message.body ?: "").performScrollTo() + } + + fun assertAllMessagesDisplayed(conversation: Conversation) { + conversation.messages.forEach { message -> + assertMessageDisplayed(message) + } + } + + fun assertStarred(isStarred: Boolean) { + composeTestRule.waitForIdle() + if (isStarred) { + composeTestRule.onNodeWithContentDescription("Unstar") + } else { + composeTestRule.onNodeWithContentDescription("Star") + } + } + + fun assertDeleteMessageAlertDialog() { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Delete Message").assertIsDisplayed() + composeTestRule.onNodeWithText("Are you sure you want to delete your copy of this message? This action cannot be undone.").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertIsDisplayed() + composeTestRule.onNodeWithText("Delete").assertIsDisplayed() + } + + fun assertDeleteConversationAlertDialog() { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Delete Conversation").assertIsDisplayed() + composeTestRule.onNodeWithText("Are you sure you want to delete your copy of this conversation? This action cannot be undone.").assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").assertIsDisplayed() + composeTestRule.onNodeWithText("Delete").assertIsDisplayed() + } + + fun pressBackButton() { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Go Back").performClick() + } + + fun pressReplyTextButtonForMessage(message: Message) { + composeTestRule.waitForIdle() + + val replyButton = composeTestRule.onNodeWithText(message.body ?: "") + .onParent() // SelectionContainer + .onParent() // Column + .onChildren() + .filterToOne(hasText("Reply")) + + composeTestRule.waitForIdle() + replyButton.performScrollTo() + replyButton.performClick() + } + + fun pressReplyIconButtonForMessage(message: Message) { + composeTestRule.waitForIdle() + + val replyButton = composeTestRule.onNodeWithText(message.body ?: "") + .onParent() // SelectionContainer + .onParent() // Column + .onChildren() + .filterToOne(hasContentDescription("Reply")) + + replyButton.performScrollTo() + composeTestRule.waitForIdle() + replyButton.performScrollTo() + replyButton.performClick() + } + + fun pressStarButton(newIsStarred: Boolean) { + if (newIsStarred) { + composeTestRule.onNodeWithContentDescription("Star").performClick() + } else { + composeTestRule.onNodeWithContentDescription("Unstar").performClick() + } + } + + fun pressAlertButton(buttonLabel: String) { + composeTestRule.onNodeWithText(buttonLabel).performClick() + } + + fun pressOverflowMenuItemForMessage(conversation: Conversation, message: Message, buttonLabel: String) { + pressOverflowIconButtonForMessage(conversation, message) + + composeTestRule.onNode(hasTestTag("messageMenuItem").and(hasText(buttonLabel)), true) + .performClick() + } + + fun pressOverflowMenuItemForConversation(buttonLabel: String) { + pressOverflowIconButtonForConversation() + + composeTestRule.onNode(hasTestTag("messageMenuItem").and(hasText(buttonLabel)), true) + .performClick() + } + + private fun pressOverflowIconButtonForMessage(conversation: Conversation, message: Message) { + composeTestRule.waitForIdle() + + val overflowButton = composeTestRule.onNodeWithText(message.body ?: "") + .onParent() // SelectionContainer + .onParent() // Column + .onChildren() + .filter(hasContentDescription("More options")) + .get(conversation.messages.indexOf(message)) + + overflowButton.performScrollTo() + composeTestRule.waitForIdle() + overflowButton.performClick() + } + + private fun pressOverflowIconButtonForConversation() { + composeTestRule.waitForIdle() + + val overflowButton = composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasContentDescription("More options") + ) + ) + + overflowButton.performClick() + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index c161c0f043..eae08deab2 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -867,12 +867,12 @@ fun MockCanvas.addSentConversation(subject: String, userId: Long, messageBody : * for all other conversations. * */ -fun MockCanvas.addConversations(conversationCount: Int = 1, userId: Long, messageBody : String = Randomizer.randomConversationBody()) { +fun MockCanvas.addConversations(conversationCount: Int = 1, userId: Long, messageBody : String = Randomizer.randomConversationBody(), contextName: String? = null, contextCode: String? = null) { for (i in 0 until conversationCount) { - val sentConversation = createBasicConversation(userId = userId, isUserAuthor = true, messageBody = messageBody) - val archivedConversation = createBasicConversation(userId, workflowState = Conversation.WorkflowState.ARCHIVED, messageBody = messageBody) - val starredConversation = createBasicConversation(userId, isStarred = true, messageBody = messageBody) - val unreadConversation = createBasicConversation(userId, workflowState = Conversation.WorkflowState.UNREAD, messageBody = messageBody) + val sentConversation = createBasicConversation(userId = userId, isUserAuthor = true, messageBody = messageBody, contextCode = contextCode, contextName = contextName) + val archivedConversation = createBasicConversation(userId, workflowState = Conversation.WorkflowState.ARCHIVED, messageBody = messageBody, contextCode = contextCode, contextName = contextName) + val starredConversation = createBasicConversation(userId, isStarred = true, messageBody = messageBody, contextCode = contextCode, contextName = contextName) + val unreadConversation = createBasicConversation(userId, workflowState = Conversation.WorkflowState.UNREAD, messageBody = messageBody, contextCode = contextCode, contextName = contextName) conversations[sentConversation.id] = sentConversation conversations[archivedConversation.id] = archivedConversation conversations[starredConversation.id] = starredConversation @@ -880,6 +880,65 @@ fun MockCanvas.addConversations(conversationCount: Int = 1, userId: Long, messag } } +/** + * Adds a single conversation, with sender [senderId] and receivers [receiverIds]. It will not + * be associated with any course. + */ +fun MockCanvas.addConversationWithMultipleMessages( + senderId: Long, + receiverIds: List, + messageCount: Int = 1, +) : Conversation { + val messageSubject = Randomizer.randomConversationSubject() + val sender = this.users[senderId]!! + val senderBasic = BasicUser( + id = sender.id, + name = sender.shortName, + pronouns = sender.pronouns, + avatarUrl = sender.avatarUrl + ) + + val participants = mutableListOf(senderBasic) + receiverIds.forEach {id -> + val receiver = this.users[id]!! + participants.add( + BasicUser( + id = receiver.id, + name = receiver.shortName, + pronouns = receiver.pronouns, + avatarUrl = receiver.avatarUrl + ) + ) + } + + val basicMessages = MutableList(messageCount) { + Message( + id = newItemId(), + createdAt = APIHelper.dateToString(GregorianCalendar()), + body = Randomizer.randomConversationBody(), + authorId = sender.id, + participatingUserIds = receiverIds.toMutableList().plus(senderId) + ) + } + + val result = Conversation( + id = newItemId(), + subject = messageSubject, + workflowState = Conversation.WorkflowState.UNREAD, + lastMessage = basicMessages.last().body, + lastAuthoredMessageAt = APIHelper.dateToString(GregorianCalendar()), + messageCount = basicMessages.size, + messages = basicMessages, + avatarUrl = Randomizer.randomAvatarUrl(), + participants = participants, + audience = null // Prevents "Monologue" + ) + + this.conversations[result.id] = result + + return result +} + /** * Adds a single conversation, with sender [senderId] and receivers [receiverIds]. It will not * be associated with any course. diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/InboxApi.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/InboxApi.kt index 1d0d2152b0..d08efd68fd 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/InboxApi.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/InboxApi.kt @@ -89,6 +89,9 @@ object InboxApi { @GET("conversations/{conversationId}?include[]=participant_avatars") fun getConversation(@Path("conversationId") conversationId: Long, @Query("auto_mark_as_read") markAsRead: Boolean): Call + @GET("conversations/{conversationId}?include[]=participant_avatars") + suspend fun getConversation(@Path("conversationId") conversationId: Long, @Query("auto_mark_as_read") markAsRead: Boolean, @Tag params: RestParams): DataResult + @PUT("conversations/{conversationId}") fun updateConversation(@Path("conversationId") conversationId: Long, @Query("conversation[workflow_state]") workflowState: String, @Query("conversation[starred]") isStarred: Boolean?): Call @@ -98,9 +101,15 @@ object InboxApi { @DELETE("conversations/{conversationId}") fun deleteConversation(@Path("conversationId") conversationId: Long): Call + @DELETE("conversations/{conversationId}") + suspend fun deleteConversation(@Path("conversationId") conversationId: Long, @Tag params: RestParams): DataResult + @POST("conversations/{conversationId}/remove_messages") fun deleteMessages(@Path("conversationId") conversationId: Long, @Query("remove[]") messageIds: List): Call + @POST("conversations/{conversationId}/remove_messages") + suspend fun deleteMessages(@Path("conversationId") conversationId: Long, @Query("remove[]") messageIds: List, @Tag params: RestParams): DataResult + @FormUrlEncoded @POST("conversations/{conversationId}/add_message?group_conversation=true") fun addMessage(@Path("conversationId") conversationId: Long, @@ -110,6 +119,18 @@ object InboxApi { @Field("attachment_ids[]") attachmentIds: LongArray, @Field("context_code") contextCode: String?): Call + @FormUrlEncoded + @POST("conversations/{conversationId}/add_message?group_conversation=true") + suspend fun addMessage( + @Path("conversationId") conversationId: Long, + @Field("recipients[]") recipientIds: List, + @Field("body") body: String, + @Field("included_messages[]") includedMessageIds: LongArray, + @Field("attachment_ids[]") attachmentIds: LongArray, + @Field("context_code") contextCode: String?, + @Tag params: RestParams + ): DataResult + @PUT("conversations/{conversationId}?conversation[workflow_state]=unread") fun markConversationAsUnread(@Path("conversationId") conversationId: Long): Call diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CanvasContext.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CanvasContext.kt index 60c0dbf19a..fd24e8caf6 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CanvasContext.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CanvasContext.kt @@ -17,7 +17,8 @@ package com.instructure.canvasapi2.models -import java.util.* +import java.util.Date +import java.util.Locale abstract class CanvasContext : CanvasModel() { abstract val name: String? @@ -111,8 +112,8 @@ abstract class CanvasContext : CanvasModel() { * @param contextCode Context code string, e.g. "course_1" * @return A generic CanvasContext, or null if the provided contextCode is invalid */ - fun fromContextCode(contextCode: String?): CanvasContext? { - if (contextCode.isNullOrBlank()) return null + fun fromContextCode(contextCode: String?, name: String? = ""): CanvasContext? { + if (contextCode.isNullOrBlank() || name == null) return null val codeParts = contextCode.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() if (codeParts.size != 2) return null @@ -131,7 +132,7 @@ abstract class CanvasContext : CanvasModel() { return null } - return getGenericContext(type, id, "") + return getGenericContext(type, id, name) } fun getApiContext(canvasContext: CanvasContext): String = if (canvasContext.type == Type.COURSE) "courses" else "groups" diff --git a/libs/pandares/src/main/res/drawable/ic_forward.xml b/libs/pandares/src/main/res/drawable/ic_forward.xml new file mode 100644 index 0000000000..57533dfd49 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_reply.xml b/libs/pandares/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000000..97ed49734c --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_reply_all.xml b/libs/pandares/src/main/res/drawable/ic_reply_all.xml new file mode 100644 index 0000000000..507a801373 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_reply_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 8801a9c03b..261ed9c6eb 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -339,6 +339,7 @@ Add to Home Archive Move to Inbox + Mark as Read Mark as Unread Select People Delete @@ -384,6 +385,7 @@ with Star Conversation Delete Conversation + Delete Message Shared with \u0020 Shared with you Tap the \"+\" to create a new conversation. @@ -403,6 +405,10 @@ Conversation archived Conversation unarchived Message deleted + Failed to delete message + Conversation deleted + Failed to delete conversation + Failed to update conversation Send individual message to each recipient You are not allowed to send messages to one or more of the selected recipients. New Message @@ -428,6 +434,14 @@ Remove Recipient Failed to send message Failed to open attachment + Failed to load conversation + No messages found + Re: %1$s + Fw: %1$s + %1$s to %2$s + %1$s + %2$s Others + Failed to open url + Previous Messages %d Person %d People diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/compose/InboxComposeScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/compose/InboxComposeScreenTest.kt index 230c5a3d7b..ebf793c722 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/compose/InboxComposeScreenTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/compose/InboxComposeScreenTest.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.pandautils.compose.features.inbox.compose import androidx.compose.ui.test.assert @@ -21,13 +36,15 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Recipient import com.instructure.pandautils.compose.composables.MultipleValuesRowState import com.instructure.pandautils.compose.composables.SelectContextUiState -import com.instructure.pandautils.features.inbox.compose.AttachmentCardItem -import com.instructure.pandautils.features.inbox.compose.AttachmentStatus import com.instructure.pandautils.features.inbox.compose.InboxComposeScreenOptions import com.instructure.pandautils.features.inbox.compose.InboxComposeUiState import com.instructure.pandautils.features.inbox.compose.RecipientPickerUiState import com.instructure.pandautils.features.inbox.compose.ScreenState import com.instructure.pandautils.features.inbox.compose.composables.InboxComposeScreen +import com.instructure.pandautils.features.inbox.utils.AttachmentCardItem +import com.instructure.pandautils.features.inbox.utils.AttachmentStatus +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsDisabledFields +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsHiddenFields import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -206,7 +223,7 @@ class InboxComposeScreenTest { .assertIsDisplayed() .assertHasClickAction() - composeTestRule.onNode(hasContentDescription("Remove Attachment")) + composeTestRule.onNode(hasContentDescription("Remove attachment")) .assertIsDisplayed() .assertHasClickAction() } @@ -230,6 +247,135 @@ class InboxComposeScreenTest { .assertHasClickAction() } + @Test + fun testDisabledFields() { + setComposeScreen( + InboxComposeUiState( + disabledFields = InboxComposeOptionsDisabledFields( + isContextDisabled = true, + isRecipientsDisabled = true, + isSendIndividualDisabled = true, + isSubjectDisabled = true, + isBodyDisabled = true, + isAttachmentDisabled = true + ), + subject = TextFieldValue("testSubject"), + body = TextFieldValue("testBody"), + selectContextUiState = SelectContextUiState(selectedCanvasContext = Course(name = "Course 1")), + inlineRecipientSelectorState = MultipleValuesRowState(enabled = false, isSearchEnabled = true, selectedValues = listOf(Recipient(stringId = "r2", name = "r2"))), + recipientPickerUiState = RecipientPickerUiState(selectedRecipients = listOf(Recipient(stringId = "r2", name = "r2"))), + ) + ) + + composeTestRule.onNode(hasText("Course")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("Course 1")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("To")) + .assertIsDisplayed() + + composeTestRule.onNode(hasText("Search")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasContentDescription("Add")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("r2")) + .assertIsDisplayed() + + composeTestRule.onNode(hasContentDescription("Remove Recipient")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("Send individual message to each recipient")) + .assertIsDisplayed() + + composeTestRule.onNode(hasTestTag("switch")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("Subject")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("testSubject")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasText("testBody")) + .assertIsDisplayed() + .assert(isNotEnabled()) + + composeTestRule.onNode(hasContentDescription("Add attachment")) + .assertIsDisplayed() + .assert(isNotEnabled()) + } + + @Test + fun testHiddenFields() { + setComposeScreen( + InboxComposeUiState( + hiddenFields = InboxComposeOptionsHiddenFields( + isContextHidden = true, + isRecipientsHidden = true, + isSendIndividualHidden = true, + isSubjectHidden = true, + isBodyHidden = true, + isAttachmentHidden = true, + ), + subject = TextFieldValue("testSubject"), + body = TextFieldValue("testBody"), + selectContextUiState = SelectContextUiState(selectedCanvasContext = Course(name = "Course 1")), + inlineRecipientSelectorState = MultipleValuesRowState(isSearchEnabled = true, selectedValues = listOf(Recipient(stringId = "r2", name = "r2"))), + recipientPickerUiState = RecipientPickerUiState(selectedRecipients = listOf(Recipient(stringId = "r2", name = "r2"))), + ) + ) + + composeTestRule.onNode(hasText("Course")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("Course 1")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("To")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("Search")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasContentDescription("Add")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("r2")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasContentDescription("Remove Recipient")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("Send individual message to each recipient")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasTestTag("switch")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("Subject")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("testSubject")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasText("testBody")) + .assertIsNotDisplayed() + + composeTestRule.onNode(hasContentDescription("Add attachment")) + .assertIsNotDisplayed() + } + private fun setComposeScreen(uiState: InboxComposeUiState = getUiState()) { composeTestRule.setContent { InboxComposeScreen( @@ -258,7 +404,7 @@ class InboxComposeScreenTest { sendIndividual = sendIndividual, subject = TextFieldValue(subject), body = TextFieldValue(body), - attachments = attachments.map { AttachmentCardItem(it, AttachmentStatus.UPLOADED) }, + attachments = attachments.map { AttachmentCardItem(it, AttachmentStatus.UPLOADED, false) }, screenState = ScreenState.Data, showConfirmationDialog = showConfirmationDialog ) diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/details/InboxDetailsScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/details/InboxDetailsScreenTest.kt new file mode 100644 index 0000000000..5a1c345e37 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/compose/features/inbox/details/InboxDetailsScreenTest.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.compose.features.inbox.details + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.pandautils.features.inbox.details.ConfirmationDialogState +import com.instructure.pandautils.features.inbox.details.InboxDetailsUiState +import com.instructure.pandautils.features.inbox.details.ScreenState +import com.instructure.pandautils.features.inbox.details.composables.InboxDetailsScreen +import com.instructure.pandautils.features.inbox.utils.InboxMessageUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.ZonedDateTime + +@RunWith(AndroidJUnit4::class) +class InboxDetailsScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val title = "Message" + + @Test + fun testInboxDetailsScreenErrorState() { + setDetailsScreen(getUiState(state = ScreenState.Error)) + + composeTestRule.onNodeWithText("Failed to load conversation") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Retry") + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testInboxDetailsScreenEmptyState() { + setDetailsScreen(getUiState(state = ScreenState.Empty)) + + composeTestRule.onNodeWithText("No messages found") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Retry") + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testInboxDetailsScreenContentStateWithStar() { + val conversation = getConversation( + messages = listOf( + getMessage(id = 1, authorId = 1), + ) + ) + setDetailsScreen(getUiState(conversation = conversation)) + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasText("Message") + ) + ) + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test subject") + .assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Star") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithText("User 1 to User 2") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test message") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Reply") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithContentDescription("Reply") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).not().and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testInboxDetailsScreenContentStateWithUnStar() { + val conversation = getConversation( + messages = listOf( + getMessage(id = 1, authorId = 1), + ), + isStarred = true + ) + setDetailsScreen(getUiState(conversation = conversation)) + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasText("Message") + ) + ) + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test subject") + .assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Unstar") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithText("User 1 to User 2") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test message") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Reply") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithContentDescription("Reply") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).not().and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testInboxDetailsAlertDialog() { + setDetailsScreen(getUiState( + confirmationDialogState = ConfirmationDialogState( + showDialog = true, + title = "Test title", + message = "Test message", + positiveButton = "Positive", + negativeButton = "Negative" + ), + conversation = getConversation() + )) + + composeTestRule.onNodeWithText("Test title") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test message") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Positive") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithText("Negative") + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun testInboxDetailsScreenWithCannotReply() { + val conversation = getConversation( + messages = listOf( + getMessage(id = 1, authorId = 1), + ), + cannotReply = true + ) + setDetailsScreen(getUiState(conversation = conversation)) + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).and( + hasText("Message") + ) + ) + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test subject") + .assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Star") + .assertIsDisplayed() + .assertHasClickAction() + + composeTestRule.onNodeWithText("User 1 to User 2") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Test message") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Reply") + .assertIsNotDisplayed() + + composeTestRule.onNodeWithContentDescription("Reply") + .assertIsNotDisplayed() + + composeTestRule.onNode( + hasParent(hasTestTag("toolbar")).not().and( + hasContentDescription("More options") + ) + ) + .assertIsDisplayed() + .assertHasClickAction() + } + + private fun setDetailsScreen(uiState: InboxDetailsUiState = getUiState()) { + composeTestRule.setContent { + InboxDetailsScreen( + title = title, + uiState = uiState, + messageActionHandler = {}, + actionHandler = {} + ) + } + } + + private fun getUiState( + id: Long = 1, + conversation: Conversation? = null, + state: ScreenState = ScreenState.Success, + confirmationDialogState: ConfirmationDialogState = ConfirmationDialogState() + ): InboxDetailsUiState { + return InboxDetailsUiState( + conversationId = id, + conversation = conversation, + messageStates = conversation?.messages?.map { getMessageViewState(conversation, it) } ?: emptyList(), + state = state, + confirmationDialogState = confirmationDialogState + ) + } + + private fun getConversation( + id: Long = 1, + subject: String = "Test subject", + workflowState: Conversation.WorkflowState = Conversation.WorkflowState.READ, + messages: List = emptyList(), + participants: MutableList = mutableListOf( + BasicUser(id = 1, name = "User 1"), + BasicUser(id = 2, name = "User 2"), + BasicUser(id = 3, name = "User 3"), + ), + isStarred: Boolean = false, + cannotReply: Boolean = false, + ): Conversation { + return Conversation( + id = id, + subject = subject, + workflowState = workflowState, + messages = messages, + messageCount = messages.size, + lastMessage = messages.lastOrNull()?.body, + participants = participants, + isStarred = isStarred, + cannotReply = cannotReply + ) + } + + private fun getMessage( + id: Long = 1, + authorId: Long = 1, + body: String = "Test message", + participatingUserIds: List = listOf(2), + createdAt: String = ZonedDateTime.now().toString() + ): Message { + return Message( + id = id, + authorId = authorId, + body = body, + participatingUserIds = participatingUserIds, + createdAt = createdAt + ) + } + + private fun getMessageViewState(conversation: Conversation, message: Message): InboxMessageUiState { + val author = conversation.participants.find { it.id == message.authorId } + val recipients = conversation.participants.filter { message.participatingUserIds.filter { it != message.authorId }.contains(it.id) } + return InboxMessageUiState( + message = message, + author = author, + recipients = recipients, + enabledActions = true, + cannotReply = conversation.cannotReply + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelSwitchRow.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelSwitchRow.kt index d1c0ce10fb..770802705a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelSwitchRow.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelSwitchRow.kt @@ -26,6 +26,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -41,6 +42,7 @@ import com.instructure.pandautils.utils.ThemePrefs fun LabelSwitchRow( label: String, checked: Boolean, + enabled: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier ) { @@ -50,6 +52,7 @@ fun LabelSwitchRow( .height(52.dp) .padding(start = 16.dp, end = 8.dp) .padding(vertical = 8.dp) + .alpha(if (enabled) 1f else 0.5f) ) { Text( text = label, @@ -64,6 +67,7 @@ fun LabelSwitchRow( onCheckedChange = { onCheckedChange(it) }, + enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color(ThemePrefs.brandColor), checkedTrackColor = Color(ThemePrefs.brandColor).copy(alpha = 0.5f), @@ -84,6 +88,7 @@ fun LabelSwitchRowCheckedPreview() { LabelSwitchRow( label = "Switch row", checked = true, + enabled = true, onCheckedChange = {}, ) } @@ -95,6 +100,7 @@ fun LabelSwitchRowUncheckedPreview() { LabelSwitchRow( label = "Switch row", checked = false, + enabled = true, onCheckedChange = {}, ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelTextFieldRow.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelTextFieldRow.kt index 1c2cac589d..94137bb27e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelTextFieldRow.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/LabelTextFieldRow.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag @@ -42,6 +43,7 @@ import com.instructure.pandautils.R fun LabelTextFieldRow( label: String, value: TextFieldValue, + enabled: Boolean, onValueChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } @@ -51,6 +53,7 @@ fun LabelTextFieldRow( modifier = modifier .height(48.dp) .padding(start = 16.dp, end = 16.dp) + .alpha(if (enabled) 1f else 0.5f) ) { Text( text = label, @@ -59,6 +62,7 @@ fun LabelTextFieldRow( fontWeight = FontWeight.SemiBold, modifier = Modifier .clickable( + enabled = enabled, indication = null, interactionSource = remember { MutableInteractionSource() } ) { @@ -70,6 +74,7 @@ fun LabelTextFieldRow( CanvasThemedTextField( value = value, onValueChange = onValueChange, + enabled = enabled, singleLine = true, modifier = Modifier .fillMaxWidth() @@ -85,6 +90,7 @@ fun LabelTextFieldRowPreview() { LabelTextFieldRow( label = "Label", value = TextFieldValue("Some text"), - onValueChange = {} + onValueChange = {}, + enabled = true ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/MultipleValuesRow.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/MultipleValuesRow.kt index 1f5278586b..80ceb337b7 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/MultipleValuesRow.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/MultipleValuesRow.kt @@ -42,6 +42,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource @@ -62,7 +63,7 @@ fun MultipleValuesRow( label: String, uiState: MultipleValuesRowState, actionHandler: (MultipleValuesRowAction) -> Unit, - itemComposable: @Composable (T) -> Unit, + itemComposable: @Composable (T, Boolean) -> Unit, modifier: Modifier = Modifier, searchResultComposable: (@Composable (T) -> Unit)? = null, ) { @@ -74,6 +75,7 @@ fun MultipleValuesRow( modifier = modifier .padding(start = 16.dp, end = 16.dp) .defaultMinSize(minHeight = 52.dp) + .alpha(if (uiState.enabled) 1f else 0.5f) ) { Column { Text( @@ -103,12 +105,12 @@ fun MultipleValuesRow( label = animationLabel, targetState = value, ) { - itemComposable(it) + itemComposable(it, uiState.enabled) } } } - if (uiState.isSearchEnabled) { + if (uiState.isSearchEnabled && uiState.enabled) { Spacer(Modifier.height(8.dp)) CanvasThemedTextField( @@ -166,6 +168,7 @@ fun MultipleValuesRow( Spacer(modifier = Modifier.width(8.dp)) IconButton( + enabled = uiState.enabled, onClick = { actionHandler(MultipleValuesRowAction.AddValueClicked) }, modifier = Modifier .size(24.dp) @@ -181,6 +184,7 @@ fun MultipleValuesRow( data class MultipleValuesRowState( val selectedValues: List = emptyList(), + val enabled: Boolean = true, val isLoading: Boolean = false, val isSearchEnabled: Boolean = false, val isShowResults: Boolean = false, @@ -214,7 +218,7 @@ fun LabelMultipleValuesRowPreview() { MultipleValuesRow( label = "To", uiState = uiState, - itemComposable = { user -> + itemComposable = { user, enabled -> Text(user.name ?: "") }, actionHandler = {}, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/OverflowMenu.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/OverflowMenu.kt index 5e56ebfade..bd287d174c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/OverflowMenu.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/OverflowMenu.kt @@ -29,13 +29,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.instructure.pandautils.R -import com.instructure.pandautils.utils.ThemePrefs @Composable fun OverflowMenu( modifier: Modifier = Modifier, showMenu: Boolean, - iconColor: Color = Color(ThemePrefs.primaryTextColor), + iconColor: Color = Color.White, onDismissRequest: () -> Unit, content: @Composable () -> Unit ) { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/TextFieldWithHeader.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/TextFieldWithHeader.kt index 3b71d3a297..20522d0b05 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/TextFieldWithHeader.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/TextFieldWithHeader.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext @@ -51,6 +52,8 @@ fun TextFieldWithHeader( label: String, value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, + enabled: Boolean, + headerEnabled: Boolean, modifier: Modifier = Modifier, @DrawableRes headerIconResource: Int? = null, iconContentDescription: String? = null, @@ -59,9 +62,11 @@ fun TextFieldWithHeader( ) { Column( modifier = modifier + .alpha(if (enabled) 1f else 0.5f) ) { TextFieldHeader( label = label, + enabled = headerEnabled, headerIconResource = headerIconResource, iconContentDescription = iconContentDescription, onIconClick = onIconClick, @@ -77,6 +82,7 @@ fun TextFieldWithHeader( CanvasThemedTextField( value = value, onValueChange = onValueChange, + enabled = enabled, modifier = Modifier .fillMaxSize() .padding(start = 16.dp, end = 16.dp) @@ -89,6 +95,7 @@ fun TextFieldWithHeader( @Composable private fun TextFieldHeader( label: String, + enabled: Boolean, @DrawableRes headerIconResource: Int?, iconContentDescription: String?, onIconClick: (() -> Unit)?, @@ -110,6 +117,7 @@ private fun TextFieldHeader( headerIconResource?.let { icon -> IconButton( + enabled = enabled, onClick = { onIconClick?.invoke() }, modifier = Modifier .size(24.dp) @@ -133,6 +141,8 @@ fun TextFieldWithHeaderPreview() { label = "Label", value = TextFieldValue("Some text"), headerIconResource = R.drawable.ic_attachment, + enabled = true, + headerEnabled = true, onValueChange = {} ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/InboxModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/InboxModule.kt index 31c34ce787..ac203c6527 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/InboxModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/InboxModule.kt @@ -17,7 +17,10 @@ package com.instructure.pandautils.di import android.content.Context +import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.features.inbox.details.InboxDetailsRepository +import com.instructure.pandautils.features.inbox.details.InboxDetailsRepositoryImpl import com.instructure.pandautils.features.inbox.list.InboxEntryItemCreator import dagger.Module import dagger.Provides @@ -33,4 +36,9 @@ class InboxModule { fun provideInboxEntryCreator(@ApplicationContext context: Context, apiPrefs: ApiPrefs): InboxEntryItemCreator { return InboxEntryItemCreator(context, apiPrefs) } + + @Provides + fun provideInboxDetailsRepository(inboxAPI: InboxApi.InboxInterface): InboxDetailsRepository { + return InboxDetailsRepositoryImpl(inboxAPI) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/AttachmentCardItem.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/AttachmentCardItem.kt deleted file mode 100644 index 728115894f..0000000000 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/AttachmentCardItem.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2024 - present Instructure, Inc. - * - * 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. - */ -package com.instructure.pandautils.features.inbox.compose - -import com.instructure.canvasapi2.models.Attachment - -data class AttachmentCardItem ( - val attachment: Attachment, - val status: AttachmentStatus // TODO: Currently this is not used for proper state handling, but if the upload process will be refactored it can be useful -) - -enum class AttachmentStatus { - UPLOADING, - UPLOADED, - FAILED - -} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeFragment.kt index 641fea05e9..6c63195751 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeFragment.kt @@ -15,6 +15,8 @@ */ package com.instructure.pandautils.features.inbox.compose +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -36,6 +38,10 @@ import com.instructure.pandautils.R import com.instructure.pandautils.features.file.upload.FileUploadDialogFragment import com.instructure.pandautils.features.file.upload.FileUploadDialogParent import com.instructure.pandautils.features.inbox.compose.composables.InboxComposeScreenWrapper +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode.FORWARD +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode.NEW_MESSAGE +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode.REPLY +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode.REPLY_ALL import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.collectOneOffEvents import dagger.hilt.android.AndroidEntryPoint @@ -59,7 +65,7 @@ class InboxComposeFragment : Fragment(), FragmentInteractions, FileUploadDialogP setContent { val uiState by viewModel.uiState.collectAsState() - InboxComposeScreenWrapper(uiState, viewModel::handleAction, viewModel::handleAction, viewModel::handleAction) + InboxComposeScreenWrapper(title(), uiState, viewModel::handleAction, viewModel::handleAction, viewModel::handleAction) } } } @@ -67,7 +73,14 @@ class InboxComposeFragment : Fragment(), FragmentInteractions, FileUploadDialogP override val navigation: Navigation? get() = activity as? Navigation - override fun title(): String = getString(R.string.newMessage) + override fun title(): String { + return when(viewModel.uiState.value.inboxComposeMode) { + NEW_MESSAGE -> getString(R.string.newMessage) + REPLY -> getString(R.string.reply) + REPLY_ALL -> getString(R.string.replyAll) + FORWARD -> getString(R.string.forward) + } + } override fun applyTheme() { ViewStyler.themeStatusBar(requireActivity()) @@ -99,6 +112,10 @@ class InboxComposeFragment : Fragment(), FragmentInteractions, FileUploadDialogP is InboxComposeViewModelAction.UpdateParentFragment -> { setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf()) } + is InboxComposeViewModelAction.UrlSelected -> { + val urlIntent = Intent(Intent.ACTION_VIEW, Uri.parse(action.url)) + activity?.startActivity(urlIntent) + } } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeRepository.kt index 03c8b64cab..76440e7afc 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeRepository.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeRepository.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ package com.instructure.pandautils.features.inbox.compose import com.instructure.canvasapi2.models.Attachment @@ -5,6 +20,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.DataResult @@ -13,5 +29,6 @@ interface InboxComposeRepository { suspend fun getGroups(forceRefresh: Boolean = false): DataResult> suspend fun getRecipients(searchQuery: String, context: CanvasContext, forceRefresh: Boolean = false): DataResult> suspend fun createConversation(recipients: List, subject: String, message: String, context: CanvasContext, attachments: List, isIndividual: Boolean): DataResult> + suspend fun addMessage(conversationId: Long, recipients: List, message: String, includedMessages: List, attachments: List, context: CanvasContext, ): DataResult suspend fun canSendToAll(context: CanvasContext): DataResult } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeUiState.kt index c7b3233294..da981e36e8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeUiState.kt @@ -21,12 +21,22 @@ import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.type.EnrollmentType import com.instructure.pandautils.compose.composables.MultipleValuesRowState import com.instructure.pandautils.compose.composables.SelectContextUiState +import com.instructure.pandautils.features.inbox.utils.AttachmentCardItem +import com.instructure.pandautils.features.inbox.utils.AttachmentStatus +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsDisabledFields +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsHiddenFields +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsPreviousMessages import java.util.EnumMap data class InboxComposeUiState( + val inboxComposeMode: InboxComposeOptionsMode = InboxComposeOptionsMode.NEW_MESSAGE, val selectContextUiState: SelectContextUiState = SelectContextUiState(), val recipientPickerUiState: RecipientPickerUiState = RecipientPickerUiState(), val inlineRecipientSelectorState: MultipleValuesRowState = MultipleValuesRowState(isSearchEnabled = true), + val disabledFields: InboxComposeOptionsDisabledFields = InboxComposeOptionsDisabledFields(), + val hiddenFields: InboxComposeOptionsHiddenFields = InboxComposeOptionsHiddenFields(), + val previousMessages: InboxComposeOptionsPreviousMessages? = null, val screenOption: InboxComposeScreenOptions = InboxComposeScreenOptions.None, val sendIndividual: Boolean = false, val subject: TextFieldValue = TextFieldValue(""), @@ -47,6 +57,7 @@ sealed class InboxComposeViewModelAction { data object UpdateParentFragment: InboxComposeViewModelAction() data object OpenAttachmentPicker: InboxComposeViewModelAction() data class ShowScreenResult(val message: String): InboxComposeViewModelAction() + data class UrlSelected(val url: String): InboxComposeViewModelAction() } sealed class InboxComposeActionHandler { @@ -65,6 +76,7 @@ sealed class InboxComposeActionHandler { data object AddAttachmentSelected : InboxComposeActionHandler() data class RemoveAttachment(val attachment: AttachmentCardItem) : InboxComposeActionHandler() data class OpenAttachment(val attachment: AttachmentCardItem) : InboxComposeActionHandler() + data class UrlSelected(val url: String) : InboxComposeActionHandler() } sealed class InboxComposeScreenOptions { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModel.kt index 179d15b4f2..9fbd921855 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModel.kt @@ -17,6 +17,7 @@ package com.instructure.pandautils.features.inbox.compose import android.content.Context import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo @@ -26,6 +27,10 @@ import com.instructure.canvasapi2.type.EnrollmentType import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.displayText import com.instructure.pandautils.R +import com.instructure.pandautils.features.inbox.utils.AttachmentCardItem +import com.instructure.pandautils.features.inbox.utils.AttachmentStatus +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode import com.instructure.pandautils.room.appdatabase.daos.AttachmentDao import com.instructure.pandautils.utils.FileDownloader import com.instructure.pandautils.utils.debounce @@ -46,6 +51,7 @@ import javax.inject.Inject @HiltViewModel class InboxComposeViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, @ApplicationContext private val context: Context, private val fileDownloader: FileDownloader, private val inboxComposeRepository: InboxComposeRepository, @@ -59,6 +65,8 @@ class InboxComposeViewModel @Inject constructor( private val _events = Channel() val events = _events.receiveAsFlow() + private val options = savedStateHandle.get(InboxComposeOptions.COMPOSE_PARAMETERS) + private val debouncedInnerSearch = debounce(waitMs = 200, coroutineScope = viewModelScope) { searchQuery -> val recipients = getRecipientList( searchQuery, @@ -82,6 +90,38 @@ class InboxComposeViewModel @Inject constructor( init { loadContexts() + if (options != null) { + initFromOptions(options) + } + } + + private fun initFromOptions(options: InboxComposeOptions?) { + options?.let { + val context = CanvasContext.fromContextCode(options.defaultValues.contextCode, options.defaultValues.contextName) + context?.let { loadRecipients("", it, false) } + _uiState.update { + it.copy( + inboxComposeMode = options.mode, + previousMessages = options.previousMessages, + selectContextUiState = it.selectContextUiState.copy( + selectedCanvasContext = context + ), + recipientPickerUiState = it.recipientPickerUiState.copy( + selectedRecipients = options.defaultValues.recipients, + ), + inlineRecipientSelectorState = it.inlineRecipientSelectorState.copy( + selectedValues = options.defaultValues.recipients, + enabled = options.disabledFields.isRecipientsDisabled.not(), + ), + disabledFields = options.disabledFields, + hiddenFields = options.hiddenFields, + sendIndividual = options.defaultValues.sendIndividual, + subject = TextFieldValue(options.defaultValues.subject), + body = TextFieldValue(options.defaultValues.body), + attachments = options.defaultValues.attachments.map { attachment -> AttachmentCardItem(attachment, AttachmentStatus.UPLOADED, false) } + ) + } + } } fun updateAttachments(uuid: UUID?, workInfo: WorkInfo) { @@ -91,7 +131,7 @@ class InboxComposeViewModel @Inject constructor( val attachmentEntities = attachmentDao.findByParentId(uuid.toString()) val status = workInfo.state.toAttachmentCardStatus() attachmentEntities?.let { attachmentList -> - _uiState.update { it.copy(attachments = it.attachments + attachmentList.map { AttachmentCardItem(it.toApiModel(), status) }) } + _uiState.update { it.copy(attachments = it.attachments + attachmentList.map { AttachmentCardItem(it.toApiModel(), status, false) }) } attachmentDao.deleteAll(attachmentList) } ?: sendScreenResult(context.getString(R.string.errorUploadingFile)) } ?: sendScreenResult(context.getString(R.string.errorUploadingFile)) @@ -126,7 +166,12 @@ class InboxComposeViewModel @Inject constructor( _uiState.update { it.copy(body = action.body) } } is InboxComposeActionHandler.SendClicked -> { - createConversation() + when(uiState.value.inboxComposeMode) { + InboxComposeOptionsMode.NEW_MESSAGE -> createConversation() + InboxComposeOptionsMode.REPLY -> createMessage() + InboxComposeOptionsMode.REPLY_ALL -> createMessage() + InboxComposeOptionsMode.FORWARD -> createMessage() + } } is InboxComposeActionHandler.SubjectChanged -> { _uiState.update { it.copy(subject = action.subject) } @@ -170,14 +215,19 @@ class InboxComposeViewModel @Inject constructor( } } } - - InboxComposeActionHandler.HideSearchResults -> { + is InboxComposeActionHandler.HideSearchResults -> { _uiState.update { it.copy( inlineRecipientSelectorState = it.inlineRecipientSelectorState.copy( isShowResults = false, ) ) } } + + is InboxComposeActionHandler.UrlSelected -> { + viewModelScope.launch { + _events.send(InboxComposeViewModelAction.UrlSelected(action.url)) + } + } } } @@ -367,6 +417,36 @@ class InboxComposeViewModel @Inject constructor( } } + private fun createMessage() { + uiState.value.selectContextUiState.selectedCanvasContext?.let { canvasContext -> + viewModelScope.launch { + _uiState.update { uiState.value.copy(screenState = ScreenState.Loading) } + + try { + inboxComposeRepository.addMessage( + conversationId = uiState.value.previousMessages?.conversation?.id ?: 0, + recipients = uiState.value.recipientPickerUiState.selectedRecipients, + message = uiState.value.body.text, + includedMessages = uiState.value.previousMessages?.previousMessages ?: emptyList(), + attachments = uiState.value.attachments.map { it.attachment }, + context = canvasContext + ).dataOrThrow + + _events.send(InboxComposeViewModelAction.UpdateParentFragment) + + sendScreenResult(context.getString(R.string.messageSentSuccessfully)) + + handleAction(InboxComposeActionHandler.Close) + + } catch (e: IllegalStateException) { + sendScreenResult(context.getString(R.string.failed_to_send_message)) + } finally { + _uiState.update { uiState.value.copy(screenState = ScreenState.Data) } + } + } + } + } + private fun getAllRecipients(selected: EnrollmentType? = null, roleRecipients: EnumMap>? = null): Recipient? { if (!canSendToAll) return null diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/ContextValueRow.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/ContextValueRow.kt index 65f7b552e7..ff8ae4091c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/ContextValueRow.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/ContextValueRow.kt @@ -31,6 +31,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource @@ -51,6 +52,7 @@ import com.instructure.pandautils.utils.color fun ContextValueRow( label: String, value: CanvasContext?, + enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -58,10 +60,11 @@ fun ContextValueRow( verticalAlignment = Alignment.CenterVertically, modifier = modifier .height(52.dp) - .clickable { onClick() } + .clickable(enabled = enabled) { onClick() } .fillMaxWidth() .padding(start = 16.dp, end = 16.dp) .padding(top = 8.dp, bottom = 8.dp) + .alpha(if (enabled) 1f else 0.5f) ) { Text( text = label, @@ -113,6 +116,7 @@ fun ContextValueRowPreview() { name = "Course 1", courseColor = "#FF0000" ), + enabled = true, onClick = {} ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreen.kt index 96e71c1fa5..7f3a5ce666 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreen.kt @@ -17,6 +17,10 @@ package com.instructure.pandautils.features.inbox.compose.composables import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -26,10 +30,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -37,20 +44,36 @@ import androidx.compose.material.LocalContentAlpha import androidx.compose.material.Scaffold import androidx.compose.material.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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DateHelper import com.instructure.pandares.R import com.instructure.pandautils.compose.CanvasTheme import com.instructure.pandautils.compose.composables.CanvasAppBar @@ -68,6 +91,11 @@ import com.instructure.pandautils.features.inbox.compose.InboxComposeActionHandl import com.instructure.pandautils.features.inbox.compose.InboxComposeUiState import com.instructure.pandautils.features.inbox.compose.RecipientPickerUiState import com.instructure.pandautils.features.inbox.compose.ScreenState +import com.instructure.pandautils.features.inbox.utils.AttachmentCard +import com.instructure.pandautils.features.inbox.utils.AttachmentCardItem +import com.instructure.pandautils.features.inbox.utils.AttachmentStatus +import com.instructure.pandautils.utils.handleUrlAt +import com.instructure.pandautils.utils.linkify @Composable fun InboxComposeScreen( @@ -155,32 +183,48 @@ private fun InboxComposeScreenContent( .padding(padding) .fillMaxSize() ) { - ContextValueRow( - label = stringResource(id = R.string.course), - value = uiState.selectContextUiState.selectedCanvasContext, - onClick = { actionHandler(InboxComposeActionHandler.OpenContextPicker) }, - ) + if (uiState.hiddenFields.isContextHidden.not()) { + ContextValueRow( + label = stringResource(id = R.string.course), + value = uiState.selectContextUiState.selectedCanvasContext, + enabled = uiState.disabledFields.isContextDisabled.not(), + onClick = { actionHandler(InboxComposeActionHandler.OpenContextPicker) }, + ) + } CanvasDivider() - AnimatedVisibility(visible = uiState.selectContextUiState.selectedCanvasContext != null) { + AnimatedVisibility(visible = uiState.selectContextUiState.selectedCanvasContext != null && uiState.hiddenFields.isContextHidden.not()) { Column { MultipleValuesRow( label = stringResource(R.string.recipientsTo), uiState = uiState.inlineRecipientSelectorState, - itemComposable = { - RecipientChip(it) { - actionHandler(InboxComposeActionHandler.RemoveRecipient(it)) + itemComposable = { recipient, enabled -> + RecipientChip(enabled, recipient) { + actionHandler(InboxComposeActionHandler.RemoveRecipient(recipient)) } }, actionHandler = { action -> - when(action) { - is MultipleValuesRowAction.AddValueClicked -> actionHandler(InboxComposeActionHandler.OpenRecipientPicker) + when (action) { + is MultipleValuesRowAction.AddValueClicked -> actionHandler( + InboxComposeActionHandler.OpenRecipientPicker + ) + is MultipleValuesRowAction.SearchValueSelected<*> -> { - (action.value as? Recipient)?.let { actionHandler(InboxComposeActionHandler.AddRecipient(it)) } + (action.value as? Recipient)?.let { + actionHandler( + InboxComposeActionHandler.AddRecipient(it) + ) + } } - is MultipleValuesRowAction.SearchQueryChanges -> actionHandler(InboxComposeActionHandler.SearchRecipientQueryChanged(action.searchQuery)) - is MultipleValuesRowAction.HideSearchResults -> actionHandler(InboxComposeActionHandler.HideSearchResults) + + is MultipleValuesRowAction.SearchQueryChanges -> actionHandler( + InboxComposeActionHandler.SearchRecipientQueryChanged(action.searchQuery) + ) + + is MultipleValuesRowAction.HideSearchResults -> actionHandler( + InboxComposeActionHandler.HideSearchResults + ) } }, searchResultComposable = { recipient -> @@ -209,42 +253,52 @@ private fun InboxComposeScreenContent( } } - LabelSwitchRow( - label = stringResource(R.string.sendIndividualMessage), - checked = uiState.sendIndividual, - onCheckedChange = { - actionHandler(InboxComposeActionHandler.SendIndividualChanged(it)) - }, - ) + if (uiState.hiddenFields.isSendIndividualHidden.not()) { + LabelSwitchRow( + label = stringResource(R.string.sendIndividualMessage), + checked = uiState.sendIndividual, + enabled = uiState.disabledFields.isSendIndividualDisabled.not(), + onCheckedChange = { + actionHandler(InboxComposeActionHandler.SendIndividualChanged(it)) + }, + ) - CanvasDivider() + CanvasDivider() + } - LabelTextFieldRow( - value = uiState.subject, - label = stringResource(R.string.subject), - onValueChange = { - actionHandler(InboxComposeActionHandler.SubjectChanged(it)) - }, - focusRequester = subjectFocusRequester, - ) + if (uiState.hiddenFields.isSubjectHidden.not()) { + LabelTextFieldRow( + value = uiState.subject, + label = stringResource(R.string.subject), + onValueChange = { + actionHandler(InboxComposeActionHandler.SubjectChanged(it)) + }, + enabled = uiState.disabledFields.isSubjectDisabled.not(), + focusRequester = subjectFocusRequester, + ) - CanvasDivider() + CanvasDivider() + } - TextFieldWithHeader( - label = stringResource(R.string.message), - value = uiState.body, - headerIconResource = R.drawable.ic_attachment, - iconContentDescription = stringResource(id = R.string.a11y_addAttachment), - onValueChange = { - actionHandler(InboxComposeActionHandler.BodyChanged(it)) - }, - onIconClick = { - actionHandler(InboxComposeActionHandler.AddAttachmentSelected) - }, - focusRequester = bodyFocusRequester, - modifier = Modifier - .defaultMinSize(minHeight = 100.dp) - ) + if (uiState.hiddenFields.isBodyHidden.not()) { + TextFieldWithHeader( + label = stringResource(R.string.message), + value = uiState.body, + enabled = uiState.disabledFields.isBodyDisabled.not(), + headerEnabled = uiState.disabledFields.isAttachmentDisabled.not(), + headerIconResource = if (uiState.hiddenFields.isBodyHidden) null else R.drawable.ic_attachment, + iconContentDescription = if (uiState.hiddenFields.isBodyHidden) null else stringResource(id = R.string.a11y_addAttachment), + onValueChange = { + actionHandler(InboxComposeActionHandler.BodyChanged(it)) + }, + onIconClick = { + actionHandler(InboxComposeActionHandler.AddAttachmentSelected) + }, + focusRequester = bodyFocusRequester, + modifier = Modifier + .defaultMinSize(minHeight = 100.dp) + ) + } Column { uiState.attachments.forEach { attachment -> @@ -252,10 +306,140 @@ private fun InboxComposeScreenContent( attachmentCardItem = attachment, onSelect = { actionHandler(InboxComposeActionHandler.OpenAttachment(attachment)) }, onRemove = { actionHandler(InboxComposeActionHandler.RemoveAttachment(attachment)) }, - context = LocalContext.current, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(vertical = 8.dp) ) } } + + uiState.previousMessages?.let { previousMessages -> + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .animateContentSize() + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + stringResource(com.instructure.pandautils.R.string.previousMessages), + color = colorResource(id = R.color.textDarkest), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CanvasDivider() + + Spacer(modifier = Modifier.height(16.dp)) + + previousMessages.previousMessages.forEach { message -> + PreviousMessageView(previousMessages.conversation, message, actionHandler) + + Spacer(modifier = Modifier.height(16.dp)) + + CanvasDivider() + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +@Composable +private fun PreviousMessageView( + conversation: Conversation, + message: Message, + actionHandler: (InboxComposeActionHandler) -> Unit +) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + val rotationAnimation by animateFloatAsState( + targetValue = if (isExpanded) 180F else 0F, + animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing), + label = "messageExpandIconRotation" + + ) + + Column( + modifier = Modifier + .animateContentSize() + .testTag("previousMessageView") + ) { + Column( + modifier = Modifier + .clickable { + isExpanded = !isExpanded + } + ){ + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = conversation.participants.firstOrNull { it.id == message.authorId }?.name + ?: "", + color = colorResource(id = R.color.textDarkest), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = DateHelper.getFormattedDate(LocalContext.current, DateHelper.stringToDateWithMillis(message.createdAt)) ?: "", + color = colorResource(id = R.color.textDark), + fontSize = 12.sp, + ) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = null, + tint = colorResource(id = R.color.textDark), + modifier = Modifier + .rotate(rotationAnimation) + ) + } + val annotatedBody = message.body?.linkify( + SpanStyle( + color = colorResource(id = com.instructure.pandautils.R.color.textInfo), + textDecoration = TextDecoration.Underline + ) + ) ?: AnnotatedString("") + + SelectionContainer { + ClickableText( + text = annotatedBody, + onClick = { + annotatedBody.handleUrlAt(it) { + actionHandler(InboxComposeActionHandler.UrlSelected(it)) + } + }, + style = TextStyle.Default.copy( + color = colorResource(id = R.color.textDark), + fontSize = 14.sp, + ), + maxLines = if (isExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (message.attachments.isNotEmpty() && isExpanded) { + Spacer(modifier = Modifier.height(8.dp)) + + message.attachments + .map { AttachmentCardItem(it, AttachmentStatus.UPLOADED, true) } + .forEach { attachment -> + AttachmentCard( + attachmentCardItem = attachment, + onSelect = { actionHandler(InboxComposeActionHandler.OpenAttachment(attachment)) }, + onRemove = {}, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreenWrapper.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreenWrapper.kt index d6651fc6ce..c85fc1d0cd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreenWrapper.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/InboxComposeScreenWrapper.kt @@ -34,6 +34,7 @@ import com.instructure.pandautils.utils.isGroup @Composable fun InboxComposeScreenWrapper( + title: String, uiState: InboxComposeUiState, inboxComposeActionHandler: (InboxComposeActionHandler) -> Unit, contextPickerActionHandler: (ContextPickerActionHandler) -> Unit, @@ -84,7 +85,7 @@ fun InboxComposeScreenWrapper( when (screenOption) { InboxComposeScreenOptions.None -> { InboxComposeScreen( - title = stringResource(id = R.string.newMessage), + title = title, uiState = uiState ) { action -> inboxComposeActionHandler(action) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientChip.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientChip.kt index 7da37cdb29..a0217adef5 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientChip.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientChip.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -46,6 +47,7 @@ import com.instructure.pandautils.compose.composables.UserAvatar @OptIn(ExperimentalMaterialApi::class) @Composable fun RecipientChip( + enabled: Boolean, recipient: Recipient, onRemove: () -> Unit = {} ) { @@ -55,6 +57,7 @@ fun RecipientChip( .clip(CircleShape) .background(colorResource(R.color.backgroundLightest)) .border(1.dp, colorResource(R.color.borderMedium), CircleShape) + .testTag("recipientChip") ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -78,6 +81,7 @@ fun RecipientChip( Spacer(Modifier.width(4.dp)) IconButton( + enabled = enabled, onClick = { onRemove() }, modifier = Modifier.size(20.dp) ) { @@ -98,6 +102,7 @@ fun RecipientChip( @Preview fun RecipientChipPreview() { RecipientChip( + enabled = true, recipient = Recipient( name = "John Doe", avatarURL = null diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientPickerScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientPickerScreen.kt index 62b1ed9c85..03f423d9e6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientPickerScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/RecipientPickerScreen.kt @@ -19,6 +19,7 @@ package com.instructure.pandautils.features.inbox.compose.composables import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +31,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -254,46 +257,43 @@ private fun RecipientPickerPeopleScreen( fun StateScreen( uiState: RecipientPickerUiState, ) { - LazyColumn( - Modifier.fillMaxSize() + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) ) { when (uiState.screenState) { is ScreenState.Loading -> { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - ) { - Loading() - } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + ) { + Loading() } } is ScreenState.Error -> { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - ) { - ErrorContent(errorMessage = stringResource(id = R.string.failedToLoadRecipients)) - } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + ) { + ErrorContent(errorMessage = stringResource(id = R.string.failedToLoadRecipients)) } } is ScreenState.Empty -> { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - ) { - EmptyContent( - emptyMessage = stringResource(id = R.string.noRecipients), - imageRes = R.drawable.ic_panda_nothing_to_see - ) - } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + ) { + EmptyContent( + emptyMessage = stringResource(id = R.string.noRecipients), + imageRes = R.drawable.ic_panda_nothing_to_see + ) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsFragment.kt new file mode 100644 index 0000000000..e6394a747c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsFragment.kt @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.instructure.interactions.FragmentInteractions +import com.instructure.interactions.Navigation +import com.instructure.pandautils.R +import com.instructure.pandautils.features.inbox.compose.InboxComposeFragment +import com.instructure.pandautils.features.inbox.details.composables.InboxDetailsScreen +import com.instructure.pandautils.features.inbox.list.InboxRouter +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.collectOneOffEvents +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class InboxDetailsFragment : Fragment(), FragmentInteractions { + + private val viewModel: InboxDetailsViewModel by viewModels() + + @Inject + lateinit var inboxRouter: InboxRouter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + applyTheme() + viewLifecycleOwner.lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) + + return ComposeView(requireActivity()).apply { + setContent { + val uiState by viewModel.uiState.collectAsState() + + InboxDetailsScreen(title(), uiState, viewModel::messageActionHandler, viewModel::handleAction) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupFragmentResultListener() + } + + private fun setupFragmentResultListener() { + setFragmentResultListener(InboxComposeFragment.FRAGMENT_RESULT_KEY) { key, bundle -> + if (key == InboxComposeFragment.FRAGMENT_RESULT_KEY) { + viewModel.handleAction(InboxDetailsAction.RefreshCalled) + viewModel.refreshParentFragment() + } + } + } + + override val navigation: Navigation? + get() = activity as? Navigation + + override fun title(): String = getString(R.string.message) + + override fun applyTheme() { + ViewStyler.setStatusBarDark(requireActivity(), ThemePrefs.primaryColor) + } + + override fun getFragment(): Fragment { + return this + } + + private fun handleAction(action: InboxDetailsFragmentAction) { + when (action) { + is InboxDetailsFragmentAction.CloseFragment -> { + activity?.supportFragmentManager?.popBackStack() + } + is InboxDetailsFragmentAction.ShowScreenResult -> { + Toast.makeText(requireContext(), action.message, Toast.LENGTH_SHORT).show() + } + is InboxDetailsFragmentAction.UrlSelected -> { + try { + val urlIntent = Intent(Intent.ACTION_VIEW, Uri.parse(action.url)) + activity?.startActivity(urlIntent) + } catch (e: Exception) { + Toast.makeText(requireContext(), R.string.inboxMessageFailedToOpenUrl, Toast.LENGTH_SHORT).show() + } + } + is InboxDetailsFragmentAction.UpdateParentFragment -> { + setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf()) + } + is InboxDetailsFragmentAction.NavigateToCompose -> { + inboxRouter.routeToCompose(action.options) + } + } + } + + companion object { + const val CONVERSATION_ID = "conversation_id" + const val FRAGMENT_RESULT_KEY = "InboxDetailsFragmentResultKey" + + fun newInstance(): InboxDetailsFragment { + return InboxDetailsFragment() + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepository.kt new file mode 100644 index 0000000000..a7535df8ab --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepository.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.InboxApi +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.utils.DataResult + +interface InboxDetailsRepository { + suspend fun getConversation(conversationId: Long, markAsRead: Boolean = true, forceRefresh: Boolean = false): DataResult + suspend fun deleteConversation(conversationId: Long): DataResult + suspend fun deleteMessage(conversationId: Long, messageIds: List): DataResult + suspend fun updateStarred(conversationId: Long, isStarred: Boolean): DataResult + suspend fun updateState(conversationId: Long, state: Conversation.WorkflowState): DataResult +} + +class InboxDetailsRepositoryImpl( + private val inboxAPI: InboxApi.InboxInterface, +): InboxDetailsRepository { + override suspend fun getConversation(conversationId: Long, markAsRead: Boolean, forceRefresh: Boolean): DataResult { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return inboxAPI.getConversation(conversationId, markAsRead, params) + } + + override suspend fun deleteConversation(conversationId: Long): DataResult { + val params = RestParams() + return inboxAPI.deleteConversation(conversationId, params) + } + + override suspend fun deleteMessage( + conversationId: Long, + messageIds: List + ): DataResult { + val params = RestParams() + deleteConversationCache(conversationId) + return inboxAPI.deleteMessages(conversationId, messageIds, params) + } + + override suspend fun updateStarred( + conversationId: Long, + isStarred: Boolean + ): DataResult { + val params = RestParams() + deleteConversationCache(conversationId) + return inboxAPI.updateConversation(conversationId, null, isStarred, params) + } + + override suspend fun updateState( + conversationId: Long, + state: Conversation.WorkflowState + ): DataResult { + val params = RestParams() + deleteConversationCache(conversationId) + return inboxAPI.updateConversation(conversationId, state.apiString, null, params) + } + + private fun deleteConversationCache(conversationId: Long) { + CanvasRestAdapter.clearCacheUrls("conversations/$conversationId") + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsUiState.kt new file mode 100644 index 0000000000..826c397c23 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsUiState.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions +import com.instructure.pandautils.features.inbox.utils.InboxMessageUiState + +data class InboxDetailsUiState( + val conversationId: Long? = null, + val conversation: Conversation? = null, + val messageStates: List = emptyList(), + val state: ScreenState = ScreenState.Loading, + val confirmationDialogState: ConfirmationDialogState = ConfirmationDialogState() +) + +data class ConfirmationDialogState( + val showDialog: Boolean = false, + val title: String = "", + val message: String = "", + val positiveButton: String = "", + val negativeButton: String = "", + val onPositiveButtonClick: () -> Unit = {}, + val onNegativeButtonClick: () -> Unit = {} + +) + +sealed class InboxDetailsFragmentAction { + data object CloseFragment : InboxDetailsFragmentAction() + data class ShowScreenResult(val message: String) : InboxDetailsFragmentAction() + data class UrlSelected(val url: String) : InboxDetailsFragmentAction() + data object UpdateParentFragment : InboxDetailsFragmentAction() + data class NavigateToCompose(val options: InboxComposeOptions) : InboxDetailsFragmentAction() +} + +sealed class InboxDetailsAction { + data object CloseFragment : InboxDetailsAction() + data object RefreshCalled : InboxDetailsAction() + data class Reply(val message: Message) : InboxDetailsAction() + data class ReplyAll(val message: Message) : InboxDetailsAction() + data class Forward(val message: Message) : InboxDetailsAction() + data class DeleteConversation(val conversationId: Long) : InboxDetailsAction() + data class DeleteMessage(val conversationId: Long, val message: Message) : InboxDetailsAction() + data class UpdateState(val conversationId: Long, val workflowState: Conversation.WorkflowState) : InboxDetailsAction() + data class UpdateStarred(val conversationId: Long, val newStarValue: Boolean) : InboxDetailsAction() +} + +sealed class ScreenState { + data object Loading : ScreenState() + data object Error : ScreenState() + data object Empty : ScreenState() + data object Success : ScreenState() +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModel.kt new file mode 100644 index 0000000000..d88ad2af18 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModel.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.pandares.R +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions +import com.instructure.pandautils.features.inbox.utils.InboxMessageUiState +import com.instructure.pandautils.features.inbox.utils.MessageAction +import com.instructure.pandautils.utils.FileDownloader +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InboxDetailsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + savedStateHandle: SavedStateHandle, + private val repository: InboxDetailsRepository, + private val fileDownloader: FileDownloader +): ViewModel() { + + val conversationId: Long? = savedStateHandle.get(InboxDetailsFragment.CONVERSATION_ID) + + private val _uiState = MutableStateFlow(InboxDetailsUiState()) + val uiState = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + init { + _uiState.update { it.copy(conversationId = conversationId) } + getConversation() + } + + fun messageActionHandler(action: MessageAction) { + when (action) { + is MessageAction.Reply -> handleAction(InboxDetailsAction.Reply(action.message)) + is MessageAction.ReplyAll -> handleAction(InboxDetailsAction.ReplyAll(action.message)) + is MessageAction.Forward -> handleAction(InboxDetailsAction.Forward(action.message)) + is MessageAction.DeleteMessage -> handleAction(InboxDetailsAction.DeleteMessage(conversationId ?: 0, action.message)) + is MessageAction.OpenAttachment -> { fileDownloader.downloadFileToDevice(action.attachment) } + is MessageAction.UrlSelected -> { + viewModelScope.launch { + _events.send(InboxDetailsFragmentAction.UrlSelected(action.url)) + } + } + } + } + + fun handleAction(action: InboxDetailsAction) { + when (action) { + is InboxDetailsAction.CloseFragment -> { + viewModelScope.launch { + _events.send(InboxDetailsFragmentAction.CloseFragment) + } + } + + InboxDetailsAction.RefreshCalled -> { + getConversation(true) + } + + is InboxDetailsAction.DeleteConversation -> _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState( + showDialog = true, + title = context.getString(R.string.deleteConversation), + message = context.getString(R.string.confirmDeleteConversation), + positiveButton = context.getString(R.string.delete), + negativeButton = context.getString(R.string.cancel), + onPositiveButtonClick = { + deleteConversation(action.conversationId) + _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState()) } + }, + onNegativeButtonClick = { + _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState()) } + } + )) } + is InboxDetailsAction.DeleteMessage -> _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState( + showDialog = true, + title = context.getString(R.string.deleteMessage), + message = context.getString(R.string.confirmDeleteMessage), + positiveButton = context.getString(R.string.delete), + negativeButton = context.getString(R.string.cancel), + onPositiveButtonClick = { + deleteMessage(action.conversationId, action.message) + _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState()) } + }, + onNegativeButtonClick = { + _uiState.update { it.copy(confirmationDialogState = ConfirmationDialogState()) } + } + )) } + is InboxDetailsAction.Forward -> { + viewModelScope.launch { + uiState.value.conversation?.let { + _events.send(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildForward(context, it, action.message))) + } + } + } + is InboxDetailsAction.Reply -> { + viewModelScope.launch { + uiState.value.conversation?.let { + _events.send(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReply(context, it, action.message))) + } + } + } + is InboxDetailsAction.ReplyAll -> { + viewModelScope.launch { + uiState.value.conversation?.let { + _events.send(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReplyAll(context, it, action.message))) + } + } + } + is InboxDetailsAction.UpdateState -> updateState(action.conversationId, action.workflowState) + is InboxDetailsAction.UpdateStarred -> updateStarred(action.conversationId, action.newStarValue) + } + } + + fun refreshParentFragment() { + viewModelScope.launch { _events.send(InboxDetailsFragmentAction.UpdateParentFragment) } + } + + private fun getConversation(forceRefresh: Boolean = false) { + conversationId?.let { + viewModelScope.launch { + _uiState.update { it.copy(state = ScreenState.Loading) } + + val conversationResult = repository.getConversation(conversationId, true, forceRefresh) + + try { + val conversation = conversationResult.dataOrThrow + if (conversation.messages.isEmpty()) { + _uiState.update { it.copy(state = ScreenState.Empty, conversation = conversation) } + } else { + _uiState.update { uiState -> uiState.copy( + state = ScreenState.Success, + conversation = conversation, + messageStates = conversation.messages.map { getMessageViewState(conversation, it) }, + ) } + } + } catch (e: Exception) { + _uiState.update { it.copy(state = ScreenState.Error) } + } + } + } + } + + private fun getMessageViewState(conversation: Conversation, message: Message): InboxMessageUiState { + val author = conversation.participants.find { it.id == message.authorId } + val recipients = conversation.participants.filter { message.participatingUserIds.filter { it != message.authorId }.contains(it.id) } + return InboxMessageUiState( + message = message, + author = author, + recipients = recipients, + enabledActions = true, + cannotReply = conversation.cannotReply, + ) + } + + private fun deleteConversation(conversationId: Long) { + viewModelScope.launch { + val result = repository.deleteConversation(conversationId) + if (result.isSuccess) { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationDeleted))) + _events.send(InboxDetailsFragmentAction.CloseFragment) + + refreshParentFragment() + } else { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationDeletedFailed))) + } + } + } + + private fun deleteMessage(conversationId: Long, message: Message) { + viewModelScope.launch { + val result = repository.deleteMessage(conversationId, listOf(message.id)) + val conversationResult = repository.getConversation(conversationId, true, true) + if (result.isSuccess) { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeleted))) + + val conversation = conversationResult.dataOrNull + + _uiState.update { + it.copy( + conversation = conversation, + messageStates = conversation?.messages?.map { getMessageViewState(conversation, it) } ?: emptyList() + ) + } + + refreshParentFragment() + } else { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeletedFailed))) + } + } + } + + private fun updateStarred(conversationId: Long, isStarred: Boolean) { + viewModelScope.launch { + val result = repository.updateStarred(conversationId, isStarred) + if (result.isSuccess) { + _uiState.update { it.copy(conversation = it.conversation?.copy(isStarred = result.dataOrNull?.isStarred ?: false)) } + + refreshParentFragment() + } else { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationUpdateFailed))) + } + } + } + + private fun updateState(conversationId: Long, state: Conversation.WorkflowState) { + viewModelScope.launch { + val result = repository.updateState(conversationId, state) + if (result.isSuccess) { + _uiState.update { it.copy(conversation = it.conversation?.copy(workflowState = result.dataOrNull?.workflowState)) } + + refreshParentFragment() + } else { + _events.send(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationUpdateFailed))) + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/InboxDetailsScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/InboxDetailsScreen.kt new file mode 100644 index 0000000000..55ed879c10 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/InboxDetailsScreen.kt @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.pandautils.features.inbox.details.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.compose.composables.EmptyContent +import com.instructure.pandautils.compose.composables.ErrorContent +import com.instructure.pandautils.compose.composables.Loading +import com.instructure.pandautils.compose.composables.OverflowMenu +import com.instructure.pandautils.compose.composables.SimpleAlertDialog +import com.instructure.pandautils.features.inbox.details.InboxDetailsAction +import com.instructure.pandautils.features.inbox.details.InboxDetailsUiState +import com.instructure.pandautils.features.inbox.details.ScreenState +import com.instructure.pandautils.features.inbox.utils.InboxMessageUiState +import com.instructure.pandautils.features.inbox.utils.InboxMessageView +import com.instructure.pandautils.features.inbox.utils.MessageAction +import java.time.ZonedDateTime + +@Composable +fun InboxDetailsScreen( + title: String, + uiState: InboxDetailsUiState, + messageActionHandler: (MessageAction) -> Unit, + actionHandler: (InboxDetailsAction) -> Unit +) { + CanvasTheme { + Scaffold( + backgroundColor = colorResource(id = R.color.backgroundLightest), + topBar = { + CanvasThemedAppBar( + title = title, + navIconRes = R.drawable.ic_back_arrow, + navIconContentDescription = stringResource(id = R.string.contentDescription_back), + navigationActionClick = { actionHandler(InboxDetailsAction.CloseFragment) }, + actions = { + AppBarMenu(uiState.conversation, actionHandler) + }, + ) + }, + content = { padding -> + InboxDetailsScreenContent(padding, uiState, messageActionHandler, actionHandler) + } + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun InboxDetailsScreenContent( + padding: PaddingValues, + uiState: InboxDetailsUiState, + messageActionHandler: (MessageAction) -> Unit, + actionHandler: (InboxDetailsAction) -> Unit +) { + val pullToRefreshState = rememberPullRefreshState(refreshing = false, onRefresh = { + actionHandler(InboxDetailsAction.RefreshCalled) + }) + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullToRefreshState) + .padding(padding) + ) { + when (uiState.state) { + ScreenState.Loading -> { + InboxDetailsLoading() + } + + ScreenState.Error -> { + InboxDetailsError(actionHandler) + } + + ScreenState.Empty -> { + InboxDetailsEmpty(actionHandler) + } + + ScreenState.Success -> { + InboxDetailsContentView(uiState, actionHandler, messageActionHandler) + } + } + + PullRefreshIndicator( + refreshing = uiState.state == ScreenState.Loading, + state = pullToRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .testTag("pullRefreshIndicator"), + ) + } +} + +@Composable +private fun InboxDetailsLoading() { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Loading() + } +} + +@Composable +private fun InboxDetailsError(actionHandler: (InboxDetailsAction) -> Unit) { + ErrorContent( + errorMessage = stringResource(R.string.failed_to_load_conversation), + modifier = Modifier.fillMaxSize(), + retryClick = { actionHandler(InboxDetailsAction.RefreshCalled) } + ) +} + +@Composable +private fun InboxDetailsEmpty(actionHandler: (InboxDetailsAction) -> Unit) { + EmptyContent( + emptyMessage = stringResource(R.string.no_messages_found), + imageRes = R.drawable.ic_panda_nocourses, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + buttonText = stringResource(id = R.string.retry), + buttonClick = { actionHandler(InboxDetailsAction.RefreshCalled) } + ) +} + +@Composable +private fun InboxDetailsContentView( + uiState: InboxDetailsUiState, + actionHandler: (InboxDetailsAction) -> Unit, + messageActionHandler: (MessageAction) -> Unit, +) { + val conversation = uiState.conversation + val messages = uiState.messageStates + + if (conversation == null) { + InboxDetailsError(actionHandler) + return + } + + if (uiState.confirmationDialogState.showDialog) { + SimpleAlertDialog( + dialogTitle = uiState.confirmationDialogState.title, + dialogText = uiState.confirmationDialogState.message, + dismissButtonText = uiState.confirmationDialogState.negativeButton, + confirmationButtonText = uiState.confirmationDialogState.positiveButton, + onDismissRequest = uiState.confirmationDialogState.onNegativeButtonClick, + onConfirmation = uiState.confirmationDialogState.onPositiveButtonClick + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = conversation.subject ?: stringResource(id = R.string.message), + color = colorResource(id = R.color.textDarkest), + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .weight(1f) + .padding(16.dp) + ) + + Spacer(Modifier.width(8.dp)) + + IconButton(onClick = { actionHandler(InboxDetailsAction.UpdateStarred(conversation.id, !conversation.isStarred)) }) { + Icon( + painter = if (conversation.isStarred) painterResource(id = R.drawable.ic_star_filled) else painterResource(id = R.drawable.ic_star_outline), + tint = colorResource(id = R.color.textDarkest), + contentDescription = if (conversation.isStarred) stringResource(id = R.string.unstarSelected) else stringResource(id = R.string.starSelected), + modifier = Modifier + .padding(vertical = 16.dp) + ) + } + + Spacer(Modifier.width(4.dp)) + } + + Divider( + color = colorResource(id = R.color.borderLight), + ) + + messages.forEach { messageState -> + InboxMessageView(messageState, messageActionHandler) + + Divider( + color = colorResource(id = R.color.borderLight), + ) + } + } +} + +@Composable +private fun AppBarMenu(conversation: Conversation?, actionHandler: (InboxDetailsAction) -> Unit) { + var showMenu by rememberSaveable { mutableStateOf(false) } + OverflowMenu( + modifier = Modifier + .background(color = colorResource(id = R.color.backgroundLightestElevated)) + .testTag("overFlowMenu"), + showMenu = showMenu, + onDismissRequest = { + showMenu = !showMenu + } + ) { + conversation?.messages?.sortedBy { it.createdAt }?.last()?.let { message -> + if (!conversation.cannotReply){ + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(InboxDetailsAction.Reply(message)) + } + ) { + MessageMenuItem(R.drawable.ic_reply, stringResource(id = R.string.reply)) + } + + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(InboxDetailsAction.ReplyAll(message)) + } + ) { + MessageMenuItem(R.drawable.ic_reply_all, stringResource(id = R.string.replyAll)) + } + } + + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(InboxDetailsAction.Forward(message)) + } + ) { + MessageMenuItem(R.drawable.ic_forward, stringResource(id = R.string.forward)) + } + + if (conversation.workflowState == Conversation.WorkflowState.READ) { + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler( + InboxDetailsAction.UpdateState( + conversation.id, + Conversation.WorkflowState.UNREAD + ) + ) + } + ) { + MessageMenuItem( + R.drawable.ic_mark_as_unread, + stringResource(id = R.string.markAsUnread) + ) + } + } + + if (conversation.workflowState == Conversation.WorkflowState.UNREAD) { + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler( + InboxDetailsAction.UpdateState( + conversation.id, + Conversation.WorkflowState.READ + ) + ) + } + ) { + MessageMenuItem( + R.drawable.ic_mark_as_read, + stringResource(id = R.string.markAsRead) + ) + } + } + + if (conversation.workflowState != Conversation.WorkflowState.ARCHIVED) { + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler( + InboxDetailsAction.UpdateState( + conversation.id, + Conversation.WorkflowState.ARCHIVED + ) + ) + } + ) { + MessageMenuItem(R.drawable.ic_archive, stringResource(id = R.string.archive)) + } + } else { + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler( + InboxDetailsAction.UpdateState( + conversation.id, + Conversation.WorkflowState.READ + ) + ) + } + ) { + MessageMenuItem( + R.drawable.ic_unarchive, + stringResource(id = R.string.unarchive) + ) + } + } + + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(InboxDetailsAction.DeleteConversation(conversation.id)) + } + ) { + MessageMenuItem(R.drawable.ic_trash, stringResource(id = R.string.delete)) + } + } + + } +} + +@Composable +@Preview +fun InboxDetailsScreenLoadingPreview() { + ContextKeeper.appContext = LocalContext.current + + InboxDetailsScreen(title = "Message", actionHandler = {}, messageActionHandler = {}, uiState = InboxDetailsUiState( + conversationId = 1, + conversation = null, + messageStates = emptyList(), + state = ScreenState.Loading + )) +} + +@Composable +@Preview +fun InboxDetailsScreenErrorPreview() { + ContextKeeper.appContext = LocalContext.current + + InboxDetailsScreen(title = "Message", actionHandler = {}, messageActionHandler = {}, uiState = InboxDetailsUiState( + conversationId = 1, + conversation = null, + messageStates = emptyList(), + state = ScreenState.Error + )) +} + +@Composable +@Preview +fun InboxDetailsScreenEmptyPreview() { + ContextKeeper.appContext = LocalContext.current + + InboxDetailsScreen(title = "Message", actionHandler = {}, messageActionHandler = {}, uiState = InboxDetailsUiState( + conversationId = 1, + conversation = Conversation(), + messageStates = emptyList(), + state = ScreenState.Empty + )) +} + +@Composable +@Preview +fun InboxDetailsScreenContentPreview() { + ContextKeeper.appContext = LocalContext.current + + val messages = listOf( + Message( + createdAt = ZonedDateTime.now().toString(), + body = "Message 1", + authorId = 1, + participatingUserIds = listOf(2), + attachments = listOf( + Attachment(filename = "Attachment 1.txt", size = 1452), + ) + ), + Message( + createdAt = ZonedDateTime.now().toString(), + body = "Message 2", + authorId = 2, + participatingUserIds = listOf(1), + attachments = listOf( + Attachment(filename = "Attachment 2.txt", size = 1252), + ) + ), + ) + + val conversation = Conversation( + id = 1, + subject = "Test subject", + messageCount = 2, + messages = messages, + isStarred = true, + participants = mutableListOf( + BasicUser(id = 1, name = "User 1"), + BasicUser(id = 2, name = "User 2"), + ) + ) + + val messageStates = messages.map { message -> + val author = conversation.participants.find { it.id == message.authorId } + val recipients = conversation.participants.filter { message.participatingUserIds.filter { it != message.authorId }.contains(it.id) } + InboxMessageUiState( + message = message, + author = author, + recipients = recipients, + enabledActions = true, + ) + } + + InboxDetailsScreen(title = "Message", actionHandler = {}, messageActionHandler = {}, uiState = InboxDetailsUiState( + conversationId = 1, + conversation = conversation, + messageStates = messageStates, + state = ScreenState.Success + )) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/MessageMenuItem.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/MessageMenuItem.kt new file mode 100644 index 0000000000..0f3a4ddf8b --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/details/composables/MessageMenuItem.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details.composables + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.instructure.pandautils.R + +@Composable +fun MessageMenuItem( + @DrawableRes iconRes: Int, + label: String +) { + Row { + Icon( + painter = painterResource(id = iconRes), + tint = colorResource(id = R.color.textDarkest), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Text( + text = label, + color = colorResource(id = R.color.textDarkest), + modifier = Modifier + .padding(start = 8.dp) + .testTag("messageMenuItem") + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt index 9d4c56471c..9effa44d21 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt @@ -59,6 +59,7 @@ import com.instructure.pandautils.binding.BindableViewHolder import com.instructure.pandautils.databinding.FragmentInboxBinding import com.instructure.pandautils.databinding.ItemInboxEntryBinding import com.instructure.pandautils.features.inbox.compose.InboxComposeFragment +import com.instructure.pandautils.features.inbox.details.InboxDetailsFragment import com.instructure.pandautils.features.inbox.list.filter.ContextFilterFragment import com.instructure.pandautils.features.inbox.list.itemviewmodels.InboxEntryItemViewModel import com.instructure.pandautils.interfaces.NavigationCallbacks @@ -153,6 +154,9 @@ class InboxFragment : Fragment(), NavigationCallbacks, FragmentInteractions { setFragmentResultListener(InboxComposeFragment.FRAGMENT_RESULT_KEY) { key, bundle -> if (key == InboxComposeFragment.FRAGMENT_RESULT_KEY) { conversationUpdated() } } + setFragmentResultListener(InboxDetailsFragment.FRAGMENT_RESULT_KEY) { key, bundle -> + if (key == InboxDetailsFragment.FRAGMENT_RESULT_KEY) { conversationUpdated() } + } } private fun configureItemTouchHelper() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxRouter.kt index f9d3eda09f..962989accc 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxRouter.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxRouter.kt @@ -19,6 +19,7 @@ package com.instructure.pandautils.features.inbox.list import androidx.appcompat.widget.Toolbar import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.models.Conversation +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions interface InboxRouter { @@ -28,5 +29,7 @@ interface InboxRouter { fun routeToNewMessage() + fun routeToCompose(options: InboxComposeOptions) + fun avatarClicked(conversation: Conversation, scope: InboxApi.Scope) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/AttachmentCard.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt similarity index 66% rename from libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/AttachmentCard.kt rename to libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt index 0a94ace0d9..2e847816e8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/compose/composables/AttachmentCard.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt @@ -1,24 +1,23 @@ /* * Copyright (C) 2024 - present Instructure, Inc. * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. + * 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 * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * 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. */ -package com.instructure.pandautils.features.inbox.compose.composables +package com.instructure.pandautils.features.inbox.utils -import android.content.Context import android.text.format.Formatter import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,7 +26,6 @@ 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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -52,17 +50,15 @@ import com.bumptech.glide.integration.compose.GlideImage import com.instructure.canvasapi2.models.Attachment import com.instructure.pandautils.R import com.instructure.pandautils.compose.composables.Loading -import com.instructure.pandautils.features.inbox.compose.AttachmentCardItem -import com.instructure.pandautils.features.inbox.compose.AttachmentStatus import com.instructure.pandautils.utils.iconRes @OptIn(ExperimentalGlideComposeApi::class) @Composable fun AttachmentCard( attachmentCardItem: AttachmentCardItem, - context: Context, onSelect: () -> Unit, - onRemove: () -> Unit + onRemove: () -> Unit, + modifier: Modifier = Modifier ) { val attachment = attachmentCardItem.attachment val status = attachmentCardItem.status @@ -71,9 +67,7 @@ fun AttachmentCard( backgroundColor = colorResource(id = com.instructure.pandares.R.color.backgroundLightest), border = BorderStroke(1.dp, colorResource(id = R.color.backgroundMedium)), shape = RoundedCornerShape(10.dp), - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(vertical = 8.dp) + modifier = modifier .clickable { onSelect() } ) { Row( @@ -85,13 +79,16 @@ fun AttachmentCard( contentAlignment = Alignment.Center, modifier = Modifier .size(96.dp) + .background(colorResource(id = R.color.backgroundLight)) ){ if (attachment.thumbnailUrl != null) { GlideImage( model = attachment.thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.backgroundLight)) ) } else { Icon( @@ -112,7 +109,7 @@ fun AttachmentCard( Text( attachment.filename ?: "", color = colorResource(id = R.color.textDarkest), - fontSize = 20.sp, + fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -120,43 +117,47 @@ fun AttachmentCard( Spacer(Modifier.height(8.dp)) Text( - Formatter.formatFileSize(context, attachment.size), + Formatter.formatFileSize(LocalContext.current, attachment.size), color = colorResource(id = R.color.textDark), - fontSize = 16.sp, + fontSize = 14.sp, ) } Spacer(modifier = Modifier.width(8.dp)) - when (status) { - AttachmentStatus.UPLOADING -> { - Loading() - } - AttachmentStatus.UPLOADED -> { - Icon( - painter = painterResource(id = R.drawable.ic_complete), - contentDescription = null, - tint = colorResource(id = R.color.textDark) - ) + if (!attachmentCardItem.readOnly){ + when (status) { + AttachmentStatus.UPLOADING -> { + Loading() + } + + AttachmentStatus.UPLOADED -> { + Icon( + painter = painterResource(id = R.drawable.ic_complete), + contentDescription = null, + tint = colorResource(id = R.color.textDark) + ) + } + + AttachmentStatus.FAILED -> { + Icon( + painter = painterResource(id = R.drawable.ic_no), + contentDescription = null, + tint = colorResource(id = R.color.textDark) + ) + } } - AttachmentStatus.FAILED -> { + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton(onClick = { onRemove() }) { Icon( - painter = painterResource(id = R.drawable.ic_no), - contentDescription = null, - tint = colorResource(id = R.color.textDark) + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(R.string.removeAttachment), + tint = colorResource(id = R.color.textDark), ) } } - - Spacer(modifier = Modifier.width(8.dp)) - - IconButton(onClick = { onRemove() }) { - Icon( - painter = painterResource(id = R.drawable.ic_close), - contentDescription = stringResource(id = R.string.a11y_removeAttachment), - tint = colorResource(id = R.color.textDark), - ) - } } } } @@ -177,10 +178,10 @@ fun AttachmentCardPreview() { previewUrl = null, size = 1024 ), - AttachmentStatus.UPLOADED + AttachmentStatus.UPLOADED, + false ), - context, {}, - {} + {}, ) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCardItem.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCardItem.kt new file mode 100644 index 0000000000..a52a409c13 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCardItem.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.utils + +import androidx.work.WorkInfo +import com.instructure.canvasapi2.models.Attachment + +data class AttachmentCardItem ( + val attachment: Attachment, + val status: AttachmentStatus, // TODO: Currently this is not used for proper state handling, but if the upload process will be refactored it can be useful + val readOnly: Boolean +) + +enum class AttachmentStatus { + UPLOADING, + UPLOADED, + FAILED + + ; + + companion object { + fun fromWorkInfoState(state: WorkInfo.State): AttachmentStatus { + return when (state) { + WorkInfo.State.SUCCEEDED -> AttachmentStatus.UPLOADED + WorkInfo.State.FAILED -> AttachmentStatus.FAILED + WorkInfo.State.ENQUEUED -> AttachmentStatus.UPLOADING + WorkInfo.State.RUNNING -> AttachmentStatus.UPLOADING + WorkInfo.State.BLOCKED -> AttachmentStatus.FAILED + WorkInfo.State.CANCELLED -> AttachmentStatus.FAILED + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptions.kt new file mode 100644 index 0000000000..7fd11e1003 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptions.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.utils + +import android.content.Context +import android.os.Parcelable +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.canvasapi2.models.Recipient +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.R +import kotlinx.parcelize.Parcelize +import java.time.ZonedDateTime + +@Parcelize +data class InboxComposeOptions( + val mode: InboxComposeOptionsMode = InboxComposeOptionsMode.NEW_MESSAGE, + val previousMessages: InboxComposeOptionsPreviousMessages? = null, + val disabledFields: InboxComposeOptionsDisabledFields = InboxComposeOptionsDisabledFields(), + val hiddenFields: InboxComposeOptionsHiddenFields = InboxComposeOptionsHiddenFields(), + val defaultValues: InboxComposeOptionsDefaultValues = InboxComposeOptionsDefaultValues(), +): Parcelable { + companion object { + const val COMPOSE_PARAMETERS = "InboxComposeOptions" + fun buildNewMessage(): InboxComposeOptions { + return InboxComposeOptions( + mode = InboxComposeOptionsMode.NEW_MESSAGE + ) + } + + fun buildReply(context: Context, conversation: Conversation, selectedMessage: Message): InboxComposeOptions { + val currentUser = ApiPrefs.user?.id + val recipients = if (selectedMessage.authorId == currentUser) { + conversation.participants.filter { it.id != currentUser } + } else { + conversation.participants.filter { it.id == selectedMessage.authorId } + } + + return InboxComposeOptions( + mode = InboxComposeOptionsMode.REPLY, + previousMessages = InboxComposeOptionsPreviousMessages( + conversation, + conversation.messages + .filter { + if (it.createdAt != null && selectedMessage.createdAt != null) + ZonedDateTime.parse(it.createdAt) <= ZonedDateTime.parse(selectedMessage.createdAt) + else + true + } + ), + disabledFields = InboxComposeOptionsDisabledFields(isContextDisabled = true, isSubjectDisabled = true), + hiddenFields = InboxComposeOptionsHiddenFields(isSendIndividualHidden = true), + defaultValues = InboxComposeOptionsDefaultValues( + contextCode = conversation.contextCode, + contextName = conversation.contextName, + recipients = recipients.map { Recipient(it.id.toString(), it.name, it.avatarUrl) }, + subject = context.getString( + R.string.inboxReplySubjectRePrefix, + conversation.subject + ), + ) + ) + } + + fun buildReplyAll(context: Context, conversation: Conversation, selectedMessage: Message): InboxComposeOptions { + val currentUser = ApiPrefs.user?.id + val recipients = conversation.participants.filter { it.id != currentUser } + + return InboxComposeOptions( + mode = InboxComposeOptionsMode.REPLY_ALL, + previousMessages = InboxComposeOptionsPreviousMessages( + conversation, + conversation.messages + .filter { + if (it.createdAt != null && selectedMessage.createdAt != null) + ZonedDateTime.parse(it.createdAt) <= ZonedDateTime.parse(selectedMessage.createdAt) + else + true + } + ), + disabledFields = InboxComposeOptionsDisabledFields(isContextDisabled = true, isSubjectDisabled = true), + hiddenFields = InboxComposeOptionsHiddenFields(isSendIndividualHidden = true), + defaultValues = InboxComposeOptionsDefaultValues( + contextCode = conversation.contextCode, + contextName = conversation.contextName, + recipients = recipients.map { Recipient(it.id.toString(), it.name, it.avatarUrl) }, + subject = context.getString( + R.string.inboxReplySubjectRePrefix, + conversation.subject + ), + ) + ) + } + + fun buildForward(context: Context, conversation: Conversation, selectedMessage: Message): InboxComposeOptions { + return InboxComposeOptions( + mode = InboxComposeOptionsMode.FORWARD, + previousMessages = InboxComposeOptionsPreviousMessages( + conversation, + conversation.messages + .filter { + if (it.createdAt != null && selectedMessage.createdAt != null) + ZonedDateTime.parse(it.createdAt) <= ZonedDateTime.parse(selectedMessage.createdAt) + else + true + } + ), + disabledFields = InboxComposeOptionsDisabledFields(isContextDisabled = true, isSubjectDisabled = true), + hiddenFields = InboxComposeOptionsHiddenFields(isSendIndividualHidden = true), + defaultValues = InboxComposeOptionsDefaultValues( + contextCode = conversation.contextCode, + contextName = conversation.contextName, + subject = context.getString( + R.string.inboxForwardSubjectFwPrefix, + conversation.subject + ), + ) + ) + } + } +} + +@Parcelize +data class InboxComposeOptionsDisabledFields( + val isContextDisabled: Boolean = false, + val isRecipientsDisabled: Boolean = false, + val isSendIndividualDisabled: Boolean = false, + val isSubjectDisabled: Boolean = false, + val isBodyDisabled: Boolean = false, + val isAttachmentDisabled: Boolean = false, +): Parcelable + +@Parcelize +data class InboxComposeOptionsHiddenFields( + val isContextHidden: Boolean = false, + val isRecipientsHidden: Boolean = false, + val isSendIndividualHidden: Boolean = false, + val isSubjectHidden: Boolean = false, + val isBodyHidden: Boolean = false, + val isAttachmentHidden: Boolean = false, +): Parcelable + +@Parcelize +data class InboxComposeOptionsDefaultValues( + val contextCode: String? = null, + val contextName: String? = null, + val recipients: List = emptyList(), + val sendIndividual: Boolean = false, + val subject: String = "", + val body: String = "", + val attachments: List = emptyList(), +): Parcelable + +@Parcelize +data class InboxComposeOptionsPreviousMessages( + val conversation: Conversation, + val previousMessages: List, +): Parcelable + +enum class InboxComposeOptionsMode { + REPLY, + REPLY_ALL, + FORWARD, + NEW_MESSAGE, +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageUiState.kt new file mode 100644 index 0000000000..a3e743526c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageUiState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.utils + +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Message + +data class InboxMessageUiState( + val message: Message? = null, + val author: BasicUser? = null, + val recipients: List = emptyList(), + val enabledActions: Boolean = true, + val cannotReply: Boolean = false +) + +sealed class MessageAction { + data class Reply(val message: Message) : MessageAction() + data class ReplyAll(val message: Message) : MessageAction() + data class Forward(val message: Message) : MessageAction() + data class DeleteMessage(val message: Message) : MessageAction() + data class OpenAttachment(val attachment: Attachment) : MessageAction() + data class UrlSelected(val url: String) : MessageAction() +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageView.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageView.kt new file mode 100644 index 0000000000..2e7edde159 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/InboxMessageView.kt @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.pandautils.features.inbox.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Message +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toDate +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.composables.OverflowMenu +import com.instructure.pandautils.compose.composables.UserAvatar +import com.instructure.pandautils.features.inbox.details.composables.MessageMenuItem +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.handleUrlAt +import com.instructure.pandautils.utils.linkify +import java.time.ZonedDateTime + +@Composable +fun InboxMessageView( + messageState: InboxMessageUiState, + actionHandler: (MessageAction) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + InboxMessageAuthorView(messageState, actionHandler) + + Spacer(Modifier.height(16.dp)) + + InboxMessageDetailsView(messageState, actionHandler) + } +} + +@Composable +private fun InboxMessageDetailsView( + messageState: InboxMessageUiState, + actionHandler: (MessageAction) -> Unit +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + val annotatedString = messageState.message?.body?.linkify( + SpanStyle( + color = colorResource(id = R.color.textInfo), + textDecoration = TextDecoration.Underline + ) + ) ?: AnnotatedString("") + SelectionContainer { + ClickableText( + text = annotatedString, + onClick = { + annotatedString.handleUrlAt(it) { + actionHandler(MessageAction.UrlSelected(it)) + } + }, + style = TextStyle.Default.copy( + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest) + ) + ) + } + + messageState.message?.attachments?.forEach { attachment -> + Spacer(modifier = Modifier.height(16.dp)) + + val attachmentCardItem = AttachmentCardItem(attachment, AttachmentStatus.UPLOADED, true) + AttachmentCard( + attachmentCardItem, + onSelect = { actionHandler(MessageAction.OpenAttachment(attachment)) }, + onRemove = {}) + } + + if (messageState.enabledActions && !messageState.cannotReply) { + Spacer(modifier = Modifier.height(16.dp)) + + TextButton( + onClick = { messageState.message?.let { actionHandler( MessageAction.Reply(it) ) } }, + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(id = R.color.backgroundLightest), + contentColor = Color(ThemePrefs.brandColor) + ), + contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + content = { + Text( + text = stringResource(id = R.string.reply), + modifier = Modifier.offset(x = (-8).dp) // Remove button's default padding + ) + }, + ) + } + } +} + +@Composable +private fun InboxMessageAuthorView( + messageState: InboxMessageUiState, + actionHandler: (MessageAction) -> Unit +) { + val author = messageState.author + val message = messageState.message + + var recipientsExpanded by rememberSaveable { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + ) { + UserAvatar( + imageUrl = author?.avatarUrl, + name = author?.name ?: "", + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + modifier = Modifier + .weight(1f) + .clickable { recipientsExpanded = !recipientsExpanded } + ) { + val recipientText = if (recipientsExpanded) { + messageState.recipients.map { it.name }.joinToString(", ") + } else { + if (messageState.recipients.size > 1) { + stringResource( + R.string.inboxMessageRecipientsText, + messageState.recipients[0].name ?: "", + messageState.recipients.size - 1 + ) + } else { + messageState.recipients[0].name + } + } + + Text( + text = stringResource( + R.string.inboxMessageAuthorAndRecipientsLabel, + author?.name ?: "", + recipientText ?:"" + ), + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest) + ) + + Text( + text = DateHelper.getDateTimeString(LocalContext.current, message?.createdAt.toDate()) ?: "", + fontSize = 14.sp, + color = colorResource(id = R.color.textDark) + ) + } + + if (messageState.enabledActions) { + if (!messageState.cannotReply) { + IconButton(onClick = { + messageState.message?.let { actionHandler(MessageAction.Reply(it)) } + }) { + Icon( + painter = painterResource(id = R.drawable.ic_reply), + contentDescription = stringResource(id = R.string.reply), + tint = colorResource(id = R.color.textDarkest) + ) + } + } + + message?.let { + MessageMenu(it, messageState.cannotReply, actionHandler) + } + } + } +} + +@Composable +private fun MessageMenu(message: Message, cannotReply: Boolean, actionHandler: (MessageAction) -> Unit) { + var showMenu by rememberSaveable { mutableStateOf(false) } + Box( + contentAlignment = Alignment.CenterEnd, + ){ + OverflowMenu( + modifier = Modifier + .background(color = colorResource(id = R.color.backgroundLightestElevated)), + showMenu = showMenu, + iconColor = colorResource(id = R.color.textDarkest), + onDismissRequest = { + showMenu = !showMenu + } + ) { + if (!cannotReply) { + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(MessageAction.Reply(message)) + } + ) { + MessageMenuItem(R.drawable.ic_reply, stringResource(id = R.string.reply)) + } + } + + if (!cannotReply){ + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(MessageAction.ReplyAll(message)) + } + ) { + MessageMenuItem(R.drawable.ic_reply_all, stringResource(id = R.string.replyAll)) + } + } + + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(MessageAction.Forward(message)) + } + ) { + MessageMenuItem(R.drawable.ic_forward, stringResource(id = R.string.forward)) + } + + DropdownMenuItem( + onClick = { + showMenu = !showMenu + actionHandler(MessageAction.DeleteMessage(message)) + } + ) { + MessageMenuItem(R.drawable.ic_trash, stringResource(id = R.string.delete)) + } + + } + } +} + +@Composable +@Preview +fun InboxMessageViewPreviewWithActions() { + ContextKeeper.appContext = LocalContext.current + + Column( + modifier = Modifier.background(colorResource(id = R.color.backgroundLightest)) + ){ + InboxMessageView( + messageState = InboxMessageUiState( + author = BasicUser(id = 1, name = "User 1"), + recipients = listOf(BasicUser(id = 2, name = "User 2")), + message = Message( + id = 1, + authorId = 1, + body = "Test message", + participatingUserIds = listOf(2), + createdAt = ZonedDateTime.now().toString() + ), + enabledActions = true + ), + actionHandler = {} + ) + } +} + +@Composable +@Preview +fun InboxMessageViewPreviewWithoutActions() { + ContextKeeper.appContext = LocalContext.current + + Column( + modifier = Modifier.background(colorResource(id = R.color.backgroundLightest)) + ){ + InboxMessageView( + messageState = InboxMessageUiState( + author = BasicUser(id = 1, name = "User 1"), + recipients = listOf(BasicUser(id = 2, name = "User 2")), + message = Message( + id = 1, + authorId = 1, + body = "Test message", + participatingUserIds = listOf(2), + createdAt = ZonedDateTime.now().toString() + ), + enabledActions = false + ), + actionHandler = {} + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/StringExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/StringExtensions.kt index 474252e7d7..222022c256 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/StringExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/StringExtensions.kt @@ -17,6 +17,13 @@ package com.instructure.pandautils.utils import android.icu.text.Normalizer2 +import android.text.SpannableString +import android.text.style.URLSpan +import android.text.util.Linkify +import android.util.Patterns +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString private val REGEX_UNACCENT = "\\p{InCombiningDiacriticalMarks}+".toRegex() @@ -30,4 +37,38 @@ object Normalizer { fun normalize(text: String): String { return Normalizer2.getNFDInstance().normalize(text) } +} + +fun String.linkify( + linkStyle: SpanStyle, +) = buildAnnotatedString { + append(this@linkify) + + val spannable = SpannableString(this@linkify) + Linkify.addLinks(spannable, Patterns.WEB_URL, null) + Linkify.addLinks(spannable, Patterns.EMAIL_ADDRESS, null) + Linkify.addLinks(spannable, Patterns.PHONE, null) + + val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) + for (span in spans) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + + addStyle( + start = start, + end = end, + style = linkStyle, + ) + addStringAnnotation( + tag = "URL", + annotation = span.url, + start = start, + end = end + ) + } +} + +fun AnnotatedString.handleUrlAt(position: Int, onFound: (String) -> Unit) = + getStringAnnotations("URL", position, position).firstOrNull()?.item?.let { + onFound(it) } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModelTest.kt index 7914a2eb3c..3a7097b1b6 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/compose/InboxComposeViewModelTest.kt @@ -17,15 +17,26 @@ package com.instructure.pandautils.features.inbox.compose import android.content.Context import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle import androidx.work.WorkInfo import com.instructure.canvasapi2.models.Attachment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.type.EnrollmentType import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.R +import com.instructure.pandautils.features.inbox.utils.AttachmentCardItem +import com.instructure.pandautils.features.inbox.utils.AttachmentStatus +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsDefaultValues +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsDisabledFields +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsHiddenFields +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsMode +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptionsPreviousMessages import com.instructure.pandautils.room.appdatabase.daos.AttachmentDao import com.instructure.pandautils.utils.FileDownloader import io.mockk.coEvery @@ -346,7 +357,7 @@ class InboxComposeViewModelTest { val viewmodel = getViewModel() val attachment = Attachment() val attachmentEntity = com.instructure.pandautils.room.appdatabase.entities.AttachmentEntity(attachment) - val attachmentCardItem = AttachmentCardItem(Attachment(), AttachmentStatus.UPLOADED) + val attachmentCardItem = AttachmentCardItem(Attachment(), AttachmentStatus.UPLOADED, false) val uuid = UUID.randomUUID() coEvery { attachmentDao.findByParentId(uuid.toString()) } returns listOf(attachmentEntity) viewmodel.updateAttachments(uuid, WorkInfo(UUID.randomUUID(), WorkInfo.State.SUCCEEDED, setOf(""))) @@ -363,7 +374,7 @@ class InboxComposeViewModelTest { val fileDownloader: FileDownloader = mockk(relaxed = true) val viewModel = getViewModel(fileDownloader) val attachment = Attachment() - val attachmentCardItem = AttachmentCardItem(attachment, AttachmentStatus.UPLOADED) + val attachmentCardItem = AttachmentCardItem(attachment, AttachmentStatus.UPLOADED, false) viewModel.handleAction(InboxComposeActionHandler.OpenAttachment(attachmentCardItem)) @@ -554,7 +565,100 @@ class InboxComposeViewModelTest { } //endregion + // region Arguments + + @Test + fun `Argument values are populated to ViewModel`() { + val savedStateHandle = mockk(relaxed = true) + + val mode = InboxComposeOptionsMode.REPLY + val conversation = Conversation(id = 2) + val messages = listOf(Message(id = 2), Message(id = 3)) + val contextCode = "course_1" + val contextName = "Course 1" + val recipients = listOf(Recipient(stringId = "1")) + val subject = "Test subject" + val body = "Test body" + val attachments = listOf(Attachment()) + coEvery { savedStateHandle.get(InboxComposeOptions.COMPOSE_PARAMETERS) } returns InboxComposeOptions( + mode = mode, + previousMessages = InboxComposeOptionsPreviousMessages(conversation, messages), + defaultValues = InboxComposeOptionsDefaultValues( + contextCode = contextCode, + contextName = contextName, + recipients = recipients, + subject = subject, + body = body, + attachments = attachments + ) + ) + val viewmodel = InboxComposeViewModel(savedStateHandle, context, mockk(relaxed = true), inboxComposeRepository, attachmentDao) + val uiState = viewmodel.uiState.value + + assertEquals(mode, uiState.inboxComposeMode) + assertEquals(conversation, uiState.previousMessages?.conversation) + assertEquals(messages, uiState.previousMessages?.previousMessages) + assertEquals(contextName, uiState.selectContextUiState.selectedCanvasContext?.name) + assertEquals(contextCode, uiState.selectContextUiState.selectedCanvasContext?.contextId) + assertEquals(recipients, uiState.recipientPickerUiState.selectedRecipients) + assertEquals(subject, uiState.subject.text) + assertEquals(body, uiState.body.text) + assertEquals(attachments, uiState.attachments.map { it.attachment }) + } + + @Test + fun `Argument disabled fields are populated to ViewModel`() { + val savedStateHandle = mockk(relaxed = true) + + coEvery { savedStateHandle.get(InboxComposeOptions.COMPOSE_PARAMETERS) } returns InboxComposeOptions( + disabledFields = InboxComposeOptionsDisabledFields( + isContextDisabled = true, + isRecipientsDisabled = true, + isSendIndividualDisabled = true, + isSubjectDisabled = true, + isBodyDisabled = true, + isAttachmentDisabled = true + ) + ) + val viewmodel = InboxComposeViewModel(savedStateHandle, context, mockk(relaxed = true), inboxComposeRepository, attachmentDao) + val disabledFields = viewmodel.uiState.value.disabledFields + + assertEquals(true, disabledFields.isContextDisabled) + assertEquals(true, disabledFields.isRecipientsDisabled) + assertEquals(true, disabledFields.isSendIndividualDisabled) + assertEquals(true, disabledFields.isSubjectDisabled) + assertEquals(true, disabledFields.isBodyDisabled) + assertEquals(true, disabledFields.isAttachmentDisabled) + } + + @Test + fun `Argument hidden fields are populated to ViewModel`() { + val savedStateHandle = mockk(relaxed = true) + + coEvery { savedStateHandle.get(InboxComposeOptions.COMPOSE_PARAMETERS) } returns InboxComposeOptions( + hiddenFields = InboxComposeOptionsHiddenFields( + isContextHidden = true, + isRecipientsHidden = true, + isSendIndividualHidden = true, + isSubjectHidden= true, + isBodyHidden = true, + isAttachmentHidden = true + ) + ) + val viewmodel = InboxComposeViewModel(savedStateHandle, context, mockk(relaxed = true), inboxComposeRepository, attachmentDao) + val hiddenFields = viewmodel.uiState.value.hiddenFields + + assertEquals(true, hiddenFields.isContextHidden) + assertEquals(true, hiddenFields.isRecipientsHidden) + assertEquals(true, hiddenFields.isSendIndividualHidden) + assertEquals(true, hiddenFields.isSubjectHidden) + assertEquals(true, hiddenFields.isBodyHidden) + assertEquals(true, hiddenFields.isAttachmentHidden) + } + + // endregion + private fun getViewModel(fileDownloader: FileDownloader = mockk(relaxed = true)): InboxComposeViewModel { - return InboxComposeViewModel(context, fileDownloader, inboxComposeRepository, attachmentDao) + return InboxComposeViewModel(SavedStateHandle(), context, fileDownloader, inboxComposeRepository, attachmentDao) } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepositoryTest.kt new file mode 100644 index 0000000000..958929c3ca --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsRepositoryTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import android.content.Context +import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.InboxApi +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InboxDetailsRepositoryTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val context: Context = mockk(relaxed = true) + private val inboxAPI: InboxApi.InboxInterface = mockk(relaxed = true) + private val inboxRepository = InboxDetailsRepositoryImpl(inboxAPI) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + ContextKeeper.appContext = context + + mockkObject(CanvasRestAdapter) + every { CanvasRestAdapter.clearCacheUrls(any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Get Conversation successfully`() = runTest { + val conversation = Conversation() + val params = RestParams(isForceReadFromNetwork = false) + + coEvery { inboxAPI.getConversation(conversation.id, true, params) } returns DataResult.Success(conversation) + + val result = inboxRepository.getConversation(conversation.id) + + assertEquals(conversation, result.dataOrNull) + } + + @Test + fun `Get Conversation failed`() = runTest { + val conversation = Conversation() + val params = RestParams(isForceReadFromNetwork = false) + + coEvery { inboxAPI.getConversation(conversation.id, true, params) } returns DataResult.Fail() + + val result = inboxRepository.getConversation(conversation.id) + + assertEquals(DataResult.Fail(), result) + } + + @Test + fun `Get Conversation successfully with force refresh`() = runTest { + val conversation = Conversation() + val params = RestParams(isForceReadFromNetwork = true) + + coEvery { inboxAPI.getConversation(conversation.id, true, params) } returns DataResult.Success(conversation) + + val result = inboxRepository.getConversation(conversation.id, true, true) + + assertEquals(conversation, result.dataOrNull) + } + + @Test + fun `Delete Conversation successfully`() = runTest { + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.deleteConversation(conversation.id, params) } returns DataResult.Success(conversation) + + val result = inboxRepository.deleteConversation(conversation.id) + + assertEquals(conversation, result.dataOrNull) + } + + @Test + fun `Delete Conversation failed`() = runTest { + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.deleteConversation(conversation.id, params) } returns DataResult.Fail() + + val result = inboxRepository.deleteConversation(conversation.id) + + assertEquals(DataResult.Fail(), result) + } + + @Test + fun `Delete Message successfully`() = runTest { + val conversation = Conversation() + val messageIds = listOf(1L) + val params = RestParams() + + coEvery { inboxAPI.deleteMessages(conversation.id, messageIds, params) } returns DataResult.Success(conversation) + + val result = inboxRepository.deleteMessage(conversation.id, messageIds) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(conversation, result.dataOrNull) + } + + @Test + fun `Delete Message failed`() = runTest { + val conversation = Conversation() + val messageIds = listOf(1L) + val params = RestParams() + + coEvery { inboxAPI.deleteMessages(conversation.id, messageIds, params) } returns DataResult.Fail() + + val result = inboxRepository.deleteMessage(conversation.id, messageIds) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(DataResult.Fail(), result) + } + + @Test + fun `Update Conversation isStarred successfully`() = runTest { + val isStarred = true + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.updateConversation(conversation.id, null, isStarred, params) } returns DataResult.Success(conversation.copy(isStarred = isStarred)) + + val result = inboxRepository.updateStarred(conversation.id, isStarred) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(isStarred, result.dataOrNull?.isStarred) + } + + @Test + fun `Update Conversation isStarred failed`() = runTest { + val isStarred = true + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.updateConversation(conversation.id, null, isStarred, params) } returns DataResult.Fail() + + val result = inboxRepository.updateStarred(conversation.id, isStarred) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(DataResult.Fail(), result) + } + + @Test + fun `Update Conversation workflow state successfully`() = runTest { + val workflowState = Conversation.WorkflowState.READ + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.updateConversation(conversation.id, workflowState.apiString, null, params) } returns DataResult.Success(conversation.copy(workflowState = workflowState)) + + val result = inboxRepository.updateState(conversation.id, workflowState) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(workflowState, result.dataOrNull?.workflowState) + } + + @Test + fun `Update Conversation workflow state failed`() = runTest { + val workflowState = Conversation.WorkflowState.READ + val conversation = Conversation() + val params = RestParams() + + coEvery { inboxAPI.updateConversation(conversation.id, workflowState.apiString, null, params) } returns DataResult.Fail() + + val result = inboxRepository.updateState(conversation.id, workflowState) + + verify(exactly = 1) { CanvasRestAdapter.clearCacheUrls(any()) } + assertEquals(DataResult.Fail(), result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt new file mode 100644 index 0000000000..4c7b0ecaa4 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt @@ -0,0 +1,612 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.details + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandares.R +import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions +import com.instructure.pandautils.features.inbox.utils.InboxMessageUiState +import com.instructure.pandautils.features.inbox.utils.MessageAction +import com.instructure.pandautils.utils.FileDownloader +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InboxDetailsViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val context: Context = mockk(relaxed = true) + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val inboxDetailsRepository: InboxDetailsRepository = mockk(relaxed = true) + + private val conversation = Conversation( + id = 1, + participants = mutableListOf(BasicUser(id = 1, name = "User 1"), BasicUser(id = 2, name = "User 2")), + messages = mutableListOf( + Message(id = 1, authorId = 1, body = "Message 1", participatingUserIds = mutableListOf(1, 2)), + Message(id = 2, authorId = 2, body = "Message 2", participatingUserIds = mutableListOf(1, 2)), + ) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + ContextKeeper.appContext = context + + coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(conversation) + coEvery { savedStateHandle.get(any()) } returns conversation.id + coEvery { context.getString( + com.instructure.pandautils.R.string.inboxForwardSubjectFwPrefix, + conversation.subject + ) } returns "Fwd: ${conversation.subject}" + coEvery { context.getString( + com.instructure.pandautils.R.string.inboxReplySubjectRePrefix, + conversation.subject + ) } returns "Re: ${conversation.subject}" + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test ViewModel init`() { + coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(conversation) + + val viewModel = getViewModel() + + assertEquals(conversation.id, viewModel.conversationId) + + val messageStates = listOf( + InboxMessageUiState( + message = conversation.messages[0], + author = conversation.participants[0], + recipients = listOf(conversation.participants[1]), + enabledActions = true, + ), + InboxMessageUiState( + message = conversation.messages[1], + author = conversation.participants[1], + recipients = listOf(conversation.participants[0]), + enabledActions = true, + ), + ) + val expectedUiState = InboxDetailsUiState( + conversationId = conversation.id, + conversation = conversation, + messageStates = messageStates, + state = ScreenState.Success, + ) + + assertEquals(expectedUiState, viewModel.uiState.value) + + } + + // region: InboxDetailsAction tests + + @Test + fun `Test Close fragment action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.CloseFragment) + + assertEquals(InboxDetailsFragmentAction.CloseFragment, events.last()) + } + + @Test + fun `Test Refresh action`() { + val viewModel = getViewModel() + + coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(conversation) + val messageStates = listOf( + InboxMessageUiState( + message = conversation.messages[0], + author = conversation.participants[0], + recipients = listOf(conversation.participants[1]), + enabledActions = true, + ), + InboxMessageUiState( + message = conversation.messages[1], + author = conversation.participants[1], + recipients = listOf(conversation.participants[0]), + enabledActions = true, + ), + ) + val expectedUiState = InboxDetailsUiState( + conversationId = conversation.id, + conversation = conversation, + messageStates = messageStates, + state = ScreenState.Success, + ) + + viewModel.handleAction(InboxDetailsAction.RefreshCalled) + + assertEquals(expectedUiState, viewModel.uiState.value) + coVerify(exactly = 1) { inboxDetailsRepository.getConversation(conversation.id, true, true) } + + } + + @Test + fun `Test Conversation Delete action with Cancel`() { + val viewModel = getViewModel() + + viewModel.handleAction(InboxDetailsAction.DeleteConversation(conversation.id)) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteConversation), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteConversation), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onNegativeButtonClick.invoke() + + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + } + + @Test + fun `Test Conversation Delete action with successful Delete`() = runTest { + val viewModel = getViewModel() + coEvery { inboxDetailsRepository.deleteConversation(conversation.id) } returns DataResult.Success(conversation) + + viewModel.handleAction(InboxDetailsAction.DeleteConversation(conversation.id)) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteConversation), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteConversation), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + coVerify(exactly = 1) { inboxDetailsRepository.deleteConversation(conversation.id) } + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(3, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationDeleted)), events[0]) + assertEquals(InboxDetailsFragmentAction.CloseFragment, events[1]) + assertEquals(InboxDetailsFragmentAction.UpdateParentFragment, events[2]) + } + + @Test + fun `Test Conversation Delete action with failed Delete`() = runTest { + val viewModel = getViewModel() + coEvery { inboxDetailsRepository.deleteConversation(conversation.id) } returns DataResult.Fail() + + viewModel.handleAction(InboxDetailsAction.DeleteConversation(conversation.id)) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteConversation), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteConversation), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + coVerify(exactly = 1) { inboxDetailsRepository.deleteConversation(conversation.id) } + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(1, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationDeletedFailed)), events[0]) + } + + @Test + fun `Test Message Delete action with Cancel`() { + val viewModel = getViewModel() + + viewModel.handleAction(InboxDetailsAction.DeleteMessage(conversation.id, conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onNegativeButtonClick.invoke() + + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + } + + @Test + fun `Test Message Delete action with successful Delete`() = runTest { + val viewModel = getViewModel() + val newConversation = conversation.copy(messages = listOf(conversation.messages[1])) + val messageStates = listOf( + InboxMessageUiState( + message = conversation.messages[1], + author = conversation.participants[1], + recipients = listOf(conversation.participants[0]), + enabledActions = true, + ), + ) + val expectedUiState = InboxDetailsUiState( + conversationId = newConversation.id, + conversation = newConversation, + messageStates = messageStates, + state = ScreenState.Success, + ) + coEvery { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } returns DataResult.Success(newConversation) + coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(newConversation) + + viewModel.handleAction(InboxDetailsAction.DeleteMessage(conversation.id, conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(2, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeleted)), events[0]) + assertEquals(InboxDetailsFragmentAction.UpdateParentFragment, events[1]) + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + assertEquals(expectedUiState, viewModel.uiState.value) + + coVerify(exactly = 1) { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } + } + + @Test + fun `Test Message Delete action with failed Delete`() = runTest { + val viewModel = getViewModel() + coEvery { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } returns DataResult.Fail() + + viewModel.handleAction(InboxDetailsAction.DeleteMessage(conversation.id, conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(1, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeletedFailed)), events[0]) + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + + coVerify(exactly = 1) { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } + } + + @Test + fun `Test Reply action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.Reply(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReply(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test Reply All action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.ReplyAll(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReplyAll(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test Forward action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.Forward(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildForward(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test Conversation isStarred state update successfully`() { + val viewModel = getViewModel() + val isStarred = true + val newConversation = conversation.copy(isStarred = isStarred) + + coEvery { inboxDetailsRepository.updateStarred(conversation.id, isStarred) } returns DataResult.Success(newConversation) + + viewModel.handleAction(InboxDetailsAction.UpdateStarred(conversation.id, isStarred)) + + assertEquals(isStarred, viewModel.uiState.value.conversation?.isStarred) + coVerify(exactly = 1) { inboxDetailsRepository.updateStarred(conversation.id, isStarred) } + } + + @Test + fun `Test Conversation isStarred state update failed`() = runTest { + val viewModel = getViewModel() + val isStarred = true + + coEvery { inboxDetailsRepository.updateStarred(conversation.id, isStarred) } returns DataResult.Fail() + + viewModel.handleAction(InboxDetailsAction.UpdateStarred(conversation.id, isStarred)) + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + assertEquals(1, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationUpdateFailed)), events[0]) + coVerify(exactly = 1) { inboxDetailsRepository.updateStarred(conversation.id, isStarred) } + } + + @Test + fun `Test Conversation workflow state update successfully`() { + val viewModel = getViewModel() + val newState = Conversation.WorkflowState.READ + val newConversation = conversation.copy(workflowState = newState) + + coEvery { inboxDetailsRepository.updateState(conversation.id, newState) } returns DataResult.Success(newConversation) + + viewModel.handleAction(InboxDetailsAction.UpdateState(conversation.id, newState)) + + assertEquals(newState, viewModel.uiState.value.conversation?.workflowState) + coVerify(exactly = 1) { inboxDetailsRepository.updateState(conversation.id, newState) } + } + + @Test + fun `Test Conversation workflow state update failed`() = runTest { + val viewModel = getViewModel() + val newState = Conversation.WorkflowState.READ + + coEvery { inboxDetailsRepository.updateState(conversation.id, newState) } returns DataResult.Fail() + + viewModel.handleAction(InboxDetailsAction.UpdateState(conversation.id, newState)) + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + assertEquals(1, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationUpdateFailed)), events[0]) + coVerify(exactly = 1) { inboxDetailsRepository.updateState(conversation.id, newState) } + } + + // endregion + + //region MessageAction tests + + @Test + fun `Test MessageAction Attachment onClick`() { + val fileDownloader: FileDownloader = mockk(relaxed = true) + val viewModel = getViewModel(fileDownloader) + val attachment = Attachment() + + viewModel.messageActionHandler(MessageAction.OpenAttachment(attachment)) + + coVerify(exactly = 1) { fileDownloader.downloadFileToDevice(attachment) } + } + + @Test + fun `Test MessageAction open url in message`() = runTest { + val viewModel = getViewModel() + val url = "testURL" + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.messageActionHandler(MessageAction.UrlSelected(url)) + + assertEquals(InboxDetailsFragmentAction.UrlSelected(url), events.last()) + } + + @Test + fun `Test MessageAction Reply action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.messageActionHandler(MessageAction.Reply(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReply(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test MessageAction Reply All action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.messageActionHandler(MessageAction.ReplyAll(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildReplyAll(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test MessageAction Forward action`() = runTest { + val viewModel = getViewModel() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.messageActionHandler(MessageAction.Forward(conversation.messages.last())) + + assertEquals(InboxDetailsFragmentAction.NavigateToCompose(InboxComposeOptions.buildForward(context, conversation, conversation.messages.last())), events.last()) + } + + @Test + fun `Test MessageAction Delete Message action with Cancel`() { + val viewModel = getViewModel() + + viewModel.messageActionHandler(MessageAction.DeleteMessage(conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onNegativeButtonClick.invoke() + + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + } + + @Test + fun `Test MessageAction Delete Message action with successful Delete`() = runTest { + val viewModel = getViewModel() + val newConversation = conversation.copy(messages = listOf(conversation.messages[1])) + val messageStates = listOf( + InboxMessageUiState( + message = conversation.messages[1], + author = conversation.participants[1], + recipients = listOf(conversation.participants[0]), + enabledActions = true, + ), + ) + val expectedUiState = InboxDetailsUiState( + conversationId = newConversation.id, + conversation = newConversation, + messageStates = messageStates, + state = ScreenState.Success, + ) + coEvery { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } returns DataResult.Success(newConversation) + coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(newConversation) + + viewModel.messageActionHandler(MessageAction.DeleteMessage(conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(2, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeleted)), events[0]) + assertEquals(InboxDetailsFragmentAction.UpdateParentFragment, events[1]) + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + assertEquals(expectedUiState, viewModel.uiState.value) + + coVerify(exactly = 1) { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } + } + + @Test + fun `Test MessageAction Delete Message action with failed Delete`() = runTest { + val viewModel = getViewModel() + coEvery { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } returns DataResult.Fail() + + viewModel.messageActionHandler(MessageAction.DeleteMessage(conversation.messages[0])) + + val alertDialogState = viewModel.uiState.value.confirmationDialogState + assertEquals(true, alertDialogState.showDialog) + assertEquals(context.getString(R.string.deleteMessage), alertDialogState.title) + assertEquals(context.getString(R.string.confirmDeleteMessage), alertDialogState.message) + assertEquals(context.getString(R.string.delete), alertDialogState.positiveButton) + assertEquals(context.getString(R.string.cancel), alertDialogState.negativeButton) + + alertDialogState.onPositiveButtonClick.invoke() + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + assertEquals(1, events.size) + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.messageDeletedFailed)), events[0]) + assertEquals(ConfirmationDialogState(), viewModel.uiState.value.confirmationDialogState) + + coVerify(exactly = 1) { inboxDetailsRepository.deleteMessage(conversation.id, listOf(conversation.messages[0].id)) } + } + + // endregion + + private fun getViewModel(fileDownloader: FileDownloader = FileDownloader(context)): InboxDetailsViewModel { + return InboxDetailsViewModel(context, savedStateHandle, inboxDetailsRepository, fileDownloader) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptionsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptionsTest.kt new file mode 100644 index 0000000000..cd8aac9925 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/utils/InboxComposeOptionsTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * 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. + */ +package com.instructure.pandautils.features.inbox.utils + +import android.content.Context +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.BasicUser +import com.instructure.canvasapi2.models.Conversation +import com.instructure.canvasapi2.models.Message +import com.instructure.canvasapi2.models.Recipient +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.ContextKeeper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InboxComposeOptionsTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val context: Context = mockk(relaxed = true) + private val conversation = Conversation( + id = 1, + participants = mutableListOf(BasicUser(id = 1, name = "User 1"), BasicUser(id = 2, name = "User 2")), + messages = mutableListOf( + Message(id = 1, authorId = 1, body = "Message 1", participatingUserIds = mutableListOf(1, 2)), + Message(id = 2, authorId = 2, body = "Message 2", participatingUserIds = mutableListOf(1, 2)), + ) + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + ContextKeeper.appContext = context + + mockkObject(ApiPrefs) + every { ApiPrefs.user } returns User(id = 1, name = "User 1") + coEvery { context.getString( + com.instructure.pandautils.R.string.inboxForwardSubjectFwPrefix, + conversation.subject + ) } returns "Fwd: ${conversation.subject}" + coEvery { context.getString( + com.instructure.pandautils.R.string.inboxReplySubjectRePrefix, + conversation.subject + ) } returns "Re: ${conversation.subject}" + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test Compose options init value`() { + val inboxComposeOptions = InboxComposeOptions() + + // Check if the mode is set correctly + assertEquals(InboxComposeOptionsMode.NEW_MESSAGE, inboxComposeOptions.mode) + + //Check if the previousMessages are set correctly + assertEquals(null, inboxComposeOptions.previousMessages) + + // Check if the default values are set correctly + assertEquals(null, inboxComposeOptions.defaultValues.contextCode) + assertEquals(null, inboxComposeOptions.defaultValues.contextName) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.recipients) + assertEquals(false, inboxComposeOptions.defaultValues.sendIndividual) + assertEquals("", inboxComposeOptions.defaultValues.subject) + assertEquals("", inboxComposeOptions.defaultValues.body) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.attachments) + + // Check if the disabled fields are set correctly + assertFalse(inboxComposeOptions.disabledFields.isContextDisabled) + assertFalse(inboxComposeOptions.disabledFields.isRecipientsDisabled) + assertFalse(inboxComposeOptions.disabledFields.isSendIndividualDisabled) + assertFalse(inboxComposeOptions.disabledFields.isSubjectDisabled) + assertFalse(inboxComposeOptions.disabledFields.isBodyDisabled) + assertFalse(inboxComposeOptions.disabledFields.isAttachmentDisabled) + + // Check if the hidden fields are set correctly + assertFalse(inboxComposeOptions.hiddenFields.isContextHidden) + assertFalse(inboxComposeOptions.hiddenFields.isRecipientsHidden) + assertFalse(inboxComposeOptions.hiddenFields.isSendIndividualHidden) + assertFalse(inboxComposeOptions.hiddenFields.isSubjectHidden) + assertFalse(inboxComposeOptions.hiddenFields.isBodyHidden) + assertFalse(inboxComposeOptions.hiddenFields.isAttachmentHidden) + } + + @Test + fun `Test Compose options build for Reply`() { + val inboxComposeOptions = InboxComposeOptions.buildReply(context, conversation, conversation.messages.last()) + + // Check if the mode is set correctly + assertEquals(InboxComposeOptionsMode.REPLY, inboxComposeOptions.mode) + + //Check if the previousMessages are set correctly + assertEquals(conversation, inboxComposeOptions.previousMessages?.conversation) + assertEquals(conversation.messages, inboxComposeOptions.previousMessages?.previousMessages) + + // Check if the default values are set correctly + assertEquals(conversation.contextCode, inboxComposeOptions.defaultValues.contextCode) + assertEquals(conversation.contextName, inboxComposeOptions.defaultValues.contextName) + assertEquals(listOf(conversation.participants.map { it.id.toString() }.last()), inboxComposeOptions.defaultValues.recipients.map { it.stringId }) + assertEquals(false, inboxComposeOptions.defaultValues.sendIndividual) + assertEquals("Re: ${conversation.subject}", inboxComposeOptions.defaultValues.subject) + assertEquals("", inboxComposeOptions.defaultValues.body) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.attachments) + + // Check if the disabled fields are set correctly + assertTrue(inboxComposeOptions.disabledFields.isContextDisabled) + assertFalse(inboxComposeOptions.disabledFields.isRecipientsDisabled) + assertFalse(inboxComposeOptions.disabledFields.isSendIndividualDisabled) + assertTrue(inboxComposeOptions.disabledFields.isSubjectDisabled) + assertFalse(inboxComposeOptions.disabledFields.isBodyDisabled) + assertFalse(inboxComposeOptions.disabledFields.isAttachmentDisabled) + + // Check if the hidden fields are set correctly + assertFalse(inboxComposeOptions.hiddenFields.isContextHidden) + assertFalse(inboxComposeOptions.hiddenFields.isRecipientsHidden) + assertTrue(inboxComposeOptions.hiddenFields.isSendIndividualHidden) + assertFalse(inboxComposeOptions.hiddenFields.isSubjectHidden) + assertFalse(inboxComposeOptions.hiddenFields.isBodyHidden) + assertFalse(inboxComposeOptions.hiddenFields.isAttachmentHidden) + } + + @Test + fun `Test Compose options build for Reply All`() { + val inboxComposeOptions = InboxComposeOptions.buildReplyAll(context, conversation, conversation.messages.last()) + + // Check if the mode is set correctly + assertEquals(InboxComposeOptionsMode.REPLY_ALL, inboxComposeOptions.mode) + + //Check if the previousMessages are set correctly + assertEquals(conversation, inboxComposeOptions.previousMessages?.conversation) + assertEquals(conversation.messages, inboxComposeOptions.previousMessages?.previousMessages) + + // Check if the default values are set correctly + assertEquals(conversation.contextCode, inboxComposeOptions.defaultValues.contextCode) + assertEquals(conversation.contextName, inboxComposeOptions.defaultValues.contextName) + assertEquals(listOf(conversation.participants.map { it.id.toString() }.last()), inboxComposeOptions.defaultValues.recipients.map { it.stringId }) + assertEquals(false, inboxComposeOptions.defaultValues.sendIndividual) + assertEquals("Re: ${conversation.subject}", inboxComposeOptions.defaultValues.subject) + assertEquals("", inboxComposeOptions.defaultValues.body) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.attachments) + + // Check if the disabled fields are set correctly + assertTrue(inboxComposeOptions.disabledFields.isContextDisabled) + assertFalse(inboxComposeOptions.disabledFields.isRecipientsDisabled) + assertFalse(inboxComposeOptions.disabledFields.isSendIndividualDisabled) + assertTrue(inboxComposeOptions.disabledFields.isSubjectDisabled) + assertFalse(inboxComposeOptions.disabledFields.isBodyDisabled) + assertFalse(inboxComposeOptions.disabledFields.isAttachmentDisabled) + + // Check if the hidden fields are set correctly + assertFalse(inboxComposeOptions.hiddenFields.isContextHidden) + assertFalse(inboxComposeOptions.hiddenFields.isRecipientsHidden) + assertTrue(inboxComposeOptions.hiddenFields.isSendIndividualHidden) + assertFalse(inboxComposeOptions.hiddenFields.isSubjectHidden) + assertFalse(inboxComposeOptions.hiddenFields.isBodyHidden) + assertFalse(inboxComposeOptions.hiddenFields.isAttachmentHidden) + } + + @Test + fun `Test Compose options build for Forward`() { + val inboxComposeOptions = InboxComposeOptions.buildForward(context, conversation, conversation.messages.last()) + + // Check if the mode is set correctly + assertEquals(InboxComposeOptionsMode.FORWARD, inboxComposeOptions.mode) + + //Check if the previousMessages are set correctly + assertEquals(conversation, inboxComposeOptions.previousMessages?.conversation) + assertEquals(conversation.messages, inboxComposeOptions.previousMessages?.previousMessages) + + // Check if the default values are set correctly + assertEquals(conversation.contextCode, inboxComposeOptions.defaultValues.contextCode) + assertEquals(conversation.contextName, inboxComposeOptions.defaultValues.contextName) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.recipients.map { it.stringId }) + assertEquals(false, inboxComposeOptions.defaultValues.sendIndividual) + assertEquals("Fwd: ${conversation.subject}", inboxComposeOptions.defaultValues.subject) + assertEquals("", inboxComposeOptions.defaultValues.body) + assertEquals(emptyList(), inboxComposeOptions.defaultValues.attachments) + + // Check if the disabled fields are set correctly + assertTrue(inboxComposeOptions.disabledFields.isContextDisabled) + assertFalse(inboxComposeOptions.disabledFields.isRecipientsDisabled) + assertFalse(inboxComposeOptions.disabledFields.isSendIndividualDisabled) + assertTrue(inboxComposeOptions.disabledFields.isSubjectDisabled) + assertFalse(inboxComposeOptions.disabledFields.isBodyDisabled) + assertFalse(inboxComposeOptions.disabledFields.isAttachmentDisabled) + + // Check if the hidden fields are set correctly + assertFalse(inboxComposeOptions.hiddenFields.isContextHidden) + assertFalse(inboxComposeOptions.hiddenFields.isRecipientsHidden) + assertTrue(inboxComposeOptions.hiddenFields.isSendIndividualHidden) + assertFalse(inboxComposeOptions.hiddenFields.isSubjectHidden) + assertFalse(inboxComposeOptions.hiddenFields.isBodyHidden) + assertFalse(inboxComposeOptions.hiddenFields.isAttachmentHidden) + } +} \ No newline at end of file