From b622908143ac09c67106f21865cb4777f42cd9d0 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 11:12:32 +0100 Subject: [PATCH 01/18] Swipe actin UI. --- libs/pandares/src/main/res/values/strings.xml | 2 + .../features/todolist/ToDoListScreen.kt | 163 ++++++++++++++++++ .../features/todolist/ToDoListViewModel.kt | 6 +- 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index f20aadf279..11cbdea129 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2188,4 +2188,6 @@ There was an error loading your to-do items. Please check your connection and try again. No To Dos for now! It looks like a great time to rest, relax, and recharge. + Done + Undo 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 ca2e86382f..78dd4e13af 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 @@ -15,10 +15,15 @@ */ package com.instructure.pandautils.features.todolist +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -46,13 +51,16 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.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.LocalContext @@ -79,10 +87,13 @@ import com.instructure.pandautils.compose.composables.Loading import com.instructure.pandautils.compose.modifiers.conditional import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.courseOrUserColor +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.abs import kotlin.math.roundToInt private data class StickyHeaderState( @@ -310,12 +321,164 @@ private fun ToDoItem( onClick: () -> Unit, modifier: Modifier = Modifier, hideDate: Boolean = false +) { + val coroutineScope = rememberCoroutineScope() + val animatedOffsetX = remember { Animatable(0f) } + var itemWidth by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + + val swipeThreshold = with(density) { 150.dp.toPx() } + + fun animateToCenter() { + coroutineScope.launch { + animatedOffsetX.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 300) + ) + } + } + + fun handleSwipeEnd() { + coroutineScope.launch { + val currentOffset = animatedOffsetX.value + val absOffset = abs(currentOffset) + if (absOffset >= swipeThreshold) { + val targetOffset = if (currentOffset > 0) itemWidth else -itemWidth + animatedOffsetX.animateTo( + targetValue = targetOffset, + animationSpec = tween(durationMillis = 200) + ) + delay(300) + } + animateToCenter() + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + itemWidth = coordinates.size.width.toFloat() + } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { handleSwipeEnd() }, + onDragCancel = { animateToCenter() }, + onHorizontalDrag = { _, dragAmount -> + coroutineScope.launch { + val newOffset = (animatedOffsetX.value + dragAmount).coerceIn(-itemWidth, itemWidth) + animatedOffsetX.snapTo(newOffset) + } + } + ) + } + ) { + SwipeBackground( + isChecked = item.isChecked, + offsetX = animatedOffsetX.value + ) + + ToDoItemContent( + item = item, + showDateBadge = showDateBadge, + hideDate = hideDate, + onCheckedChange = onCheckedChange, + onClick = onClick, + modifier = Modifier.offset { IntOffset(animatedOffsetX.value.roundToInt(), 0) } + ) + } +} + +@Composable +private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { + val backgroundColor = if (isChecked) { + colorResource(R.color.backgroundDark) + } else { + colorResource(R.color.backgroundSuccess) + } + + val text = if (isChecked) { + stringResource(id = R.string.todoSwipeUndo) + } else { + stringResource(id = R.string.todoSwipeDone) + } + + val icon = if (isChecked) { + R.drawable.ic_reply + } else { + R.drawable.ic_checkmark_lined + } + + Box( + modifier = Modifier + .matchParentSize() + .background(backgroundColor) + ) { + if (offsetX > 0) { + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = colorResource(R.color.textLightest), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textLightest) + ) + } + } + + if (offsetX < 0) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textLightest) + ) + Spacer(modifier = Modifier.width(12.dp)) + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = colorResource(R.color.textLightest), + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +private fun ToDoItemContent( + item: ToDoItemUiState, + showDateBadge: Boolean, + hideDate: Boolean, + onCheckedChange: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { val dateBadgeData = rememberDateBadgeData(item.date) Row( modifier = modifier .fillMaxWidth() + .background(colorResource(id = R.color.backgroundLightest)) .clickable(onClick = onClick) .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.Top 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 fe424743c0..629dd0da3c 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 @@ -62,7 +62,7 @@ class ToDoListViewModel @Inject constructor( val now = LocalDate.now().atStartOfDay() val startDate = now.minusDays(7).toApiString().orEmpty() - val endDate = now.plusDays(7).toApiString().orEmpty() + val endDate = now.plusDays(28).toApiString().orEmpty() // TODO revert val courses = repository.getCourses(forceRefresh).dataOrThrow val plannerItems = repository.getPlannerItems(startDate, endDate, forceRefresh).dataOrThrow @@ -134,13 +134,13 @@ class ToDoListViewModel @Inject constructor( } private fun isComplete(plannerItem: PlannerItem): Boolean { - return if (plannerItem.plannableType == PlannableType.ASSIGNMENT + return plannerItem.plannerOverride?.markedComplete ?: if (plannerItem.plannableType == PlannableType.ASSIGNMENT || plannerItem.plannableType == PlannableType.DISCUSSION_TOPIC || plannerItem.plannableType == PlannableType.SUB_ASSIGNMENT ) { plannerItem.submissionState?.submitted == true } else { - plannerItem.plannerOverride?.markedComplete == true + false } } From e3ffdd3aaac7535cbe335646378c4a7d479e8d9f Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 12:55:44 +0100 Subject: [PATCH 02/18] Swipe action --- .../features/todolist/ToDoListFragment.kt | 6 ++ libs/pandares/src/main/res/values/strings.xml | 1 + .../features/todolist/ToDoListScreen.kt | 5 +- .../features/todolist/ToDoListUiState.kt | 4 +- .../features/todolist/ToDoListViewModel.kt | 71 ++++++++++++++++++- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt index 9797b598d3..e7c57c97bf 100644 --- a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.interactions.FragmentInteractions @@ -97,6 +98,11 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC private fun handleAction(action: ToDoListViewModelAction) { when (action) { is ToDoListViewModelAction.OpenToDoItem -> toDoListRouter.openToDoItem(action.itemId) + is ToDoListViewModelAction.ShowSnackbar -> { + view?.let { view -> + Snackbar.make(view, action.message, Snackbar.LENGTH_LONG).show() + } + } } } diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 11cbdea129..ee2fcf8ad4 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2186,6 +2186,7 @@ Multiple filters Filter There was an error loading your to-do items. Please check your connection and try again. + There was an error updating the to-do item. Please check your connection and try again. No To Dos for now! It looks like a great time to rest, relax, and recharge. Done 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 78dd4e13af..c46dc7f2bb 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 @@ -349,8 +349,11 @@ private fun ToDoItem( animationSpec = tween(durationMillis = 200) ) delay(300) + animateToCenter() + item.onSwipeToDone() + } else { + animateToCenter() } - animateToCenter() } } 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 03f7481d46..0eda54e491 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 @@ -36,7 +36,8 @@ data class ToDoItemUiState( val itemType: ToDoItemType, val isChecked: Boolean = false, val iconRes: Int = R.drawable.ic_calendar, - val tag: String? = null + val tag: String? = null, + val onSwipeToDone: () -> Unit = {} ) enum class ToDoItemType { @@ -50,6 +51,7 @@ enum class ToDoItemType { sealed class ToDoListViewModelAction { data class OpenToDoItem(val itemId: String) : ToDoListViewModelAction() + data class ShowSnackbar(val message: String) : ToDoListViewModelAction() } sealed class ToDoListActionHandler { 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 629dd0da3c..471d4bf365 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 @@ -28,6 +28,7 @@ import com.instructure.pandautils.utils.getContextNameForPlannerItem import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem import com.instructure.pandautils.utils.getTagForPlannerItem +import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel @@ -51,6 +52,8 @@ class ToDoListViewModel @Inject constructor( private val _events = Channel() val events = _events.receiveAsFlow() + private val plannerItemsMap = mutableMapOf() + init { loadData() } @@ -78,6 +81,10 @@ class ToDoListViewModel @Inject constructor( .filter { it.plannableType != PlannableType.ANNOUNCEMENT && it.plannableType != PlannableType.ASSESSMENT_REQUEST } .sortedBy { it.comparisonDate } + // Store planner items for later reference + plannerItemsMap.clear() + filteredItems.forEach { plannerItemsMap[it.plannable.id.toString()] = it } + // Group items by date val itemsByDate = filteredItems .groupBy { DateHelper.getCleanDate(it.comparisonDate.time) } @@ -119,8 +126,10 @@ class ToDoListViewModel @Inject constructor( else -> ToDoItemType.CALENDAR_EVENT } + val itemId = plannerItem.plannable.id.toString() + return ToDoItemUiState( - id = plannerItem.plannable.id.toString(), + id = itemId, title = plannerItem.plannable.title, date = plannerItem.plannableDate, dateLabel = plannerItem.getDateTextForPlannerItem(context), @@ -129,7 +138,8 @@ class ToDoListViewModel @Inject constructor( itemType = itemType, isChecked = isComplete(plannerItem), iconRes = plannerItem.getIconForPlannerItem(), - tag = plannerItem.getTagForPlannerItem(context) + tag = plannerItem.getTagForPlannerItem(context), + onSwipeToDone = { handleSwipeToDone(itemId) } ) } @@ -144,6 +154,63 @@ class ToDoListViewModel @Inject constructor( } } + private fun handleSwipeToDone(itemId: String) { + viewModelScope.launch { + val plannerItem = plannerItemsMap[itemId] ?: return@launch + val currentIsChecked = isComplete(plannerItem) + val newIsChecked = !currentIsChecked + + // Optimistically update UI + updateItemCheckedState(itemId, newIsChecked) + + try { + // Update or create planner override + val plannerOverrideResult = if (plannerItem.plannerOverride?.id != null) { + repository.updatePlannerOverride( + plannerOverrideId = plannerItem.plannerOverride?.id.orDefault(), + markedComplete = newIsChecked + ).dataOrThrow + } else { + repository.createPlannerOverride( + plannableId = plannerItem.plannable.id, + plannableType = plannerItem.plannableType, + markedComplete = newIsChecked + ).dataOrThrow + } + + // Update the stored planner item with new override state + val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult) + plannerItemsMap[itemId] = updatedPlannerItem + + } catch (e: Exception) { + e.printStackTrace() + // Revert the optimistic update + updateItemCheckedState(itemId, currentIsChecked) + // Show error snackbar + _events.send( + ToDoListViewModelAction.ShowSnackbar( + context.getString(com.instructure.pandautils.R.string.errorUpdatingToDo) + ) + ) + } + } + } + + private fun updateItemCheckedState(itemId: String, isChecked: Boolean) { + _uiState.update { state -> + val updatedItemsByDate = state.itemsByDate.mapValues { (_, items) -> + items.map { item -> + if (item.id == itemId) { + item.copy(isChecked = isChecked) + } else { + item + } + } + } + state.copy(itemsByDate = updatedItemsByDate) + } + } + fun handleAction(action: ToDoListActionHandler) { when (action) { is ToDoListActionHandler.ItemClicked -> { From 6770828a38282b7e5d00b20c4417445e6235fd0c Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 18:15:55 +0100 Subject: [PATCH 03/18] Refactored viewModel injection and screen architecture so the content of the screen could be reused. --- .../features/todolist/ToDoListFragment.kt | 33 +---- .../features/todolist/ToDoListScreen.kt | 122 +++++++++++------- .../features/todolist/ToDoListUiState.kt | 20 +-- .../features/todolist/ToDoListViewModel.kt | 48 +++---- .../todolist/ToDoListViewModelTest.kt | 20 +-- 5 files changed, 116 insertions(+), 127 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt index e7c57c97bf..ed879cc09a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt @@ -20,12 +20,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.pageview.PageView @@ -38,12 +34,9 @@ import com.instructure.pandautils.base.BaseCanvasFragment import com.instructure.pandautils.compose.CanvasTheme import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.pandautils.features.todolist.ToDoListScreen -import com.instructure.pandautils.features.todolist.ToDoListViewModel -import com.instructure.pandautils.features.todolist.ToDoListViewModelAction import com.instructure.pandautils.interfaces.NavigationCallbacks import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler -import com.instructure.pandautils.utils.collectOneOffEvents import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.withArgs import com.instructure.student.R @@ -55,8 +48,6 @@ import javax.inject.Inject @AndroidEntryPoint class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationCallbacks { - private val viewModel: ToDoListViewModel by viewModels() - @Inject lateinit var toDoListRouter: ToDoListRouter @@ -66,17 +57,18 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC savedInstanceState: Bundle? ): View { applyTheme() - viewLifecycleOwner.lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) return ComposeView(requireActivity()).apply { setContent { CanvasTheme { - val uiState by viewModel.uiState.collectAsState() - ToDoListScreen( - uiState = uiState, - actionHandler = viewModel::handleAction, - navigationIconClick = { toDoListRouter.openNavigationDrawer() } + navigationIconClick = { toDoListRouter.openNavigationDrawer() }, + openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) }, + showSnackbar = { message -> + view?.let { view -> + Snackbar.make(view, message, Snackbar.LENGTH_LONG).show() + } + } ) } } @@ -95,17 +87,6 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC override fun getFragment(): Fragment = this - private fun handleAction(action: ToDoListViewModelAction) { - when (action) { - is ToDoListViewModelAction.OpenToDoItem -> toDoListRouter.openToDoItem(action.itemId) - is ToDoListViewModelAction.ShowSnackbar -> { - view?.let { view -> - Snackbar.make(view, action.message, Snackbar.LENGTH_LONG).show() - } - } - } - } - override fun onHandleBackPressed(): Boolean { return false } 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 c46dc7f2bb..f8b0974353 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 @@ -49,6 +49,8 @@ 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.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -75,6 +77,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R @@ -113,14 +116,31 @@ private data class DateBadgeData( @OptIn(ExperimentalMaterialApi::class) @Composable fun ToDoListScreen( - uiState: ToDoListUiState, - actionHandler: (ToDoListActionHandler) -> Unit, - modifier: Modifier = Modifier, - navigationIconClick: () -> Unit = {} + navigationIconClick: () -> Unit, + openToDoItem: (String) -> Unit, + showSnackbar: (String) -> Unit, + modifier: Modifier = Modifier ) { + val viewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.openToDoItemId) { + uiState.openToDoItemId?.let { itemId -> + openToDoItem(itemId) + uiState.onOpenToDoItem() + } + } + + LaunchedEffect(uiState.snackbarMessage) { + uiState.snackbarMessage?.let { message -> + showSnackbar(message) + uiState.onSnackbarDismissed() + } + } + val pullRefreshState = rememberPullRefreshState( refreshing = uiState.isRefreshing, - onRefresh = { actionHandler(ToDoListActionHandler.Refresh) } + onRefresh = uiState.onRefresh ) Scaffold( @@ -132,7 +152,7 @@ fun ToDoListScreen( navIconContentDescription = stringResource(id = R.string.navigation_drawer_open), navigationActionClick = navigationIconClick, actions = { - IconButton(onClick = { actionHandler(ToDoListActionHandler.FilterClicked) }) { + IconButton(onClick = { /* TODO: Implement filter - will be implemented in future story */ }) { Icon( painter = painterResource(id = R.drawable.ic_filter_outline), contentDescription = stringResource(id = R.string.a11y_contentDescriptionToDoFilter) @@ -149,35 +169,9 @@ fun ToDoListScreen( .padding(padding) .pullRefresh(pullRefreshState) ) { - when { - uiState.isLoading -> { - Loading(modifier = Modifier.align(Alignment.Center)) - } - - uiState.isError -> { - ErrorContent( - errorMessage = stringResource(id = R.string.errorLoadingToDos), - retryClick = { actionHandler(ToDoListActionHandler.Refresh) }, - modifier = Modifier.fillMaxSize() - ) - } - - uiState.itemsByDate.isEmpty() -> { - EmptyContent( - emptyTitle = stringResource(id = R.string.noToDosForNow), - emptyMessage = stringResource(id = R.string.noToDosForNowSubtext), - imageRes = R.drawable.ic_no_events, - modifier = Modifier.fillMaxSize() - ) - } - - else -> { - ToDoListContent( - itemsByDate = uiState.itemsByDate, - actionHandler = actionHandler - ) - } - } + ToDoListContent( + uiState = uiState + ) PullRefreshIndicator( refreshing = uiState.isRefreshing, @@ -192,8 +186,45 @@ fun ToDoListScreen( @Composable private fun ToDoListContent( + uiState: ToDoListUiState, + modifier: Modifier = Modifier +) { + when { + uiState.isLoading -> { + Loading(modifier = modifier.fillMaxSize()) + } + + uiState.isError -> { + ErrorContent( + errorMessage = stringResource(id = R.string.errorLoadingToDos), + retryClick = uiState.onRefresh, + modifier = modifier.fillMaxSize() + ) + } + + uiState.itemsByDate.isEmpty() -> { + EmptyContent( + emptyTitle = stringResource(id = R.string.noToDosForNow), + emptyMessage = stringResource(id = R.string.noToDosForNowSubtext), + imageRes = R.drawable.ic_no_events, + modifier = modifier.fillMaxSize() + ) + } + + else -> { + ToDoItemsList( + itemsByDate = uiState.itemsByDate, + onItemClicked = uiState.onItemClicked, + modifier = modifier + ) + } + } +} + +@Composable +private fun ToDoItemsList( itemsByDate: Map>, - actionHandler: (ToDoListActionHandler) -> Unit, + onItemClicked: (String) -> Unit, modifier: Modifier = Modifier ) { val dateGroups = itemsByDate.entries.toList() @@ -245,8 +276,8 @@ private fun ToDoListContent( item = item, showDateBadge = index == 0, hideDate = index == 0 && stickyHeaderState.isVisible && stickyHeaderState.item?.id == item.id, - onCheckedChange = { actionHandler(ToDoListActionHandler.ToggleItemChecked(item.id)) }, - onClick = { actionHandler(ToDoListActionHandler.ItemClicked(item.id)) }, + onCheckedChange = { /* TODO: Implement toggle checked - will be implemented in future story */ }, + onClick = { onItemClicked(item.id) }, modifier = Modifier.onGloballyPositioned { coordinates -> itemPositions[item.id] = coordinates.positionInParent().y itemSizes[item.id] = coordinates.size.height @@ -761,7 +792,7 @@ fun ToDoListScreenPreview() { ContextKeeper.appContext = LocalContext.current val calendar = Calendar.getInstance() CanvasTheme { - ToDoListScreen( + ToDoListContent( uiState = ToDoListUiState( itemsByDate = mapOf( Date(10) to listOf( @@ -860,8 +891,7 @@ fun ToDoListScreenPreview() { ) ) ) - ), - actionHandler = {} + ) ) } } @@ -873,7 +903,7 @@ fun ToDoListScreenWithPandasPreview() { ContextKeeper.appContext = LocalContext.current val calendar = Calendar.getInstance() CanvasTheme { - ToDoListScreen( + ToDoListContent( uiState = ToDoListUiState( itemsByDate = mapOf( Date(10) to listOf( @@ -901,8 +931,7 @@ fun ToDoListScreenWithPandasPreview() { ) ) ) - ), - actionHandler = {} + ) ) } } @@ -913,9 +942,8 @@ fun ToDoListScreenWithPandasPreview() { fun ToDoListScreenEmptyPreview() { ContextKeeper.appContext = LocalContext.current CanvasTheme { - ToDoListScreen( - uiState = ToDoListUiState(), - actionHandler = {} + ToDoListContent( + uiState = ToDoListUiState() ) } } 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 0eda54e491..b8538eeb71 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 @@ -23,7 +23,13 @@ data class ToDoListUiState( val isLoading: Boolean = false, val isError: Boolean = false, val isRefreshing: Boolean = false, - val itemsByDate: Map> = emptyMap() + val itemsByDate: Map> = emptyMap(), + val openToDoItemId: String? = null, + val onOpenToDoItem: () -> Unit = {}, + val snackbarMessage: String? = null, + val onSnackbarDismissed: () -> Unit = {}, + val onItemClicked: (String) -> Unit = {}, + val onRefresh: () -> Unit = {} ) data class ToDoItemUiState( @@ -48,15 +54,3 @@ enum class ToDoItemType { CALENDAR_EVENT, PLANNER_NOTE } - -sealed class ToDoListViewModelAction { - data class OpenToDoItem(val itemId: String) : ToDoListViewModelAction() - data class ShowSnackbar(val message: String) : ToDoListViewModelAction() -} - -sealed class ToDoListActionHandler { - data object Refresh : ToDoListActionHandler() - data class ToggleItemChecked(val itemId: String) : ToDoListActionHandler() - data class ItemClicked(val itemId: String) : ToDoListActionHandler() - data object FilterClicked : ToDoListActionHandler() -} 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 471d4bf365..c693dc317c 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,10 +31,8 @@ import com.instructure.pandautils.utils.getTagForPlannerItem import com.instructure.pandautils.utils.orDefault 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 org.threeten.bp.LocalDate @@ -46,12 +44,14 @@ class ToDoListViewModel @Inject constructor( private val repository: ToDoListRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(ToDoListUiState()) + private val _uiState = MutableStateFlow(ToDoListUiState( + onOpenToDoItem = { clearOpenToDoItem() }, + onSnackbarDismissed = { clearSnackbarMessage() }, + onItemClicked = { itemId -> handleItemClicked(itemId) }, + onRefresh = { handleRefresh() } + )) val uiState = _uiState.asStateFlow() - private val _events = Channel() - val events = _events.receiveAsFlow() - private val plannerItemsMap = mutableMapOf() init { @@ -187,11 +187,9 @@ class ToDoListViewModel @Inject constructor( // Revert the optimistic update updateItemCheckedState(itemId, currentIsChecked) // Show error snackbar - _events.send( - ToDoListViewModelAction.ShowSnackbar( - context.getString(com.instructure.pandautils.R.string.errorUpdatingToDo) - ) - ) + _uiState.update { + it.copy(snackbarMessage = context.getString(com.instructure.pandautils.R.string.errorUpdatingToDo)) + } } } } @@ -211,25 +209,19 @@ class ToDoListViewModel @Inject constructor( } } - fun handleAction(action: ToDoListActionHandler) { - when (action) { - is ToDoListActionHandler.ItemClicked -> { - viewModelScope.launch { - _events.send(ToDoListViewModelAction.OpenToDoItem(action.itemId)) - } - } + private fun handleItemClicked(itemId: String) { + _uiState.update { it.copy(openToDoItemId = itemId) } + } - is ToDoListActionHandler.Refresh -> { - loadData(forceRefresh = true) - } + private fun handleRefresh() { + loadData(forceRefresh = true) + } - is ToDoListActionHandler.ToggleItemChecked -> { - // TODO: Implement toggle checked - will be implemented in future story - } + private fun clearOpenToDoItem() { + _uiState.update { it.copy(openToDoItemId = null) } + } - is ToDoListActionHandler.FilterClicked -> { - // TODO: Implement filter - will be implemented in future story - } - } + private fun clearSnackbarMessage() { + _uiState.update { it.copy(snackbarMessage = null) } } } 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 e466f0fc25..37fdd6c3ef 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 @@ -290,28 +290,22 @@ class ToDoListViewModelTest { assertFalse(item.isChecked) } - // handleAction tests + // Callback tests @Test - fun `handleAction ItemClicked sends OpenToDoItem event`() = runTest { + fun `onItemClicked callback updates openToDoItemId in UiState`() = runTest { coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) val viewModel = getViewModel() - val events = mutableListOf() - backgroundScope.launch(testDispatcher) { - viewModel.events.toList(events) - } + viewModel.uiState.value.onItemClicked("123") - viewModel.handleAction(ToDoListActionHandler.ItemClicked("123")) - - assertEquals(1, events.size) - assertTrue(events.first() is ToDoListViewModelAction.OpenToDoItem) - assertEquals("123", (events.first() as ToDoListViewModelAction.OpenToDoItem).itemId) + val uiState = viewModel.uiState.value + assertEquals("123", uiState.openToDoItemId) } @Test - fun `handleAction Refresh triggers data reload with forceRefresh`() = runTest { + fun `onRefresh callback triggers data reload 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( @@ -332,7 +326,7 @@ class ToDoListViewModelTest { assertEquals("Assignment 1", initialUiState.itemsByDate.values.flatten().first().title) // Trigger refresh - viewModel.handleAction(ToDoListActionHandler.Refresh) + viewModel.uiState.value.onRefresh() // Verify refreshed data val refreshedUiState = viewModel.uiState.value From 046f653b5da11b00953f511f1a8f3d1280314d37 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 19:15:01 +0100 Subject: [PATCH 04/18] Undo feature. --- .../features/todolist/ToDoListFragment.kt | 8 +- libs/pandares/src/main/res/values/strings.xml | 2 + .../features/todolist/ToDoListScreen.kt | 34 ++++++- .../features/todolist/ToDoListUiState.kt | 8 ++ .../features/todolist/ToDoListViewModel.kt | 92 +++++++++++++------ 5 files changed, 109 insertions(+), 35 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt index ed879cc09a..3d92dbfc8f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt @@ -22,7 +22,6 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment -import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.interactions.FragmentInteractions @@ -63,12 +62,7 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC CanvasTheme { ToDoListScreen( navigationIconClick = { toDoListRouter.openNavigationDrawer() }, - openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) }, - showSnackbar = { message -> - view?.let { view -> - Snackbar.make(view, message, Snackbar.LENGTH_LONG).show() - } - } + openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) } ) } } diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index ee2fcf8ad4..fc0fb4ac85 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2191,4 +2191,6 @@ It looks like a great time to rest, relax, and recharge. Done Undo + %s marked as done + Undo 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 f8b0974353..0252268d90 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 @@ -44,6 +44,11 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Scaffold +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -118,11 +123,12 @@ private data class DateBadgeData( fun ToDoListScreen( navigationIconClick: () -> Unit, openToDoItem: (String) -> Unit, - showSnackbar: (String) -> Unit, modifier: Modifier = Modifier ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(uiState.openToDoItemId) { uiState.openToDoItemId?.let { itemId -> @@ -133,11 +139,27 @@ fun ToDoListScreen( LaunchedEffect(uiState.snackbarMessage) { uiState.snackbarMessage?.let { message -> - showSnackbar(message) + snackbarHostState.showSnackbar(message) uiState.onSnackbarDismissed() } } + LaunchedEffect(uiState.markedAsDoneItem) { + uiState.markedAsDoneItem?.let { item -> + val message = context.getString(R.string.todoMarkedAsDone, item.title) + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = context.getString(R.string.todoMarkedAsDoneSnackbarUndo), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + uiState.onUndoMarkAsDone() + } else { + uiState.onMarkedAsDoneSnackbarDismissed() + } + } + } + val pullRefreshState = rememberPullRefreshState( refreshing = uiState.isRefreshing, onRefresh = uiState.onRefresh @@ -161,6 +183,14 @@ fun ToDoListScreen( } ) }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + actionColor = Color(ThemePrefs.textButtonColor) + ) + } + }, modifier = modifier ) { padding -> Box( 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 b8538eeb71..415617a8b9 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 @@ -28,10 +28,18 @@ data class ToDoListUiState( val onOpenToDoItem: () -> Unit = {}, val snackbarMessage: String? = null, val onSnackbarDismissed: () -> Unit = {}, + val markedAsDoneItem: MarkedAsDoneItem? = null, + val onUndoMarkAsDone: () -> Unit = {}, + val onMarkedAsDoneSnackbarDismissed: () -> Unit = {}, val onItemClicked: (String) -> Unit = {}, val onRefresh: () -> Unit = {} ) +data class MarkedAsDoneItem( + val itemId: String, + val title: String +) + data class ToDoItemUiState( val id: String, val title: String, 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 c693dc317c..e688981e85 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 @@ -24,6 +24,7 @@ import com.instructure.canvasapi2.models.PlannerItem 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.utils.getContextNameForPlannerItem import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem @@ -47,6 +48,8 @@ class ToDoListViewModel @Inject constructor( private val _uiState = MutableStateFlow(ToDoListUiState( onOpenToDoItem = { clearOpenToDoItem() }, onSnackbarDismissed = { clearSnackbarMessage() }, + onUndoMarkAsDone = { handleUndoMarkAsDone() }, + onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, onItemClicked = { itemId -> handleItemClicked(itemId) }, onRefresh = { handleRefresh() } )) @@ -160,37 +163,70 @@ class ToDoListViewModel @Inject constructor( val currentIsChecked = isComplete(plannerItem) val newIsChecked = !currentIsChecked - // Optimistically update UI - updateItemCheckedState(itemId, newIsChecked) + val success = updateItemCompleteState(itemId, newIsChecked) - try { - // Update or create planner override - val plannerOverrideResult = if (plannerItem.plannerOverride?.id != null) { - repository.updatePlannerOverride( - plannerOverrideId = plannerItem.plannerOverride?.id.orDefault(), - markedComplete = newIsChecked - ).dataOrThrow - } else { - repository.createPlannerOverride( - plannableId = plannerItem.plannable.id, - plannableType = plannerItem.plannableType, - markedComplete = newIsChecked - ).dataOrThrow + // Show marked-as-done snackbar only when marking as done (not when undoing) + if (success && newIsChecked) { + _uiState.update { + it.copy( + markedAsDoneItem = MarkedAsDoneItem( + itemId = itemId, + title = plannerItem.plannable.title + ) + ) } + } + } + } - // Update the stored planner item with new override state - val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult) - plannerItemsMap[itemId] = updatedPlannerItem + private fun handleUndoMarkAsDone() { + viewModelScope.launch { + val markedAsDoneItem = _uiState.value.markedAsDoneItem ?: return@launch + val itemId = markedAsDoneItem.itemId - } catch (e: Exception) { - e.printStackTrace() - // Revert the optimistic update - updateItemCheckedState(itemId, currentIsChecked) - // Show error snackbar - _uiState.update { - it.copy(snackbarMessage = context.getString(com.instructure.pandautils.R.string.errorUpdatingToDo)) - } + // Clear the snackbar immediately + _uiState.update { it.copy(markedAsDoneItem = null) } + + updateItemCompleteState(itemId, false) + } + } + + private suspend fun updateItemCompleteState(itemId: String, newIsChecked: Boolean): Boolean { + val plannerItem = plannerItemsMap[itemId] ?: return false + val currentIsChecked = isComplete(plannerItem) + + // Optimistically update UI + updateItemCheckedState(itemId, newIsChecked) + + return try { + // Update or create planner override + val plannerOverrideResult = if (plannerItem.plannerOverride?.id != null) { + repository.updatePlannerOverride( + plannerOverrideId = plannerItem.plannerOverride?.id.orDefault(), + markedComplete = newIsChecked + ).dataOrThrow + } else { + repository.createPlannerOverride( + plannableId = plannerItem.plannable.id, + plannableType = plannerItem.plannableType, + markedComplete = newIsChecked + ).dataOrThrow } + + // Update the stored planner item with new override state + val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult) + plannerItemsMap[itemId] = updatedPlannerItem + + true + } catch (e: Exception) { + e.printStackTrace() + // Revert the optimistic update + updateItemCheckedState(itemId, currentIsChecked) + // Show error snackbar + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.errorUpdatingToDo)) + } + false } } @@ -224,4 +260,8 @@ class ToDoListViewModel @Inject constructor( private fun clearSnackbarMessage() { _uiState.update { it.copy(snackbarMessage = null) } } + + private fun clearMarkedAsDoneItem() { + _uiState.update { it.copy(markedAsDoneItem = null) } + } } From 5cc62587ccf561368b0c7f66f0eba1d8912d3dda Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 19:30:44 +0100 Subject: [PATCH 05/18] Checkbox action. --- .../features/todolist/ToDoListScreen.kt | 2 +- .../features/todolist/ToDoListUiState.kt | 3 ++- .../features/todolist/ToDoListViewModel.kt | 23 ++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) 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 0252268d90..30d71b5798 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 @@ -306,7 +306,7 @@ private fun ToDoItemsList( item = item, showDateBadge = index == 0, hideDate = index == 0 && stickyHeaderState.isVisible && stickyHeaderState.item?.id == item.id, - onCheckedChange = { /* TODO: Implement toggle checked - will be implemented in future story */ }, + onCheckedChange = { item.onCheckboxToggle(!item.isChecked) }, onClick = { onItemClicked(item.id) }, modifier = Modifier.onGloballyPositioned { coordinates -> itemPositions[item.id] = coordinates.positionInParent().y 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 415617a8b9..50c92796d0 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 @@ -51,7 +51,8 @@ data class ToDoItemUiState( val isChecked: Boolean = false, val iconRes: Int = R.drawable.ic_calendar, val tag: String? = null, - val onSwipeToDone: () -> Unit = {} + val onSwipeToDone: () -> Unit = {}, + val onCheckboxToggle: (Boolean) -> Unit = {} ) enum class ToDoItemType { 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 e688981e85..3a9f76931a 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 @@ -142,7 +142,8 @@ class ToDoListViewModel @Inject constructor( isChecked = isComplete(plannerItem), iconRes = plannerItem.getIconForPlannerItem(), tag = plannerItem.getTagForPlannerItem(context), - onSwipeToDone = { handleSwipeToDone(itemId) } + onSwipeToDone = { handleSwipeToDone(itemId) }, + onCheckboxToggle = { isChecked -> handleCheckboxToggle(itemId, isChecked) } ) } @@ -191,6 +192,26 @@ class ToDoListViewModel @Inject constructor( } } + private fun handleCheckboxToggle(itemId: String, isChecked: Boolean) { + viewModelScope.launch { + val plannerItem = plannerItemsMap[itemId] ?: return@launch + + val success = updateItemCompleteState(itemId, isChecked) + + // Show marked-as-done snackbar only when checking the box + if (success && isChecked) { + _uiState.update { + it.copy( + markedAsDoneItem = MarkedAsDoneItem( + itemId = itemId, + title = plannerItem.plannable.title + ) + ) + } + } + } + } + private suspend fun updateItemCompleteState(itemId: String, newIsChecked: Boolean): Boolean { val plannerItem = plannerItemsMap[itemId] ?: return false val currentIsChecked = isComplete(plannerItem) From c46a1b8f42600c946ae2cee52a4def37c130a340 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 19:54:41 +0100 Subject: [PATCH 06/18] Offline and haptics. --- libs/pandares/src/main/res/values/strings.xml | 1 + .../features/todolist/ToDoListScreen.kt | 10 +++++++++- .../features/todolist/ToDoListViewModel.kt | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index fc0fb4ac85..be658bcdd1 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2193,4 +2193,5 @@ Undo %s marked as done Undo + This action cannot be performed offline 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 30d71b5798..495491bf92 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 @@ -15,6 +15,7 @@ */ package com.instructure.pandautils.features.todolist +import android.view.HapticFeedbackConstants import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -72,6 +73,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -387,6 +389,7 @@ private fun ToDoItem( val animatedOffsetX = remember { Animatable(0f) } var itemWidth by remember { mutableFloatStateOf(0f) } val density = LocalDensity.current + val view = LocalView.current val swipeThreshold = with(density) { 150.dp.toPx() } @@ -409,6 +412,7 @@ private fun ToDoItem( targetValue = targetOffset, animationSpec = tween(durationMillis = 200) ) + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) delay(300) animateToCenter() item.onSwipeToDone() @@ -538,6 +542,7 @@ private fun ToDoItemContent( modifier: Modifier = Modifier ) { val dateBadgeData = rememberDateBadgeData(item.date) + val view = LocalView.current Row( modifier = modifier @@ -621,7 +626,10 @@ private fun ToDoItemContent( Checkbox( checked = item.isChecked, - onCheckedChange = { onCheckedChange() }, + onCheckedChange = { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + onCheckedChange() + }, colors = CheckboxDefaults.colors( checkedColor = Color(ThemePrefs.brandColor), uncheckedColor = colorResource(id = R.color.textDark) 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 3a9f76931a..fb5ef7a589 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 @@ -25,6 +25,7 @@ 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.utils.NetworkStateProvider import com.instructure.pandautils.utils.getContextNameForPlannerItem import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem @@ -42,7 +43,8 @@ import javax.inject.Inject @HiltViewModel class ToDoListViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: ToDoListRepository + private val repository: ToDoListRepository, + private val networkStateProvider: NetworkStateProvider ) : ViewModel() { private val _uiState = MutableStateFlow(ToDoListUiState( @@ -160,6 +162,13 @@ class ToDoListViewModel @Inject constructor( private fun handleSwipeToDone(itemId: String) { viewModelScope.launch { + if (!networkStateProvider.isOnline()) { + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.todoActionOffline)) + } + return@launch + } + val plannerItem = plannerItemsMap[itemId] ?: return@launch val currentIsChecked = isComplete(plannerItem) val newIsChecked = !currentIsChecked @@ -194,6 +203,13 @@ class ToDoListViewModel @Inject constructor( private fun handleCheckboxToggle(itemId: String, isChecked: Boolean) { viewModelScope.launch { + if (!networkStateProvider.isOnline()) { + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.todoActionOffline)) + } + return@launch + } + val plannerItem = plannerItemsMap[itemId] ?: return@launch val success = updateItemCompleteState(itemId, isChecked) From c568bc77354eb9f77ed4aacef57a83a09cd8a87f Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 20:09:03 +0100 Subject: [PATCH 07/18] Corrected calendar user colors to be in sync with the to do. --- .../compose/composables/SelectContextScreen.kt | 12 ++---------- .../features/calendar/composables/CalendarEvents.kt | 9 ++------- .../calendar/filter/CalendarFilterViewModel.kt | 5 ++--- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt index 502258936c..dcff8f4c1e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt @@ -48,12 +48,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme -import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import com.instructure.pandautils.utils.isCourse import com.instructure.pandautils.utils.isGroup import com.instructure.pandautils.utils.isUser @@ -188,13 +186,7 @@ private fun SelectContextItem( modifier: Modifier = Modifier ) { val context = LocalContext.current - val color = Color( - if (canvasContext is User) { - ThemePrefs.brandColor - } else { - canvasContext.color - } - ) + val color = Color(canvasContext.courseOrUserColor) Row( modifier = modifier .defaultMinSize(minHeight = 54.dp) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt index 894a8b9697..447df04416 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt @@ -65,7 +65,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.composables.ErrorContent @@ -75,7 +74,7 @@ import com.instructure.pandautils.features.calendar.CalendarEventsPageUiState import com.instructure.pandautils.features.calendar.CalendarEventsUiState import com.instructure.pandautils.features.calendar.EventUiState import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import com.jakewharton.threetenabp.AndroidThreeTen private const val PAGE_COUNT = 1000 @@ -184,11 +183,7 @@ fun CalendarEventsPage( @Composable fun CalendarEventItem(eventUiState: EventUiState, onEventClick: (Long) -> Unit, modifier: Modifier = Modifier) { - val contextColor = if (eventUiState.canvasContext is User) { - Color(ThemePrefs.brandColor) - } else { - Color(eventUiState.canvasContext.color) - } + val contextColor = Color(eventUiState.canvasContext.courseOrUserColor) Row( modifier .clickable { onEventClick(eventUiState.plannableId) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt index 292d524db2..dcc4b1e145 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt @@ -23,8 +23,7 @@ import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.R import com.instructure.pandautils.features.calendar.CalendarRepository import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity -import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +88,7 @@ class CalendarFilterViewModel @Inject constructor( } private fun createFilterItemsUiState(type: CanvasContext.Type) = canvasContexts[type]?.map { - val color = if (type == CanvasContext.Type.USER) ThemePrefs.brandColor else it.color + val color = it.courseOrUserColor CalendarFilterItemUiState(it.contextId, it.name.orEmpty(), contextIdFilters.contains(it.contextId), color) } ?: emptyList() From 112ed17974e78fe27afa8904858ccff5c8be6986 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Fri, 31 Oct 2025 09:48:34 +0100 Subject: [PATCH 08/18] Fixed badge issues. --- .../student/activity/CallbackActivity.kt | 3 +- .../student/activity/NavigationActivity.kt | 9 +++- .../todolist/StudentToDoListRouter.kt | 1 + .../student/navigation/NavigationBehavior.kt | 2 +- .../student/router/RouteMatcher.kt | 2 +- .../student/router/RouteResolver.kt | 2 +- .../features/todolist/OnToDoCountChanged.kt | 20 +++++++++ .../features/todolist/ToDoListFragment.kt | 33 ++++++-------- .../features/todolist/ToDoListRepository.kt | 5 +++ .../features/todolist/ToDoListScreen.kt | 8 ++++ .../features/todolist/ToDoListUiState.kt | 4 +- .../features/todolist/ToDoListViewModel.kt | 43 +++++++++++-------- .../pandautils/utils/PlannerItemExtensions.kt | 11 +++++ 13 files changed, 97 insertions(+), 46 deletions(-) create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt rename {apps/student/src/main/java/com/instructure/student => libs/pandautils/src/main/java/com/instructure/pandautils}/features/todolist/ToDoListFragment.kt (73%) 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 d1715f8ae9..9dcbff4e6d 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 @@ -60,6 +60,7 @@ import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.SHA256 import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.isComplete import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.toast import com.instructure.student.BuildConfig @@ -236,7 +237,7 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No plannerApi.nextPagePlannerItems(nextUrl, restParams) } - val todoCount = plannerItems.dataOrNull?.count().orDefault() + val todoCount = plannerItems.dataOrNull?.count { !it.isComplete() }.orDefault() updateToDoCount(todoCount) } diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index f39b6315ad..f7ad3fa378 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -91,6 +91,8 @@ import com.instructure.pandautils.features.notification.preferences.PushNotifica import com.instructure.pandautils.features.offline.sync.OfflineSyncHelper import com.instructure.pandautils.features.reminder.AlarmScheduler import com.instructure.pandautils.features.settings.SettingsFragment +import com.instructure.pandautils.features.todolist.OnToDoCountChanged +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.pandautils.interfaces.NavigationCallbacks import com.instructure.pandautils.models.PushNotification import com.instructure.pandautils.receivers.PushExternalReceiver @@ -136,7 +138,6 @@ import com.instructure.student.events.UserUpdatedEvent import com.instructure.student.features.files.list.FileListFragment import com.instructure.student.features.modules.progression.CourseModuleProgressionFragment import com.instructure.student.features.navigation.NavigationRepository -import com.instructure.student.features.todolist.ToDoListFragment import com.instructure.student.fragment.BookmarksFragment import com.instructure.student.fragment.DashboardFragment import com.instructure.student.fragment.NotificationListFragment @@ -174,7 +175,7 @@ private const val BOTTOM_SCREENS_BUNDLE_KEY = "bottomScreens" @AndroidEntryPoint @Suppress("DELEGATED_MEMBER_HIDES_SUPERTYPE_OVERRIDE") class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog.OnMasqueradingSet, - FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver() { + FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver(), OnToDoCountChanged { private val binding by viewBinding(ActivityNavigationBinding::inflate) private lateinit var navigationDrawerBinding: NavigationDrawerBinding @@ -1273,6 +1274,10 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCount) } + override fun onToDoCountChanged(count: Int) { + updateToDoCount(count) + } + private fun updateBottomBarBadge(@IdRes menuItemId: Int, count: Int, @PluralsRes quantityContentDescription: Int? = null) = with(binding) { if (count > 0) { bottomBar.getOrCreateBadge(menuItemId).number = count diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt index 8e1ec238a0..546b18af37 100644 --- a/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt @@ -18,6 +18,7 @@ package com.instructure.student.features.todolist import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.student.activity.NavigationActivity diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt index 4f6a29c8c8..1f5fa1b168 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt @@ -26,7 +26,7 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.activity.NothingToSeeHereFragment -import com.instructure.student.features.todolist.ToDoListFragment +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ParentFragment diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt index 12a77f8045..be5807afc7 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt @@ -82,7 +82,7 @@ import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.details.PeopleDetailsFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment -import com.instructure.student.features.todolist.ToDoListFragment +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.BasicQuizViewFragment import com.instructure.student.fragment.CourseSettingsFragment diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt index 8b665681fd..f1ffe01a5a 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt @@ -45,7 +45,7 @@ import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.details.PeopleDetailsFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment -import com.instructure.student.features.todolist.ToDoListFragment +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.student.fragment.AccountPreferencesFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.AssignmentBasicFragment diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt new file mode 100644 index 0000000000..87d20371cf --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt @@ -0,0 +1,20 @@ +/* + * 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 + +interface OnToDoCountChanged { + fun onToDoCountChanged(count: Int) +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt similarity index 73% rename from apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt rename to libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt index 3d92dbfc8f..0d291327b1 100644 --- a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt @@ -1,21 +1,6 @@ -/* - * Copyright (C) 2025 - 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.student.features.todolist +package com.instructure.pandautils.features.todolist +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -27,18 +12,16 @@ import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.interactions.FragmentInteractions import com.instructure.interactions.Navigation import com.instructure.interactions.router.Route +import com.instructure.pandautils.R import com.instructure.pandautils.analytics.SCREEN_VIEW_TO_DO_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.base.BaseCanvasFragment import com.instructure.pandautils.compose.CanvasTheme -import com.instructure.pandautils.features.todolist.ToDoListRouter -import com.instructure.pandautils.features.todolist.ToDoListScreen import com.instructure.pandautils.interfaces.NavigationCallbacks import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.withArgs -import com.instructure.student.R import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -50,6 +33,13 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC @Inject lateinit var toDoListRouter: ToDoListRouter + private var onToDoCountChanged: OnToDoCountChanged? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + onToDoCountChanged = context as? OnToDoCountChanged + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -62,7 +52,8 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC CanvasTheme { ToDoListScreen( navigationIconClick = { toDoListRouter.openNavigationDrawer() }, - openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) } + openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) }, + onToDoCountChanged = { count -> onToDoCountChanged?.onToDoCountChanged(count) } ) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt index 3ef9e3904e..a64ee2b5fd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt @@ -15,6 +15,7 @@ */ package com.instructure.pandautils.features.todolist +import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.apis.PlannerAPI import com.instructure.canvasapi2.builders.RestParams @@ -78,4 +79,8 @@ class ToDoListRepository @Inject constructor( ) return plannerApi.createPlannerOverride(override, restParams) } + + fun invalidateCachedResponses() { + CanvasRestAdapter.clearCacheUrls("planner") + } } \ No newline at end of file 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 495491bf92..c84a28dfb5 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 @@ -125,6 +125,7 @@ private data class DateBadgeData( fun ToDoListScreen( navigationIconClick: () -> Unit, openToDoItem: (String) -> Unit, + onToDoCountChanged: (Int) -> Unit, modifier: Modifier = Modifier ) { val viewModel = hiltViewModel() @@ -162,6 +163,13 @@ fun ToDoListScreen( } } + LaunchedEffect(uiState.toDoCount) { + uiState.toDoCount?.let { count -> + onToDoCountChanged(count) + uiState.onToDoCountChanged() + } + } + val pullRefreshState = rememberPullRefreshState( refreshing = uiState.isRefreshing, onRefresh = uiState.onRefresh 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 50c92796d0..661804187e 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 @@ -32,7 +32,9 @@ data class ToDoListUiState( val onUndoMarkAsDone: () -> Unit = {}, val onMarkedAsDoneSnackbarDismissed: () -> Unit = {}, val onItemClicked: (String) -> Unit = {}, - val onRefresh: () -> Unit = {} + val onRefresh: () -> Unit = {}, + val toDoCount: Int? = null, + val onToDoCountChanged: () -> Unit = {} ) data class MarkedAsDoneItem( 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 fb5ef7a589..4ca96cd0e5 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 @@ -30,6 +30,7 @@ import com.instructure.pandautils.utils.getContextNameForPlannerItem import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem import com.instructure.pandautils.utils.getTagForPlannerItem +import com.instructure.pandautils.utils.isComplete import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.threeten.bp.LocalDate +import java.util.Date import javax.inject.Inject @HiltViewModel @@ -53,7 +55,8 @@ class ToDoListViewModel @Inject constructor( onUndoMarkAsDone = { handleUndoMarkAsDone() }, onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, onItemClicked = { itemId -> handleItemClicked(itemId) }, - onRefresh = { handleRefresh() } + onRefresh = { handleRefresh() }, + onToDoCountChanged = { clearToDoCount() } )) val uiState = _uiState.asStateFlow() @@ -70,7 +73,7 @@ class ToDoListViewModel @Inject constructor( val now = LocalDate.now().atStartOfDay() val startDate = now.minusDays(7).toApiString().orEmpty() - val endDate = now.plusDays(28).toApiString().orEmpty() // TODO revert + val endDate = now.plusDays(7).toApiString().orEmpty() val courses = repository.getCourses(forceRefresh).dataOrThrow val plannerItems = repository.getPlannerItems(startDate, endDate, forceRefresh).dataOrThrow @@ -99,12 +102,15 @@ class ToDoListViewModel @Inject constructor( } } + val toDoCount = calculateToDoCount(itemsByDate) + _uiState.update { it.copy( isLoading = false, isRefreshing = false, isError = false, - itemsByDate = itemsByDate + itemsByDate = itemsByDate, + toDoCount = toDoCount ) } } catch (e: Exception) { @@ -141,7 +147,7 @@ class ToDoListViewModel @Inject constructor( contextLabel = plannerItem.getContextNameForPlannerItem(context, courseMap.values), canvasContext = plannerItem.canvasContext, itemType = itemType, - isChecked = isComplete(plannerItem), + isChecked = plannerItem.isComplete(), iconRes = plannerItem.getIconForPlannerItem(), tag = plannerItem.getTagForPlannerItem(context), onSwipeToDone = { handleSwipeToDone(itemId) }, @@ -149,17 +155,6 @@ class ToDoListViewModel @Inject constructor( ) } - private fun isComplete(plannerItem: PlannerItem): Boolean { - return plannerItem.plannerOverride?.markedComplete ?: if (plannerItem.plannableType == PlannableType.ASSIGNMENT - || plannerItem.plannableType == PlannableType.DISCUSSION_TOPIC - || plannerItem.plannableType == PlannableType.SUB_ASSIGNMENT - ) { - plannerItem.submissionState?.submitted == true - } else { - false - } - } - private fun handleSwipeToDone(itemId: String) { viewModelScope.launch { if (!networkStateProvider.isOnline()) { @@ -170,7 +165,7 @@ class ToDoListViewModel @Inject constructor( } val plannerItem = plannerItemsMap[itemId] ?: return@launch - val currentIsChecked = isComplete(plannerItem) + val currentIsChecked = plannerItem.isComplete() val newIsChecked = !currentIsChecked val success = updateItemCompleteState(itemId, newIsChecked) @@ -230,7 +225,7 @@ class ToDoListViewModel @Inject constructor( private suspend fun updateItemCompleteState(itemId: String, newIsChecked: Boolean): Boolean { val plannerItem = plannerItemsMap[itemId] ?: return false - val currentIsChecked = isComplete(plannerItem) + val currentIsChecked = plannerItem.isComplete() // Optimistically update UI updateItemCheckedState(itemId, newIsChecked) @@ -254,6 +249,9 @@ class ToDoListViewModel @Inject constructor( val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult) plannerItemsMap[itemId] = updatedPlannerItem + // Invalidate planner cache + repository.invalidateCachedResponses() + true } catch (e: Exception) { e.printStackTrace() @@ -278,10 +276,15 @@ class ToDoListViewModel @Inject constructor( } } } - state.copy(itemsByDate = updatedItemsByDate) + val toDoCount = calculateToDoCount(updatedItemsByDate) + state.copy(itemsByDate = updatedItemsByDate, toDoCount = toDoCount) } } + private fun calculateToDoCount(itemsByDate: Map>): Int { + return itemsByDate.values.flatten().count { !it.isChecked } + } + private fun handleItemClicked(itemId: String) { _uiState.update { it.copy(openToDoItemId = itemId) } } @@ -301,4 +304,8 @@ class ToDoListViewModel @Inject constructor( private fun clearMarkedAsDoneItem() { _uiState.update { it.copy(markedAsDoneItem = null) } } + + private fun clearToDoCount() { + _uiState.update { it.copy(toDoCount = null) } + } } 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 3f5c7540d4..b9d132142e 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 @@ -109,3 +109,14 @@ fun PlannerItem.getTagForPlannerItem(context: Context): String? { null } } + +fun PlannerItem.isComplete(): Boolean { + return plannerOverride?.markedComplete ?: if (plannableType == PlannableType.ASSIGNMENT + || plannableType == PlannableType.DISCUSSION_TOPIC + || plannableType == PlannableType.SUB_ASSIGNMENT + ) { + submissionState?.submitted == true + } else { + false + } +} From 181cf80449fe6b6567f152344aa163c8903529df Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Fri, 31 Oct 2025 10:25:19 +0100 Subject: [PATCH 09/18] Tests. --- .../todolist/ToDoListViewModelTest.kt | 351 +++++++++++++++++- 1 file changed, 348 insertions(+), 3 deletions(-) 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 37fdd6c3ef..eebe64153d 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 @@ -20,17 +20,20 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Plannable import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride import com.instructure.canvasapi2.models.SubmissionState import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.R +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll +import io.mockk.verify 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 @@ -49,6 +52,7 @@ class ToDoListViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private val context: Context = mockk(relaxed = true) private val repository: ToDoListRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) @Before fun setUp() { @@ -370,9 +374,350 @@ class ToDoListViewModelTest { assertTrue(dates.size == 2) } + // Todo count tests + @Test + fun `ViewModel calculates todo count correctly on initial load`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false), + createPlannerItem(id = 2L, title = "Checked", submitted = true), + createPlannerItem(id = 3L, title = "Unchecked 2", submitted = false) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(2, uiState.toDoCount) + } + + @Test + fun `ViewModel emits zero todo count when all items are checked`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Checked 1", submitted = true), + createPlannerItem(id = 2L, title = "Checked 2", submitted = true) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(0, uiState.toDoCount) + } + + @Test + fun `ViewModel emits todo count when all items are unchecked`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false), + createPlannerItem(id = 2L, title = "Unchecked 2", submitted = false), + createPlannerItem(id = 3L, title = "Unchecked 3", submitted = false) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(3, uiState.toDoCount) + } + + // Checkbox toggle tests + @Test + fun `Checkbox toggle successfully marks item as done`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + 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) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Assignment", uiState.markedAsDoneItem?.title) + coVerify { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } + } + + @Test + fun `Checkbox toggle successfully marks item as undone`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals(null, uiState.markedAsDoneItem) + coVerify { repository.updatePlannerOverride(100L, false) } + } + + @Test + fun `Checkbox toggle shows offline snackbar when device is offline`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + every { networkStateProvider.isOnline() } returns false + every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("This action cannot be performed offline", uiState.snackbarMessage) + } + + @Test + fun `Checkbox toggle reverts on failure`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = 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.Fail() + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Error updating to-do", uiState.snackbarMessage) + } + + // Swipe to done tests + @Test + fun `Swipe to done successfully marks item as done when unchecked`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + 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) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Assignment", uiState.markedAsDoneItem?.title) + } + + @Test + fun `Swipe to done successfully marks item as undone when checked`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals(null, uiState.markedAsDoneItem) + } + + @Test + fun `Swipe to done shows offline snackbar when device is offline`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + every { networkStateProvider.isOnline() } returns false + every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("This action cannot be performed offline", uiState.snackbarMessage) + } + + // Cache invalidation tests + @Test + fun `Cache is invalidated after successfully creating planner override`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + 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) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify { repository.invalidateCachedResponses() } + } + + @Test + fun `Cache is invalidated after successfully updating planner override`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + verify { repository.invalidateCachedResponses() } + } + + @Test + fun `Cache is not invalidated when planner override update fails`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = 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.Fail() + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify(exactly = 0) { repository.invalidateCachedResponses() } + } + + // Undo tests + @Test + fun `Undo mark as done successfully reverts item to unchecked`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val revertedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } returns DataResult.Success(plannerOverride) + coEvery { repository.updatePlannerOverride(100L, false) } returns DataResult.Success(revertedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // First mark as done + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Verify marked as done + assertTrue(viewModel.uiState.value.itemsByDate.values.flatten().first().isChecked) + + // Now undo + viewModel.uiState.value.onUndoMarkAsDone() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals(null, uiState.markedAsDoneItem) + } + + @Test + fun `Todo count updates when item is marked as done`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + 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) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + assertEquals(1, viewModel.uiState.value.toDoCount) + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + assertEquals(0, viewModel.uiState.value.toDoCount) + } + + @Test + fun `Todo count updates when item is marked as undone`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + assertEquals(0, viewModel.uiState.value.toDoCount) + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + assertEquals(1, viewModel.uiState.value.toDoCount) + } + // Helper functions private fun getViewModel(): ToDoListViewModel { - return ToDoListViewModel(context, repository) + return ToDoListViewModel(context, repository, networkStateProvider) } private fun createPlannerItem( From 7c28fea410169d7f03253ab7c20e09227961a2d3 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Fri, 31 Oct 2025 10:43:56 +0100 Subject: [PATCH 10/18] Fixed remote config params for devDebugMinify build. --- .../student/features/settings/StudentSettingsBehaviour.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt index 3ef8fbc4d9..f735e4312f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt +++ b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt @@ -45,7 +45,7 @@ class StudentSettingsBehaviour( if (apiPrefs.canvasForElementary) { preferencesList.add(1, SettingsItem.HOMEROOM_VIEW) } - if (BuildConfig.DEBUG) { + if (BuildConfig.IS_DEBUG) { preferencesList.add(SettingsItem.ACCOUNT_PREFERENCES) preferencesList.add(SettingsItem.FEATURE_FLAGS) preferencesList.add(SettingsItem.REMOTE_CONFIG) From 0454f43e09cb92b1a6e74c86b435cf53181c250e Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Fri, 31 Oct 2025 11:24:43 +0100 Subject: [PATCH 11/18] Fade in/out text. --- .../features/todolist/ToDoListScreen.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 c84a28dfb5..43237070e0 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 @@ -67,6 +67,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -106,6 +107,8 @@ import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt +private const val SWIPE_THRESHOLD_DP = 150 + private data class StickyHeaderState( val item: ToDoItemUiState?, val yOffset: Float, @@ -399,7 +402,7 @@ private fun ToDoItem( val density = LocalDensity.current val view = LocalView.current - val swipeThreshold = with(density) { 150.dp.toPx() } + val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() } fun animateToCenter() { coroutineScope.launch { @@ -485,6 +488,11 @@ private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { R.drawable.ic_checkmark_lined } + // Calculate alpha based on swipe progress + val density = LocalDensity.current + val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() } + val alpha = (abs(offsetX) / swipeThreshold).coerceIn(0f, 1f) + Box( modifier = Modifier .matchParentSize() @@ -494,7 +502,8 @@ private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { Row( modifier = Modifier .align(Alignment.CenterStart) - .padding(start = 16.dp), + .padding(start = 16.dp) + .alpha(alpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { @@ -518,7 +527,8 @@ private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { Row( modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 16.dp), + .padding(end = 16.dp) + .alpha(alpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { From 640de11c40e00570c28dfc4489ffab48e259f780 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 3 Nov 2025 08:08:31 +0100 Subject: [PATCH 12/18] Improve haptic feedback and fade-in animation for swipe gestures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply ease-in cubic easing to swipe indicator fade-in for more natural animation - Add GESTURE_START haptic feedback when user begins dragging - Add GESTURE_END haptic feedback when swipe animation completes - Keep TOGGLE_ON/OFF for checkbox interactions - All haptics fall back to CONTEXT_CLICK on API < 34 - Provides better tactile feedback flow: start → drag → end --- .../features/todolist/ToDoListScreen.kt | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) 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 43237070e0..0cc22e3cbe 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 @@ -15,7 +15,9 @@ */ package com.instructure.pandautils.features.todolist +import android.os.Build import android.view.HapticFeedbackConstants +import android.view.View import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -109,6 +111,32 @@ import kotlin.math.roundToInt private const val SWIPE_THRESHOLD_DP = 150 +/** + * Performs haptic feedback with appropriate constants based on API level. + * Uses TOGGLE_ON/TOGGLE_OFF on API 34+ for marking done/undone, falls back to CONTEXT_CLICK on older versions. + */ +private fun View.performToggleHapticFeedback(isMarkingAsDone: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (isMarkingAsDone) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} + +/** + * Performs haptic feedback for gesture start/end with appropriate constants based on API level. + * Uses GESTURE_START/GESTURE_END on API 34+, falls back to CONTEXT_CLICK on older versions. + */ +private fun View.performGestureHapticFeedback(isStart: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (isStart) HapticFeedbackConstants.GESTURE_START else HapticFeedbackConstants.GESTURE_END + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} + private data class StickyHeaderState( val item: ToDoItemUiState?, val yOffset: Float, @@ -423,7 +451,9 @@ private fun ToDoItem( targetValue = targetOffset, animationSpec = tween(durationMillis = 200) ) - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + + // Gesture end haptic feedback + view.performGestureHapticFeedback(isStart = false) delay(300) animateToCenter() item.onSwipeToDone() @@ -441,8 +471,14 @@ private fun ToDoItem( } .pointerInput(Unit) { detectHorizontalDragGestures( + onDragStart = { + // Gesture start haptic feedback when user begins dragging + view.performGestureHapticFeedback(isStart = true) + }, onDragEnd = { handleSwipeEnd() }, - onDragCancel = { animateToCenter() }, + onDragCancel = { + animateToCenter() + }, onHorizontalDrag = { _, dragAmount -> coroutineScope.launch { val newOffset = (animatedOffsetX.value + dragAmount).coerceIn(-itemWidth, itemWidth) @@ -488,10 +524,12 @@ private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { R.drawable.ic_checkmark_lined } - // Calculate alpha based on swipe progress + // Calculate alpha based on swipe progress with ease-in curve val density = LocalDensity.current val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() } - val alpha = (abs(offsetX) / swipeThreshold).coerceIn(0f, 1f) + val progress = (abs(offsetX) / swipeThreshold).coerceIn(0f, 1f) + // Apply ease-in cubic easing for gradual fade-in that accelerates near threshold + val alpha = progress * progress * progress Box( modifier = Modifier @@ -645,7 +683,8 @@ private fun ToDoItemContent( Checkbox( checked = item.isChecked, onCheckedChange = { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + // Determine if marking as done or undone based on the new checked state + view.performToggleHapticFeedback(it) onCheckedChange() }, colors = CheckboxDefaults.colors( From 055f24120930019a5f559a574a1b46e59580aa6e Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 3 Nov 2025 08:26:17 +0100 Subject: [PATCH 13/18] Improved error logging. --- .../pandautils/features/todolist/ToDoListViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 4ca96cd0e5..b33c684b5a 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 @@ -18,6 +18,7 @@ package com.instructure.pandautils.features.todolist import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem @@ -46,7 +47,8 @@ import javax.inject.Inject class ToDoListViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: ToDoListRepository, - private val networkStateProvider: NetworkStateProvider + private val networkStateProvider: NetworkStateProvider, + private val firebaseCrashlytics: FirebaseCrashlytics ) : ViewModel() { private val _uiState = MutableStateFlow(ToDoListUiState( @@ -115,6 +117,7 @@ class ToDoListViewModel @Inject constructor( } } catch (e: Exception) { e.printStackTrace() + firebaseCrashlytics.recordException(e) _uiState.update { it.copy( isLoading = false, @@ -255,6 +258,7 @@ class ToDoListViewModel @Inject constructor( true } catch (e: Exception) { e.printStackTrace() + firebaseCrashlytics.recordException(e) // Revert the optimistic update updateItemCheckedState(itemId, currentIsChecked) // Show error snackbar From f7c67b64012d450ccc3f2bf608c2b9de042b470f Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 3 Nov 2025 09:20:37 +0100 Subject: [PATCH 14/18] Add DefaultToDoListRouter for Teacher and Parent apps - Created DefaultToDoListRouter with no-op implementations - Added ToDoListRouter provider to Teacher ToDoModule - Added ToDoListRouter provider to Parent ToDoModule - Fixes build issues for Teacher and Parent apps since ToDoListFragment is in common code --- .../parentapp/di/feature/ToDoModule.kt | 7 ++++ .../com/instructure/teacher/di/ToDoModule.kt | 7 ++++ .../todolist/DefaultToDoListRouter.kt | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt index e665f3eaeb..ac1bfddfa6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt @@ -20,6 +20,8 @@ package com.instructure.parentapp.di.feature import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior import com.instructure.pandautils.features.calendartodo.details.ToDoRouter +import com.instructure.pandautils.features.todolist.DefaultToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.parentapp.features.calendartodo.ParentToDoRouter import com.instructure.parentapp.util.navigation.Navigation import dagger.Module @@ -36,6 +38,11 @@ class ToDoModule { fun provideToDoRouter(activity: FragmentActivity, navigation: Navigation): ToDoRouter { return ParentToDoRouter(activity, navigation) } + + @Provides + fun provideToDoListRouter(): ToDoListRouter { + return DefaultToDoListRouter() + } } @Module diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt index 49984b9398..19ae28e1a9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt @@ -20,6 +20,8 @@ package com.instructure.teacher.di import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior import com.instructure.pandautils.features.calendartodo.details.ToDoRouter +import com.instructure.pandautils.features.todolist.DefaultToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.teacher.features.calendartodo.TeacherToDoRouter import dagger.Module import dagger.Provides @@ -35,6 +37,11 @@ class ToDoModule { fun provideToDoRouter(activity: FragmentActivity): ToDoRouter { return TeacherToDoRouter(activity) } + + @Provides + fun provideToDoListRouter(): ToDoListRouter { + return DefaultToDoListRouter() + } } @Module diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt new file mode 100644 index 0000000000..01e8550b48 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt @@ -0,0 +1,35 @@ +/* + * 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 + +/** + * Default implementation of ToDoListRouter with no-op implementations. + * Used when the app doesn't need custom routing behavior. + */ +class DefaultToDoListRouter : ToDoListRouter { + + override fun openNavigationDrawer() { + // No-op implementation + } + + override fun attachNavigationDrawer() { + // No-op implementation + } + + override fun openToDoItem(itemId: String) { + // No-op implementation + } +} \ No newline at end of file From 1a01c7cedfe04b7fbeb7f52681f7e8aad8ae7527 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Mon, 3 Nov 2025 15:23:23 +0100 Subject: [PATCH 15/18] fixed tests --- .../pandautils/features/todolist/ToDoListViewModelTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 eebe64153d..787ba5f974 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 @@ -16,6 +16,7 @@ package com.instructure.pandautils.features.todolist import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Plannable import com.instructure.canvasapi2.models.PlannableType @@ -53,6 +54,7 @@ class ToDoListViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: ToDoListRepository = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val firebaseCrashlytics: FirebaseCrashlytics = mockk(relaxed = true) @Before fun setUp() { @@ -717,7 +719,7 @@ class ToDoListViewModelTest { // Helper functions private fun getViewModel(): ToDoListViewModel { - return ToDoListViewModel(context, repository, networkStateProvider) + return ToDoListViewModel(context, repository, networkStateProvider, firebaseCrashlytics) } private fun createPlannerItem( From 486dda403771383b60932ec7d9cf3d219a461bfb Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 4 Nov 2025 16:48:15 +0100 Subject: [PATCH 16/18] CR fixes. --- .../features/todolist/ToDoListScreen.kt | 54 +++++-------------- .../features/todolist/ToDoListUiState.kt | 6 +-- .../features/todolist/ToDoListViewModel.kt | 17 +----- .../pandautils/utils/ViewExtensions.kt | 28 ++++++++++ .../todolist/ToDoListViewModelTest.kt | 13 ----- 5 files changed, 42 insertions(+), 76 deletions(-) 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 0cc22e3cbe..a80da249b3 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 @@ -15,9 +15,6 @@ */ package com.instructure.pandautils.features.todolist -import android.os.Build -import android.view.HapticFeedbackConstants -import android.view.View import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -100,6 +97,8 @@ import com.instructure.pandautils.compose.composables.Loading import com.instructure.pandautils.compose.modifiers.conditional import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.courseOrUserColor +import com.instructure.pandautils.utils.performGestureHapticFeedback +import com.instructure.pandautils.utils.performToggleHapticFeedback import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.text.SimpleDateFormat @@ -111,32 +110,6 @@ import kotlin.math.roundToInt private const val SWIPE_THRESHOLD_DP = 150 -/** - * Performs haptic feedback with appropriate constants based on API level. - * Uses TOGGLE_ON/TOGGLE_OFF on API 34+ for marking done/undone, falls back to CONTEXT_CLICK on older versions. - */ -private fun View.performToggleHapticFeedback(isMarkingAsDone: Boolean) { - val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - if (isMarkingAsDone) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF - } else { - HapticFeedbackConstants.CONTEXT_CLICK - } - performHapticFeedback(hapticConstant) -} - -/** - * Performs haptic feedback for gesture start/end with appropriate constants based on API level. - * Uses GESTURE_START/GESTURE_END on API 34+, falls back to CONTEXT_CLICK on older versions. - */ -private fun View.performGestureHapticFeedback(isStart: Boolean) { - val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - if (isStart) HapticFeedbackConstants.GESTURE_START else HapticFeedbackConstants.GESTURE_END - } else { - HapticFeedbackConstants.CONTEXT_CLICK - } - performHapticFeedback(hapticConstant) -} - private data class StickyHeaderState( val item: ToDoItemUiState?, val yOffset: Float, @@ -164,13 +137,6 @@ fun ToDoListScreen( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current - LaunchedEffect(uiState.openToDoItemId) { - uiState.openToDoItemId?.let { itemId -> - openToDoItem(itemId) - uiState.onOpenToDoItem() - } - } - LaunchedEffect(uiState.snackbarMessage) { uiState.snackbarMessage?.let { message -> snackbarHostState.showSnackbar(message) @@ -197,7 +163,6 @@ fun ToDoListScreen( LaunchedEffect(uiState.toDoCount) { uiState.toDoCount?.let { count -> onToDoCountChanged(count) - uiState.onToDoCountChanged() } } @@ -241,7 +206,8 @@ fun ToDoListScreen( .pullRefresh(pullRefreshState) ) { ToDoListContent( - uiState = uiState + uiState = uiState, + onOpenToDoItem = openToDoItem ) PullRefreshIndicator( @@ -258,6 +224,7 @@ fun ToDoListScreen( @Composable private fun ToDoListContent( uiState: ToDoListUiState, + onOpenToDoItem: (String) -> Unit, modifier: Modifier = Modifier ) { when { @@ -285,7 +252,7 @@ private fun ToDoListContent( else -> { ToDoItemsList( itemsByDate = uiState.itemsByDate, - onItemClicked = uiState.onItemClicked, + onItemClicked = onOpenToDoItem, modifier = modifier ) } @@ -986,7 +953,8 @@ fun ToDoListScreenPreview() { ) ) ) - ) + ), + onOpenToDoItem = {} ) } } @@ -1026,7 +994,8 @@ fun ToDoListScreenWithPandasPreview() { ) ) ) - ) + ), + onOpenToDoItem = {} ) } } @@ -1038,7 +1007,8 @@ fun ToDoListScreenEmptyPreview() { ContextKeeper.appContext = LocalContext.current CanvasTheme { ToDoListContent( - uiState = ToDoListUiState() + uiState = ToDoListUiState(), + onOpenToDoItem = {} ) } } 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 661804187e..c894ab9b61 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 @@ -24,17 +24,13 @@ data class ToDoListUiState( val isError: Boolean = false, val isRefreshing: Boolean = false, val itemsByDate: Map> = emptyMap(), - val openToDoItemId: String? = null, - val onOpenToDoItem: () -> Unit = {}, val snackbarMessage: String? = null, val onSnackbarDismissed: () -> Unit = {}, val markedAsDoneItem: MarkedAsDoneItem? = null, val onUndoMarkAsDone: () -> Unit = {}, val onMarkedAsDoneSnackbarDismissed: () -> Unit = {}, - val onItemClicked: (String) -> Unit = {}, val onRefresh: () -> Unit = {}, - val toDoCount: Int? = null, - val onToDoCountChanged: () -> Unit = {} + val toDoCount: Int? = null ) data class MarkedAsDoneItem( 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 b33c684b5a..9116c87850 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 @@ -52,13 +52,10 @@ class ToDoListViewModel @Inject constructor( ) : ViewModel() { private val _uiState = MutableStateFlow(ToDoListUiState( - onOpenToDoItem = { clearOpenToDoItem() }, onSnackbarDismissed = { clearSnackbarMessage() }, onUndoMarkAsDone = { handleUndoMarkAsDone() }, onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, - onItemClicked = { itemId -> handleItemClicked(itemId) }, - onRefresh = { handleRefresh() }, - onToDoCountChanged = { clearToDoCount() } + onRefresh = { handleRefresh() } )) val uiState = _uiState.asStateFlow() @@ -289,18 +286,10 @@ class ToDoListViewModel @Inject constructor( return itemsByDate.values.flatten().count { !it.isChecked } } - private fun handleItemClicked(itemId: String) { - _uiState.update { it.copy(openToDoItemId = itemId) } - } - private fun handleRefresh() { loadData(forceRefresh = true) } - private fun clearOpenToDoItem() { - _uiState.update { it.copy(openToDoItemId = null) } - } - private fun clearSnackbarMessage() { _uiState.update { it.copy(snackbarMessage = null) } } @@ -308,8 +297,4 @@ class ToDoListViewModel @Inject constructor( private fun clearMarkedAsDoneItem() { _uiState.update { it.copy(markedAsDoneItem = null) } } - - private fun clearToDoCount() { - _uiState.update { it.copy(toDoCount = null) } - } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt index c764887a1e..a651bad475 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt @@ -35,10 +35,12 @@ import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.util.TypedValue +import android.view.HapticFeedbackConstants import android.view.Menu import android.view.MenuItem import android.view.TouchDelegate @@ -990,3 +992,29 @@ fun View.showSnackbar( snackbar.show() snackbar.view.requestAccessibilityFocus(1000) } + +/** + * Performs haptic feedback with appropriate constants based on API level. + * Uses TOGGLE_ON/TOGGLE_OFF on API 34+ for marking done/undone, falls back to CONTEXT_CLICK on older versions. + */ +fun View.performToggleHapticFeedback(toggleOn: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (toggleOn) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} + +/** + * Performs haptic feedback for gesture start/end with appropriate constants based on API level. + * Uses GESTURE_START/GESTURE_END on API 34+, falls back to CONTEXT_CLICK on older versions. + */ +fun View.performGestureHapticFeedback(isStart: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (isStart) HapticFeedbackConstants.GESTURE_START else HapticFeedbackConstants.GESTURE_END + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} \ No newline at end of file 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 787ba5f974..b5e7b485f6 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 @@ -297,19 +297,6 @@ class ToDoListViewModelTest { } // Callback tests - @Test - fun `onItemClicked callback updates openToDoItemId in UiState`() = runTest { - coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) - coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) - - val viewModel = getViewModel() - - viewModel.uiState.value.onItemClicked("123") - - val uiState = viewModel.uiState.value - assertEquals("123", uiState.openToDoItemId) - } - @Test fun `onRefresh callback triggers data reload with forceRefresh`() = runTest { val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) From 6fb245a0867f9117269ff086ab610c80f489ae3d Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 4 Nov 2025 17:20:12 +0100 Subject: [PATCH 17/18] Show snackbar for marking as undone as well. --- libs/pandares/src/main/res/values/strings.xml | 1 + .../features/todolist/ToDoListScreen.kt | 13 +++++-- .../features/todolist/ToDoListUiState.kt | 9 +++-- .../features/todolist/ToDoListViewModel.kt | 37 ++++++++++--------- .../todolist/ToDoListViewModelTest.kt | 12 +++--- 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index be658bcdd1..20259688d5 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2192,6 +2192,7 @@ Done Undo %s marked as done + %s marked as not done Undo This action cannot be performed offline 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 a80da249b3..b2d54c9798 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 @@ -144,16 +144,21 @@ fun ToDoListScreen( } } - LaunchedEffect(uiState.markedAsDoneItem) { - uiState.markedAsDoneItem?.let { item -> - val message = context.getString(R.string.todoMarkedAsDone, item.title) + LaunchedEffect(uiState.confirmationSnackbarData) { + uiState.confirmationSnackbarData?.let { item -> + val messageRes = if (item.markedAsDone) { + R.string.todoMarkedAsDone + } else { + R.string.todoMarkedAsNotDone + } + val message = context.getString(messageRes, item.title) val result = snackbarHostState.showSnackbar( message = message, actionLabel = context.getString(R.string.todoMarkedAsDoneSnackbarUndo), duration = SnackbarDuration.Long ) if (result == SnackbarResult.ActionPerformed) { - uiState.onUndoMarkAsDone() + uiState.onUndoMarkAsDoneUndoneAction() } else { uiState.onMarkedAsDoneSnackbarDismissed() } 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 c894ab9b61..4b10f3855a 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 @@ -26,16 +26,17 @@ data class ToDoListUiState( val itemsByDate: Map> = emptyMap(), val snackbarMessage: String? = null, val onSnackbarDismissed: () -> Unit = {}, - val markedAsDoneItem: MarkedAsDoneItem? = null, - val onUndoMarkAsDone: () -> Unit = {}, + val confirmationSnackbarData: ConfirmationSnackbarData? = null, + val onUndoMarkAsDoneUndoneAction: () -> Unit = {}, val onMarkedAsDoneSnackbarDismissed: () -> Unit = {}, val onRefresh: () -> Unit = {}, val toDoCount: Int? = null ) -data class MarkedAsDoneItem( +data class ConfirmationSnackbarData( val itemId: String, - val title: String + val title: String, + val markedAsDone: Boolean ) data class ToDoItemUiState( 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 9116c87850..bbd40ad710 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 @@ -51,12 +51,13 @@ class ToDoListViewModel @Inject constructor( private val firebaseCrashlytics: FirebaseCrashlytics ) : ViewModel() { - private val _uiState = MutableStateFlow(ToDoListUiState( - onSnackbarDismissed = { clearSnackbarMessage() }, - onUndoMarkAsDone = { handleUndoMarkAsDone() }, - onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, - onRefresh = { handleRefresh() } - )) + private val _uiState = MutableStateFlow( + ToDoListUiState( + onSnackbarDismissed = { clearSnackbarMessage() }, + onUndoMarkAsDoneUndoneAction = { handleUndoMarkAsDoneUndone() }, + onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, + onRefresh = { handleRefresh() } + )) val uiState = _uiState.asStateFlow() private val plannerItemsMap = mutableMapOf() @@ -171,12 +172,13 @@ class ToDoListViewModel @Inject constructor( val success = updateItemCompleteState(itemId, newIsChecked) // Show marked-as-done snackbar only when marking as done (not when undoing) - if (success && newIsChecked) { + if (success) { _uiState.update { it.copy( - markedAsDoneItem = MarkedAsDoneItem( + confirmationSnackbarData = ConfirmationSnackbarData( itemId = itemId, - title = plannerItem.plannable.title + title = plannerItem.plannable.title, + markedAsDone = newIsChecked ) ) } @@ -184,15 +186,15 @@ class ToDoListViewModel @Inject constructor( } } - private fun handleUndoMarkAsDone() { + private fun handleUndoMarkAsDoneUndone() { viewModelScope.launch { - val markedAsDoneItem = _uiState.value.markedAsDoneItem ?: return@launch + val markedAsDoneItem = _uiState.value.confirmationSnackbarData ?: return@launch val itemId = markedAsDoneItem.itemId // Clear the snackbar immediately - _uiState.update { it.copy(markedAsDoneItem = null) } + _uiState.update { it.copy(confirmationSnackbarData = null) } - updateItemCompleteState(itemId, false) + updateItemCompleteState(itemId, !markedAsDoneItem.markedAsDone) } } @@ -210,12 +212,13 @@ class ToDoListViewModel @Inject constructor( val success = updateItemCompleteState(itemId, isChecked) // Show marked-as-done snackbar only when checking the box - if (success && isChecked) { + if (success) { _uiState.update { it.copy( - markedAsDoneItem = MarkedAsDoneItem( + confirmationSnackbarData = ConfirmationSnackbarData( itemId = itemId, - title = plannerItem.plannable.title + title = plannerItem.plannable.title, + markedAsDone = isChecked ) ) } @@ -295,6 +298,6 @@ class ToDoListViewModel @Inject constructor( } private fun clearMarkedAsDoneItem() { - _uiState.update { it.copy(markedAsDoneItem = null) } + _uiState.update { it.copy(confirmationSnackbarData = null) } } } 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 b5e7b485f6..f9753e7abe 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 @@ -436,7 +436,8 @@ class ToDoListViewModelTest { val uiState = viewModel.uiState.value assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) - assertEquals("Assignment", uiState.markedAsDoneItem?.title) + assertEquals("Assignment", uiState.confirmationSnackbarData?.title) + assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true) coVerify { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } } @@ -461,7 +462,6 @@ class ToDoListViewModelTest { val uiState = viewModel.uiState.value assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) - assertEquals(null, uiState.markedAsDoneItem) coVerify { repository.updatePlannerOverride(100L, false) } } @@ -525,7 +525,8 @@ class ToDoListViewModelTest { val uiState = viewModel.uiState.value assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) - assertEquals("Assignment", uiState.markedAsDoneItem?.title) + assertEquals("Assignment", uiState.confirmationSnackbarData?.title) + assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true) } @Test @@ -549,7 +550,6 @@ class ToDoListViewModelTest { val uiState = viewModel.uiState.value assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) - assertEquals(null, uiState.markedAsDoneItem) } @Test @@ -653,12 +653,12 @@ class ToDoListViewModelTest { assertTrue(viewModel.uiState.value.itemsByDate.values.flatten().first().isChecked) // Now undo - viewModel.uiState.value.onUndoMarkAsDone() + viewModel.uiState.value.onUndoMarkAsDoneUndoneAction() val uiState = viewModel.uiState.value assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) - assertEquals(null, uiState.markedAsDoneItem) + assertEquals(null, uiState.confirmationSnackbarData) } @Test From a6f5ae0b1fd8c21e84e364e6a1782cb42cbe8707 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 6 Nov 2025 09:54:45 +0100 Subject: [PATCH 18/18] Test fixes. --- .../instructure/student/ui/interaction/LoginInteractionTest.kt | 2 +- .../canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt | 1 - .../canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt index 4720757b45..beafbbc822 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt @@ -38,7 +38,7 @@ class LoginInteractionTest : StudentTest() { if(isTabletDevice()) loginFindSchoolPage.assertHintText(R.string.schoolInstructureCom) else loginFindSchoolPage.assertHintText(R.string.loginHint) - loginFindSchoolPage.enterDomain("harv") + loginFindSchoolPage.enterDomain("harvest") loginFindSchoolPage.assertSchoolSearchResults("City Harvest Church (Singapore)") } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt index ef196dfbc5..5a2194d4c6 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt @@ -18,7 +18,6 @@ package com.instructure.canvas.espresso.mockcanvas.endpoints import android.util.Log import com.google.gson.Gson -import com.instructure.canvas.espresso.mockCanvas.endpoints.CareerEndpoint import com.instructure.canvas.espresso.mockcanvas.Endpoint import com.instructure.canvas.espresso.mockcanvas.addDiscussionTopicToCourse import com.instructure.canvas.espresso.mockcanvas.addPlannable diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt index 28f6dd32a1..acfcfc0e4d 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt @@ -14,7 +14,7 @@ * along with this program. If not, see . * */ -package com.instructure.canvas.espresso.mockCanvas.endpoints +package com.instructure.canvas.espresso.mockcanvas.endpoints import com.instructure.canvas.espresso.mockcanvas.Endpoint import com.instructure.canvas.espresso.mockcanvas.utils.Segment