diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index 76360abe3d..6af794d925 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -47,6 +47,7 @@ import com.instructure.canvasapi2.utils.Logger import com.instructure.canvasapi2.utils.RemoteConfigParam import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.depaginate +import com.instructure.canvasapi2.utils.isInvited import com.instructure.canvasapi2.utils.pageview.PandataInfo import com.instructure.canvasapi2.utils.pageview.PandataManager import com.instructure.canvasapi2.utils.toApiString @@ -56,7 +57,6 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandautils.dialogs.RatingDialog import com.instructure.pandautils.features.inbox.list.OnUnreadCountInvalidated -import com.instructure.pandautils.features.todolist.filter.DateRangeSelection import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity import com.instructure.pandautils.utils.AppType @@ -81,7 +81,6 @@ import retrofit2.Call import retrofit2.Response import sdk.pendo.io.Pendo import javax.inject.Inject -import kotlin.collections.filter @AndroidEntryPoint abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, NotificationListFragment.OnNotificationCountInvalidated { @@ -256,11 +255,11 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No } val filteredCourses = if (todoFilters.favoriteCourses) { - val restParams = RestParams(isForceReadFromNetwork = false) - val courses = courseApi.getFavoriteCourses(restParams).depaginate { nextUrl -> + val restParams = RestParams(isForceReadFromNetwork = true) + val allCourses = courseApi.getFirstPageCourses(restParams).depaginate { nextUrl -> courseApi.next(nextUrl, restParams) } - courses.dataOrNull ?: emptyList() + allCourses.dataOrNull?.filter { !it.accessRestrictedByDate && !it.isInvited() }.orEmpty() } else { emptyList() } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/SubmissionWorker.kt b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/SubmissionWorker.kt index 9d2da52b50..48c200d10c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/SubmissionWorker.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/SubmissionWorker.kt @@ -52,28 +52,31 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.FileUtils import com.instructure.canvasapi2.utils.ProgressRequestUpdateListener +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.features.submission.SubmissionWorkerAction import com.instructure.pandautils.models.PushNotification +import com.instructure.pandautils.room.studentdb.entities.CreateFileSubmissionEntity +import com.instructure.pandautils.room.studentdb.entities.CreatePendingSubmissionCommentEntity +import com.instructure.pandautils.room.studentdb.entities.CreateSubmissionEntity +import com.instructure.pandautils.room.studentdb.entities.daos.CreateFileSubmissionDao +import com.instructure.pandautils.room.studentdb.entities.daos.CreatePendingSubmissionCommentDao +import com.instructure.pandautils.room.studentdb.entities.daos.CreateSubmissionCommentFileDao +import com.instructure.pandautils.room.studentdb.entities.daos.CreateSubmissionDao import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.FileUploadUtils import com.instructure.pandautils.utils.NotoriousUploader +import com.instructure.pandautils.utils.orDefault import com.instructure.student.R import com.instructure.student.activity.NavigationActivity import com.instructure.student.events.ShowConfettiEvent import com.instructure.student.mobius.assignmentDetails.submissionDetails.SubmissionDetailsSharedEvent import com.instructure.student.mobius.common.FlowSource import com.instructure.student.mobius.common.trySend -import com.instructure.pandautils.room.studentdb.entities.CreateFileSubmissionEntity -import com.instructure.pandautils.room.studentdb.entities.CreatePendingSubmissionCommentEntity -import com.instructure.pandautils.room.studentdb.entities.CreateSubmissionEntity -import com.instructure.pandautils.room.studentdb.entities.daos.CreateFileSubmissionDao -import com.instructure.pandautils.room.studentdb.entities.daos.CreatePendingSubmissionCommentDao -import com.instructure.pandautils.room.studentdb.entities.daos.CreateSubmissionCommentFileDao -import com.instructure.pandautils.room.studentdb.entities.daos.CreateSubmissionDao -import com.instructure.pandautils.utils.orDefault import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus @@ -96,7 +99,8 @@ class SubmissionWorker @AssistedInject constructor( private val notoriousUploader: NotoriousUploader, private val fileUploadManager: FileUploadManager, private val groupManager: GroupManager, - private val analytics: Analytics + private val analytics: Analytics, + private val calendarSharedEvents: CalendarSharedEvents ) : CoroutineWorker(context, workerParameters) { private lateinit var notificationBuilder: NotificationCompat.Builder @@ -244,6 +248,14 @@ class SubmissionWorker @AssistedInject constructor( submission, mediaSubmissionResult.dataOrThrow.late ) + + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { createSubmissionDao.setSubmissionError(true, submission.id) @@ -295,6 +307,14 @@ class SubmissionWorker @AssistedInject constructor( return result.dataOrNull?.let { deleteSubmissionsForAssignment(submission.assignmentId) showCompleteNotification(context, submission, result.dataOrThrow.late) + + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { createSubmissionDao.setSubmissionError(true, submission.id) @@ -656,6 +676,13 @@ class SubmissionWorker @AssistedInject constructor( } } + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { createSubmissionDao.setSubmissionError(true, submission.id) diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt new file mode 100644 index 0000000000..6d3dc5c5b1 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt @@ -0,0 +1,430 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Calendar +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class ToDoListScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setup() { + ContextKeeper.appContext = context + } + + @Test + fun loadingStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createLoadingUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListLoading").assertIsDisplayed() + } + + @Test + fun errorStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createErrorUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListError").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.errorLoadingToDos)).assertIsDisplayed() + } + + @Test + fun emptyStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createEmptyUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListEmpty").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNow)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNowSubtext)).assertIsDisplayed() + } + + @Test + fun itemsListIsDisplayedWhenDataExists() { + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoList").assertIsDisplayed() + composeTestRule.onNodeWithText("Test Assignment").assertIsDisplayed() + } + + @Test + fun multipleItemsAreDisplayed() { + val item1 = createToDoItem(id = "1", title = "Assignment 1") + val item2 = createToDoItem(id = "2", title = "Quiz 1") + val item3 = createToDoItem(id = "3", title = "Discussion 1") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems( + items = listOf(item1, item2, item3) + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Quiz 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Discussion 1").assertIsDisplayed() + } + + @Test + fun itemHasCheckbox() { + val item = createToDoItem(id = "1", title = "Test Assignment") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoCheckbox_1").assertIsDisplayed() + } + + @Test + fun checkboxClickTriggersCallback() { + var clicked = false + val item = createToDoItem( + id = "1", + title = "Test Assignment", + onCheckboxToggle = { clicked = true } + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoCheckbox_1").performClick() + + assertTrue(clicked) + } + + @Test + fun itemClickTriggersCallback() { + var clickedUrl: String? = null + val item = createToDoItem( + id = "1", + title = "Clickable Assignment", + htmlUrl = "https://example.com/assignments/1" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = { url -> clickedUrl = url }, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Clickable Assignment").performClick() + + assertEquals("https://example.com/assignments/1", clickedUrl) + } + + @Test + fun dateBadgeIsDisplayedForFirstItemInGroup() { + val item = createToDoItem(id = "1", title = "Test Assignment") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + // Date badge should be visible (checking for day of month "22") + composeTestRule.onNodeWithText("22").assertIsDisplayed() + } + + @Test + fun itemsGroupedByDateDisplayedCorrectly() { + val calendar = Calendar.getInstance() + val today = calendar.time + calendar.add(Calendar.DAY_OF_MONTH, 1) + val tomorrow = calendar.time + + val item1 = createToDoItem(id = "1", title = "Today Assignment", date = today) + val item2 = createToDoItem(id = "2", title = "Tomorrow Assignment", date = tomorrow) + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf( + today to listOf(item1), + tomorrow to listOf(item2) + ) + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Today Assignment").assertIsDisplayed() + composeTestRule.onNodeWithText("Tomorrow Assignment").assertIsDisplayed() + } + + @Test + fun emptyStateDisplayedWhenAllItemsFilteredOut() { + val item = createToDoItem(id = "1", title = "Filtered Item") + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf(Date() to listOf(item)), + removingItemIds = setOf("1") + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNow)).assertIsDisplayed() + composeTestRule.onNodeWithText("Filtered Item").assertIsNotDisplayed() + } + + @Test + fun itemsFilteredByRemovingItemIds() { + val item1 = createToDoItem(id = "1", title = "Visible Item") + val item2 = createToDoItem(id = "2", title = "Hidden Item") + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf(Date() to listOf(item1, item2)), + removingItemIds = setOf("2") + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Visible Item").assertIsDisplayed() + composeTestRule.onNodeWithText("Hidden Item").assertIsNotDisplayed() + } + + @Test + fun checkedItemDisplaysCorrectly() { + val item = createToDoItem( + id = "1", + title = "Completed Assignment", + isChecked = true + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Completed Assignment").assertIsDisplayed() + composeTestRule.onNodeWithTag("todoCheckbox_1").assertIsDisplayed() + } + + @Test + fun itemWithTagDisplaysTag() { + val item = createToDoItem( + id = "1", + title = "Assignment with Tag", + tag = "Important Tag" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment with Tag").assertIsDisplayed() + composeTestRule.onNodeWithText("Important Tag").assertIsDisplayed() + } + + @Test + fun itemWithDateLabelDisplaysLabel() { + val item = createToDoItem( + id = "1", + title = "Assignment with Date", + dateLabel = "11:59 PM" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment with Date").assertIsDisplayed() + composeTestRule.onNodeWithText("11:59 PM").assertIsDisplayed() + } + + @Test + fun nonClickableItemDoesNotHaveClickAction() { + val item = createToDoItem( + id = "1", + title = "Non-clickable Item", + isClickable = false + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Non-clickable Item").assertIsDisplayed() + } + + @Test + fun clickableItemHasClickAction() { + val item = createToDoItem( + id = "1", + title = "Clickable Item", + isClickable = true, + htmlUrl = "https://example.com" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Clickable Item").assertIsDisplayed().assertHasClickAction() + } + + // Helper functions to create test data + + private fun createLoadingUiState(): ToDoListUiState { + return ToDoListUiState( + isLoading = true + ) + } + + private fun createErrorUiState(): ToDoListUiState { + return ToDoListUiState( + isError = true + ) + } + + private fun createEmptyUiState(): ToDoListUiState { + return ToDoListUiState( + itemsByDate = emptyMap() + ) + } + + private fun createUiStateWithItems( + items: List = listOf(createToDoItem()) + ): ToDoListUiState { + return ToDoListUiState( + itemsByDate = mapOf(Date() to items) + ) + } + + private fun createToDoItem( + id: String = "1", + title: String = "Test Assignment", + date: Date = Calendar.getInstance().apply { set(2024, 9, 22, 11, 59) }.time, + dateLabel: String? = "11:59 AM", + contextLabel: String = "Test Course", + canvasContext: CanvasContext = CanvasContext.defaultCanvasContext(), + itemType: ToDoItemType = ToDoItemType.ASSIGNMENT, + iconRes: Int = R.drawable.ic_assignment, + isChecked: Boolean = false, + isClickable: Boolean = true, + htmlUrl: String? = "https://example.com/assignments/$id", + tag: String? = null, + onCheckboxToggle: (Boolean) -> Unit = {}, + onSwipeToDone: () -> Unit = {} + ): ToDoItemUiState { + return ToDoItemUiState( + id = id, + title = title, + date = date, + dateLabel = dateLabel, + contextLabel = contextLabel, + canvasContext = canvasContext, + itemType = itemType, + iconRes = iconRes, + isChecked = isChecked, + isClickable = isClickable, + htmlUrl = htmlUrl, + tag = tag, + onCheckboxToggle = onCheckboxToggle, + onSwipeToDone = onSwipeToDone + ) + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt index 07098e6b5a..64afae1aa6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt @@ -154,10 +154,12 @@ class EventFragment : BaseCanvasFragment(), NavigationCallbacks, FragmentInterac is EventViewModelAction.RefreshCalendarDays -> { navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(action.days)) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is EventViewModelAction.RefreshCalendar -> { navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshCalendar) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is EventViewModelAction.OnReminderAddClicked -> checkAlarmPermission() is EventViewModelAction.NavigateToComposeMessageScreen -> eventRouter.navigateToComposeMessageScreen(action.options) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt index 6eaf9808e4..b1b1293ccb 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt @@ -92,6 +92,7 @@ class ToDoFragment : BaseCanvasFragment(), NavigationCallbacks, FragmentInteract when (action) { is ToDoViewModelAction.RefreshCalendarDay -> { sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(listOf(action.date))) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) navigateBack() } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt index dcb22e0948..df596f691d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt @@ -33,6 +33,8 @@ import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDas import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardGroupItemViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardHeaderViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardNoteItemViewModel +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.mvvm.Event import com.instructure.pandautils.mvvm.ItemViewModel import com.instructure.pandautils.mvvm.ViewState @@ -46,7 +48,8 @@ class EditDashboardViewModel @Inject constructor( private val courseManager: CourseManager, private val groupManager: GroupManager, private val repository: EditDashboardRepository, - private val networkStateProvider: NetworkStateProvider + private val networkStateProvider: NetworkStateProvider, + private val calendarSharedEvents: CalendarSharedEvents ) : ViewModel() { val state: LiveData @@ -155,6 +158,7 @@ class EditDashboardViewModel @Inject constructor( addCourseToFavorites(action.itemViewModel) courseManager.addCourseToFavoritesAsync(action.itemViewModel.id).await().dataOrThrow _events.postValue(Event(EditDashboardItemAction.ShowSnackBar(R.string.added_to_dashboard))) + calendarSharedEvents.sendEvent(viewModelScope, SharedCalendarAction.RefreshToDoList) } catch (e: Exception) { Logger.d("Failed to select course: ${e.printStackTrace()}") removeCourseFromFavorites(action.itemViewModel) @@ -184,6 +188,7 @@ class EditDashboardViewModel @Inject constructor( removeCourseFromFavorites(action.itemViewModel) courseManager.removeCourseFromFavoritesAsync(action.itemViewModel.id).await().dataOrThrow _events.postValue(Event(EditDashboardItemAction.ShowSnackBar(R.string.removed_from_dashboard))) + calendarSharedEvents.sendEvent(viewModelScope, SharedCalendarAction.RefreshToDoList) } catch (e: Exception) { Logger.d("Failed to deselect course: ${e.printStackTrace()}") addCourseToFavorites(action.itemViewModel) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index cf1520a04d..1ece0cacc7 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -271,26 +272,31 @@ fun ToDoListScreen( } @Composable -private fun ToDoListContent( +internal fun ToDoListContent( uiState: ToDoListUiState, onOpenToDoItem: (String) -> Unit, onDateClick: (Date) -> Unit, modifier: Modifier = Modifier ) { + // Filter out items that are being removed + val filteredItemsByDate = uiState.itemsByDate.mapValues { (_, items) -> + items.filter { it.id !in uiState.removingItemIds } + }.filterValues { it.isNotEmpty() } + when { uiState.isLoading -> { - Loading(modifier = modifier.fillMaxSize()) + Loading(modifier = modifier.fillMaxSize().testTag("todoListLoading")) } uiState.isError -> { ErrorContent( errorMessage = stringResource(id = R.string.errorLoadingToDos), retryClick = uiState.onRefresh, - modifier = modifier.fillMaxSize() + modifier = modifier.fillMaxSize().testTag("todoListError") ) } - uiState.itemsByDate.isEmpty() -> { + filteredItemsByDate.isEmpty() -> { EmptyContent( emptyTitle = stringResource(id = R.string.noToDosForNow), emptyMessage = stringResource(id = R.string.noToDosForNowSubtext), @@ -298,15 +304,15 @@ private fun ToDoListContent( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) + .testTag("todoListEmpty") ) } else -> { ToDoItemsList( - itemsByDate = uiState.itemsByDate, + itemsByDate = filteredItemsByDate, onItemClicked = onOpenToDoItem, onDateClick = onDateClick, - removingItemIds = uiState.removingItemIds, modifier = modifier ) } @@ -318,18 +324,13 @@ private fun ToDoItemsList( itemsByDate: Map>, onItemClicked: (String) -> Unit, onDateClick: (Date) -> Unit, - modifier: Modifier = Modifier, - removingItemIds: Set = emptySet() + modifier: Modifier = Modifier ) { - // Filter out items that are being removed - val filteredItemsByDate = itemsByDate.mapValues { (_, items) -> - items.filter { it.id !in removingItemIds } - }.filterValues { it.isNotEmpty() } - - val dateGroups = filteredItemsByDate.entries.toList() + val dateGroups = itemsByDate.entries.toList() val listState = rememberLazyListState() - val itemPositions = remember { mutableStateMapOf() } - val itemSizes = remember { mutableStateMapOf() } + val configuration = LocalConfiguration.current + val itemPositions = remember(configuration) { mutableStateMapOf() } + val itemSizes = remember(configuration) { mutableStateMapOf() } val density = LocalDensity.current var listHeight by remember { mutableIntStateOf(0) } @@ -341,7 +342,7 @@ private fun ToDoItemsList( ) // Calculate content height from last item's position + size - val listContentHeight by remember(dateGroups) { + val listContentHeight by remember(dateGroups, itemPositions) { derivedStateOf { if (dateGroups.isEmpty()) return@derivedStateOf 0 val lastGroup = dateGroups.last() @@ -667,7 +668,7 @@ private fun ToDoItemContent( modifier = modifier .fillMaxWidth() .background(colorResource(id = R.color.backgroundLightest)) - .clickable(onClick = onClick) + .clickable(enabled = item.isClickable, onClick = onClick) .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.Top ) { @@ -843,7 +844,7 @@ private fun rememberStickyHeaderState( itemPositions: Map, density: Density ): StickyHeaderState { - return remember(dateGroups) { + return remember(dateGroups, itemPositions) { derivedStateOf { calculateStickyHeaderState(dateGroups, listState, itemPositions, density) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt index 327d27db4f..0666d1ca8a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt @@ -55,6 +55,7 @@ data class ToDoItemUiState( val iconRes: Int = R.drawable.ic_calendar, val tag: String? = null, val htmlUrl: String? = null, + val isClickable: Boolean = true, val onSwipeToDone: () -> Unit = {}, val onCheckboxToggle: (Boolean) -> Unit = {} ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index ef8eeacb6a..077d7362bc 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -31,6 +31,8 @@ import com.instructure.canvasapi2.utils.DateHelper import com.instructure.canvasapi2.utils.isInvited import com.instructure.canvasapi2.utils.toApiString import com.instructure.pandautils.R +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.features.todolist.filter.DateRangeSelection import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity @@ -64,6 +66,7 @@ class ToDoListViewModel @Inject constructor( private val apiPrefs: ApiPrefs, private val analytics: Analytics, private val toDoListViewModelBehavior: ToDoListViewModelBehavior, + private val calendarSharedEvents: CalendarSharedEvents, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -115,6 +118,20 @@ class ToDoListViewModel @Inject constructor( init { loadData() + observeCalendarSharedEvents() + } + + private fun observeCalendarSharedEvents() { + viewModelScope.launch { + calendarSharedEvents.events.collect { action -> + when (action) { + is SharedCalendarAction.RefreshToDoList -> { + loadData(forceRefresh = true) + } + else -> {} // Ignore other calendar actions + } + } + } } private fun loadData(forceRefresh: Boolean = false) { @@ -175,6 +192,10 @@ class ToDoListViewModel @Inject constructor( val itemId = plannerItem.plannable.id.toString() + // Account-level calendar events should not be clickable + val isAccountLevelEvent = plannerItem.contextType?.equals("Account", ignoreCase = true) == true + val isClickable = !(isAccountLevelEvent && itemType == ToDoItemType.CALENDAR_EVENT) + return ToDoItemUiState( id = itemId, title = plannerItem.plannable.title, @@ -187,6 +208,7 @@ class ToDoListViewModel @Inject constructor( iconRes = plannerItem.getIconForPlannerItem(), tag = plannerItem.getTagForPlannerItem(context), htmlUrl = plannerItem.getUrl(apiPrefs), + isClickable = isClickable, onSwipeToDone = { handleSwipeToDone(itemId) }, onCheckboxToggle = { isChecked -> handleCheckboxToggle(itemId, isChecked) } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt index 3e2a3a4008..db545ff387 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt @@ -136,6 +136,7 @@ fun PlannerItem.isComplete(): Boolean { return plannerOverride?.markedComplete ?: if (plannableType == PlannableType.ASSIGNMENT || plannableType == PlannableType.DISCUSSION_TOPIC || plannableType == PlannableType.SUB_ASSIGNMENT + || plannableType == PlannableType.QUIZ ) { submissionState?.submitted == true } else { @@ -161,7 +162,13 @@ fun List.filterByToDoFilters( } if (filters.favoriteCourses) { - val course = courses.find { it.id == item.courseId } + val courseIdToCheck = item.courseId ?: if (item.plannableType == PlannableType.PLANNER_NOTE) { + item.plannable.courseId + } else { + null + } + + val course = courses.find { it.id == courseIdToCheck } if (course != null && !course.isFavorite) { return@filter false } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt index 4466a7ac1d..0a91c1f0a2 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt @@ -36,6 +36,7 @@ import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDas import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardGroupItemViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardHeaderViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardNoteItemViewModel +import com.instructure.pandautils.features.calendar.CalendarSharedEvents import com.instructure.pandautils.mvvm.ViewState import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery @@ -68,6 +69,7 @@ class EditDashboardViewModelTest { private val groupManager: GroupManager = mockk(relaxed = true) private val repository: EditDashboardRepository = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val calendarSharedEvents: CalendarSharedEvents = mockk(relaxed = true) private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) @@ -99,7 +101,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} //Then @@ -116,7 +118,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } throws IllegalStateException() //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} //Then @@ -133,7 +135,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns emptyList() //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -159,7 +161,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -190,7 +192,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -224,7 +226,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -257,7 +259,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -286,7 +288,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -315,7 +317,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -349,7 +351,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -385,7 +387,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -422,7 +424,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -458,7 +460,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -493,7 +495,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -523,7 +525,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -553,7 +555,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -582,7 +584,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -644,7 +646,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -685,7 +687,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -729,7 +731,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -761,7 +763,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -796,7 +798,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -827,7 +829,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -854,7 +856,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -884,7 +886,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -910,7 +912,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -936,7 +938,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -961,7 +963,7 @@ class EditDashboardViewModelTest { coEvery { repository.getSyncedCourseIds() } returns setOf(1L) //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -985,7 +987,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -1008,7 +1010,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) val stateUpdates = mutableListOf() viewModel.state.observeForever { stateUpdates.add(it) @@ -1031,7 +1033,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) val stateUpdates = mutableListOf() viewModel.state.observeForever { stateUpdates.add(it) @@ -1044,6 +1046,64 @@ class EditDashboardViewModelTest { coVerify(exactly = 1) { repository.getCourses() } } + @Test + fun `RefreshToDoList event is sent when course is added to favorites`() { + //Given + val courses = listOf(createCourse(1L, "Current course", false)) + + coEvery { repository.getCourses() } returns listOf(courses, emptyList(), emptyList()) + + every { repository.isFavoriteable(any()) } returns true + + coEvery { repository.getGroups() } returns emptyList() + + every { courseManager.addCourseToFavoritesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Favorite(1L)) + } + + //When + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) + viewModel.state.observe(lifecycleOwner) {} + viewModel.data.observe(lifecycleOwner) {} + + val data = viewModel.data.value?.items ?: emptyList() + + val itemViewModel = (data[3] as EditDashboardCourseItemViewModel) + itemViewModel.onFavoriteClick() + + //Then + coVerify { calendarSharedEvents.sendEvent(any(), any()) } + } + + @Test + fun `RefreshToDoList event is sent when course is removed from favorites`() { + //Given + val courses = listOf(createCourse(1L, "Current course", true)) + + coEvery { repository.getCourses() } returns listOf(courses, emptyList(), emptyList()) + + every { repository.isFavoriteable(any()) } returns true + + coEvery { repository.getGroups() } returns emptyList() + + every { courseManager.removeCourseFromFavoritesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Favorite(1L)) + } + + //When + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) + viewModel.state.observe(lifecycleOwner) {} + viewModel.data.observe(lifecycleOwner) {} + + val data = viewModel.data.value?.items ?: emptyList() + + val itemViewModel = (data[3] as EditDashboardCourseItemViewModel) + itemViewModel.onFavoriteClick() + + //Then + coVerify { calendarSharedEvents.sendEvent(any(), any()) } + } + private fun createCourse( id: Long, name: String, diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt index 7351dc131a..f57f38a166 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt @@ -32,6 +32,8 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.R +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.features.todolist.filter.DateRangeSelection import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity @@ -48,6 +50,7 @@ import io.mockk.unmockkConstructor import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain @@ -73,6 +76,7 @@ class ToDoListViewModelTest { private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val analytics: Analytics = mockk(relaxed = true) private val toDoListViewModelBehavior: ToDoListViewModelBehavior = mockk(relaxed = true) + private val calendarSharedEvents: CalendarSharedEvents = mockk(relaxed = true) private val testUser = User(id = 123L, name = "Test User") private val testDomain = "test.instructure.com" @@ -104,6 +108,9 @@ class ToDoListViewModelTest { every { apiPrefs.user } returns testUser every { apiPrefs.fullDomain } returns testDomain + // Mock CalendarSharedEvents.events flow to return empty flow + every { calendarSharedEvents.events } returns MutableSharedFlow() + // Return a default filter that shows everything (including completed items) // This prevents tests from accidentally filtering out items val defaultTestFilter = ToDoFilterEntity( @@ -1387,9 +1394,177 @@ class ToDoListViewModelTest { assertEquals("four_weeks", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_FUTURE)) } + @Test + fun `Account-level calendar events are not clickable`() = runTest { + val accountCalendarEvent = createPlannerItem( + id = 1L, + title = "Account Event", + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "Account") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(accountCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertFalse(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `Course-level calendar events are clickable`() = runTest { + val courseCalendarEvent = createPlannerItem( + id = 1L, + title = "Course Event", + courseId = 1L, + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "Course") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(courseCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `User-level calendar events are clickable`() = runTest { + val userCalendarEvent = createPlannerItem( + id = 1L, + title = "User Event", + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "User") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(userCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `RefreshToDoList event triggers loadData with forceRefresh`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val initialPlannerItems = listOf(createPlannerItem(id = 1L, title = "Assignment 1")) + val refreshedPlannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(false) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), false) } returns DataResult.Success(initialPlannerItems) + coEvery { repository.getCourses(true) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), true) } returns DataResult.Success(refreshedPlannerItems) + + // Create a real MutableSharedFlow for testing + val sharedEventsFlow = MutableSharedFlow() + every { calendarSharedEvents.events } returns sharedEventsFlow + + val viewModel = getViewModel() + + // Verify initial data + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + + // Emit RefreshToDoList event + sharedEventsFlow.emit(SharedCalendarAction.RefreshToDoList) + + // Verify data was reloaded with forceRefresh=true + coVerify { repository.getCourses(true) } + coVerify { repository.getPlannerItems(any(), any(), true) } + assertEquals(2, viewModel.uiState.value.itemsByDate.values.flatten().size) + } + + @Test + fun `Empty state is shown when completing the last item via swipe`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Last Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Verify we start with one item + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + assertEquals(1, viewModel.uiState.value.toDoCount) + + // Complete the last item via swipe + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + // Item should be marked as checked + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + // Item should be added to removingItemIds (will be hidden from UI, triggering empty state) + assertTrue(uiState.removingItemIds.contains("1")) + // Todo count should be zero + assertEquals(0, uiState.toDoCount) + } + + @Test + fun `Empty state is shown when completing the last item via checkbox after debounce`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Last Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Verify we start with one item and todo count is 1 + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + assertEquals(1, viewModel.uiState.value.toDoCount) + + // Complete the last item via checkbox + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Todo count should be zero after marking as done + assertEquals(0, viewModel.uiState.value.toDoCount) + // Item should NOT be in removingItemIds yet (debounced) + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + + // Advance time past debounce delay + advanceTimeBy(1100) + + // Now item should be added to removingItemIds, which hides it from UI (empty state) + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + assertEquals(0, viewModel.uiState.value.toDoCount) + } + // Helper functions private fun getViewModel(): ToDoListViewModel { - return ToDoListViewModel(context, repository, networkStateProvider, firebaseCrashlytics, toDoFilterDao, apiPrefs, analytics, toDoListViewModelBehavior) + return ToDoListViewModel(context, repository, networkStateProvider, firebaseCrashlytics, toDoFilterDao, apiPrefs, analytics, toDoListViewModelBehavior, calendarSharedEvents) } private fun createPlannerItem( diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt index b52f8f75c8..c46eaa65da 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt @@ -463,6 +463,59 @@ class PlannerItemExtensionsTest { assertEquals(false, result) } + @Test + fun `isComplete returns true for QUIZ when submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + + @Test + fun `isComplete returns false for QUIZ when not submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns false for QUIZ with null submission state`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = null + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns true for QUIZ with plannerOverride marked complete`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + plannerOverride = com.instructure.canvasapi2.models.PlannerOverride( + plannableType = PlannableType.QUIZ, + plannableId = 1L, + markedComplete = true + ), + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + // filterByToDoFilters tests @Test fun `filterByToDoFilters filters out PLANNER_NOTE when personalTodos is false`() { @@ -729,6 +782,54 @@ class PlannerItemExtensionsTest { assertEquals(1L, result[1].courseId) } + @Test + fun `filterByToDoFilters uses plannable courseId for PLANNER_NOTE when item courseId is null`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = null, + plannable = createPlannable(courseId = 1L) + ), + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = null, + plannable = createPlannable(courseId = 2L) + ) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Only PLANNER_NOTE with favorite course (via plannable.courseId) should be included + assertEquals(1, result.size) + assertEquals(1L, result[0].plannable.courseId) + } + + @Test + fun `filterByToDoFilters prefers item courseId over plannable courseId for PLANNER_NOTE`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = 2L, // item.courseId is set (non-favorite) + plannable = createPlannable(courseId = 1L) // plannable.courseId is favorite + ) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Should use item.courseId (2L) which is not favorite, so item is filtered out + assertEquals(0, result.size) + } + @Test fun `filterByToDoFilters handles DISCUSSION_TOPIC completion`() { val filters = createToDoFilterEntity(showCompleted = false)