diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml
index 20259688d5..268bad9d08 100644
--- a/libs/pandares/src/main/res/values/strings.xml
+++ b/libs/pandares/src/main/res/values/strings.xml
@@ -1929,6 +1929,8 @@
Sort By
No Assignments
It looks like assignments haven\'t been created in this space yet.
+ No Matching Assignments
+ No assignments match your search. Try a different search term.
We\'re having trouble loading your student\'s grades. Please try reloading the page or check back later.
We\'re having trouble loading your student\'s course details. Please try reloading the page or check back later.
Filter
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt
index eecee20841..ae69e32b6f 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt
@@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,6 +46,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.instructure.pandautils.R
@@ -61,7 +63,8 @@ fun SearchBar(
searchQuery: String = "",
collapsable: Boolean = true,
@DrawableRes hintIcon: Int? = null,
- collapseOnSearch: Boolean = false
+ collapseOnSearch: Boolean = false,
+ onQueryChange: ((String) -> Unit)? = null
) {
Row(
modifier = modifier
@@ -69,7 +72,9 @@ fun SearchBar(
verticalAlignment = Alignment.CenterVertically
) {
var expanded by remember { mutableStateOf(!collapsable) }
- var query by remember { mutableStateOf(searchQuery) }
+ var query by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue(searchQuery))
+ }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
@@ -103,7 +108,10 @@ fun SearchBar(
.focusRequester(focusRequester),
placeholder = { Text(placeholder) },
value = query,
- onValueChange = { query = it },
+ onValueChange = {
+ query = it
+ onQueryChange?.invoke(it.text)
+ },
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Search
@@ -111,7 +119,7 @@ fun SearchBar(
keyboardActions = KeyboardActions(
onSearch = {
keyboardController?.hide()
- onSearch(query)
+ onSearch(query.text)
if (collapseOnSearch) {
expanded = false
onExpand?.invoke(false)
@@ -142,11 +150,11 @@ fun SearchBar(
)
},
trailingIcon = {
- if (query.isNotEmpty()) {
+ if (query.text.isNotEmpty()) {
IconButton(
modifier = Modifier.testTag("clearButton"),
onClick = {
- query = ""
+ query = TextFieldValue("")
onClear?.invoke()
}) {
Icon(
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt
index 0920811491..75871ed1e8 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt
@@ -21,6 +21,8 @@ import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
@@ -73,6 +75,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -100,6 +104,7 @@ import com.instructure.pandautils.compose.composables.ErrorContent
import com.instructure.pandautils.compose.composables.FullScreenDialog
import com.instructure.pandautils.compose.composables.GroupHeader
import com.instructure.pandautils.compose.composables.Loading
+import com.instructure.pandautils.compose.composables.SearchBar
import com.instructure.pandautils.compose.composables.SubmissionState
import com.instructure.pandautils.features.grades.gradepreferences.GradePreferencesScreen
import com.instructure.pandautils.utils.DisplayGrade
@@ -296,8 +301,46 @@ private fun GradesScreenContent(
)
}
+ AnimatedVisibility(
+ visible = uiState.isSearchExpanded,
+ enter = slideInVertically() + fadeIn(),
+ exit = slideOutVertically() + fadeOut()
+ ) {
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(uiState.isSearchExpanded) {
+ if (uiState.isSearchExpanded) {
+ focusRequester.requestFocus()
+ }
+ }
+
+ SearchBar(
+ icon = R.drawable.ic_search_white_24dp,
+ searchQuery = uiState.searchQuery,
+ tintColor = colorResource(R.color.textDarkest),
+ placeholder = stringResource(R.string.search),
+ collapsable = false,
+ onSearch = {
+ actionHandler(GradesAction.SearchQueryChanged(it))
+ },
+ onClear = {
+ actionHandler(GradesAction.SearchQueryChanged(""))
+ },
+ onQueryChange = {
+ actionHandler(GradesAction.SearchQueryChanged(it))
+ },
+ modifier = Modifier
+ .testTag("searchField")
+ .focusRequester(focusRequester)
+ )
+ }
+
if (uiState.items.isEmpty()) {
- EmptyContent()
+ if (uiState.searchQuery.length >= 3) {
+ EmptySearchContent()
+ } else {
+ EmptyContent()
+ }
}
}
@@ -422,6 +465,27 @@ private fun GradesCard(
modifier = Modifier.size(24.dp)
)
}
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .clickable {
+ actionHandler(GradesAction.ToggleSearch)
+ }
+ .semantics {
+ role = Role.Button
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_search_white_24dp),
+ contentDescription = stringResource(id = R.string.search),
+ tint = Color(userColor),
+ modifier = Modifier
+ .size(24.dp)
+ .testTag("searchIcon")
+ )
+ }
}
}
@@ -437,6 +501,18 @@ private fun EmptyContent() {
)
}
+@Composable
+private fun EmptySearchContent() {
+ EmptyContent(
+ emptyTitle = stringResource(id = R.string.noMatchingAssignments),
+ emptyMessage = stringResource(id = R.string.noMatchingAssignmentsDescription),
+ imageRes = R.drawable.ic_panda_space,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 32.dp, horizontal = 16.dp)
+ )
+}
+
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AssignmentItem(
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt
index 9d665e52a6..0df96c8502 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt
@@ -39,7 +39,9 @@ data class GradesUiState(
val onlyGradedAssignmentsSwitchEnabled: Boolean = true,
val gradeText: String = "",
val isGradeLocked: Boolean = false,
- val snackbarMessage: String? = null
+ val snackbarMessage: String? = null,
+ val searchQuery: String = "",
+ val isSearchExpanded: Boolean = false
)
data class AssignmentGroupUiState(
@@ -97,6 +99,8 @@ sealed class GradesAction {
data class AssignmentClick(val id: Long) : GradesAction()
data object SnackbarDismissed : GradesAction()
data class ToggleCheckpointsExpanded(val assignmentId: Long) : GradesAction()
+ data object ToggleSearch : GradesAction()
+ data class SearchQueryChanged(val query: String) : GradesAction()
}
sealed class GradesViewModelAction {
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt
index 52e7e1a39c..5e6a2e1487 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt
@@ -35,6 +35,7 @@ import com.instructure.pandautils.R
import com.instructure.pandautils.compose.composables.DiscussionCheckpointUiState
import com.instructure.pandautils.features.grades.gradepreferences.SortBy
import com.instructure.pandautils.utils.Const
+import com.instructure.pandautils.utils.debounce
import com.instructure.pandautils.utils.filterHiddenAssignments
import com.instructure.pandautils.utils.getAssignmentIcon
import com.instructure.pandautils.utils.getGrade
@@ -78,6 +79,16 @@ class GradesViewModel @Inject constructor(
private var courseGrade: CourseGrade? = null
private var customStatuses = listOf()
+ private var allItems = emptyList()
+
+ private val debouncedSearch = debounce(
+ coroutineScope = viewModelScope
+ ) { query ->
+ val filteredItems = filterItems(allItems, query)
+ _uiState.update {
+ it.copy(items = filteredItems)
+ }
+ }
init {
loadGrades(
@@ -121,16 +132,18 @@ class GradesViewModel @Inject constructor(
courseGrade = repository.getCourseGrade(course, repository.studentId, enrollments, selectedGradingPeriod?.id)
- val items = when (sortBy) {
+ allItems = when (sortBy) {
SortBy.GROUP -> groupByAssignmentGroup(assignmentGroups)
SortBy.DUE_DATE -> groupByDueDate(assignmentGroups)
}.filter {
it.assignments.isNotEmpty()
}
+ val filteredItems = filterItems(allItems, _uiState.value.searchQuery)
+
_uiState.update {
it.copy(
- items = items,
+ items = filteredItems,
isLoading = false,
isRefreshing = false,
gradePreferencesUiState = it.gradePreferencesUiState.copy(
@@ -280,6 +293,21 @@ class GradesViewModel @Inject constructor(
context.getString(R.string.due, "$dateText $timeText")
} ?: context.getString(R.string.gradesNoDueDate)
+ private fun filterItems(items: List, query: String): List {
+ if (query.length < 3) return items
+
+ return items.mapNotNull { group ->
+ val filteredAssignments = group.assignments.filter { assignment ->
+ assignment.name.contains(query, ignoreCase = true)
+ }
+ if (filteredAssignments.isEmpty()) {
+ null
+ } else {
+ group.copy(assignments = filteredAssignments)
+ }
+ }
+ }
+
fun handleAction(action: GradesAction) {
when (action) {
is GradesAction.Refresh -> {
@@ -351,6 +379,24 @@ class GradesViewModel @Inject constructor(
}
_uiState.update { it.copy(items = items) }
}
+
+ is GradesAction.ToggleSearch -> {
+ val isExpanding = !uiState.value.isSearchExpanded
+ _uiState.update {
+ it.copy(
+ isSearchExpanded = isExpanding,
+ searchQuery = if (!isExpanding) "" else it.searchQuery,
+ items = if (!isExpanding) allItems else it.items
+ )
+ }
+ }
+
+ is GradesAction.SearchQueryChanged -> {
+ _uiState.update {
+ it.copy(searchQuery = action.query)
+ }
+ debouncedSearch(action.query)
+ }
}
}
}