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) + } } } }