Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement feature that displays results according to search input text #574

Merged
merged 12 commits into from
Aug 17, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ private fun KaigiNavHost(
onNavigationIconClick = {
navController.popBackStack()
},
onTimetableItemClick = { timetableItem ->
navController.navigateToTimetableItemDetailScreen(
timetableItem.id,
)
},
)
sponsorsScreen(
onNavigationIconClick = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package io.github.droidkaigi.confsched2023.sessions

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
Expand All @@ -17,18 +19,43 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day
import io.github.droidkaigi.confsched2023.model.TimetableCategory
import io.github.droidkaigi.confsched2023.model.TimetableItem
import io.github.droidkaigi.confsched2023.sessions.SearchScreenUiState.Empty
import io.github.droidkaigi.confsched2023.sessions.SearchScreenUiState.SearchList
import io.github.droidkaigi.confsched2023.sessions.component.EmptySearchResultBody
import io.github.droidkaigi.confsched2023.sessions.component.SearchFilter
import io.github.droidkaigi.confsched2023.sessions.component.SearchFilterUiState
import io.github.droidkaigi.confsched2023.sessions.component.SearchTextFieldAppBar
import io.github.droidkaigi.confsched2023.sessions.section.SearchList
import io.github.droidkaigi.confsched2023.sessions.section.SearchListUiState

const val searchScreenRoute = "search"
const val SearchScreenTestTag = "SearchScreen"

fun NavGraphBuilder.searchScreen(onNavigationIconClick: () -> Unit) {
sealed interface SearchScreenUiState {
val searchQuery: String
val searchFilterUiState: SearchFilterUiState

data class Empty(
override val searchQuery: String,
override val searchFilterUiState: SearchFilterUiState,
) : SearchScreenUiState

data class SearchList(
override val searchQuery: String,
override val searchFilterUiState: SearchFilterUiState,
val searchListUiState: SearchListUiState,
) : SearchScreenUiState
}

fun NavGraphBuilder.searchScreen(
onNavigationIconClick: () -> Unit,
onTimetableItemClick: (TimetableItem) -> Unit,
) {
composable(searchScreenRoute) {
SearchScreen(
onBackClick = onNavigationIconClick,
onTimetableItemClick = onTimetableItemClick,
)
}
}
Expand All @@ -40,63 +67,73 @@ fun NavController.navigateSearchScreen() {
@Composable
fun SearchScreen(
onBackClick: () -> Unit,
onTimetableItemClick: (TimetableItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchScreenViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()

SearchScreen(
uiState = uiState,
modifier = modifier,
onBackClick = onBackClick,
searchQuery = uiState.searchQuery,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
searchFilterUiState = uiState.searchFilterUiState,
onDaySelected = viewModel::onDaySelected,
onFilterCategoryChipClicked = viewModel::onFilterCategoryChipClicked,
onCategoriesSelected = viewModel::onCategoriesSelected,
onTimetableItemClick = onTimetableItemClick,
onBookmarkClick = viewModel::onClickTimetableItemBookmark,
)
}

data class SearchScreenUiState(
val searchQuery: String,
val searchFilterUiState: SearchFilterUiState,
)

@Composable
private fun SearchScreen(
searchFilterUiState: SearchFilterUiState,
uiState: SearchScreenUiState,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
searchQuery: String = "",
onSearchQueryChanged: (String) -> Unit = {},
onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit = { _, _ -> },
onFilterCategoryChipClicked: () -> Unit = {},
onCategoriesSelected: (TimetableCategory, Boolean) -> Unit = { _, _ -> },
onTimetableItemClick: (TimetableItem) -> Unit = {},
onBookmarkClick: (TimetableItem) -> Unit = {},
) {
val scrollState = rememberLazyListState()
Scaffold(
modifier = modifier.testTag(SearchScreenTestTag),
topBar = {
SearchTextFieldAppBar(
searchQuery = searchQuery,
searchQuery = uiState.searchQuery,
onSearchQueryChanged = onSearchQueryChanged,
onBackClick = onBackClick,
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
) {
Divider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline,
)
SearchFilter(
searchFilterUiState = searchFilterUiState,
searchFilterUiState = uiState.searchFilterUiState,
onDaySelected = onDaySelected,
onFilterCategoryChipClicked = onFilterCategoryChipClicked,
onCategoriesSelected = onCategoriesSelected,
)
EmptySearchResultBody()
when (uiState) {
is Empty -> EmptySearchResultBody(missedQuery = uiState.searchQuery)
is SearchList -> SearchList(
contentPaddingValues = PaddingValues(
bottom = innerPadding.calculateBottomPadding(),
),
scrollState = scrollState,
searchListUiState = uiState.searchListUiState,
onTimetableItemClick = onTimetableItemClick,
onBookmarkIconClick = onBookmarkClick,
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.droidkaigi.confsched2023.designsystem.strings.AppStrings
import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day
import io.github.droidkaigi.confsched2023.model.Filters
import io.github.droidkaigi.confsched2023.model.SessionsRepository
import io.github.droidkaigi.confsched2023.model.Timetable
import io.github.droidkaigi.confsched2023.model.TimetableCategory
import io.github.droidkaigi.confsched2023.model.TimetableItem
import io.github.droidkaigi.confsched2023.sessions.component.SearchFilterUiState
import io.github.droidkaigi.confsched2023.sessions.section.SearchListUiState
import io.github.droidkaigi.confsched2023.ui.UserMessageStateHolder
import io.github.droidkaigi.confsched2023.ui.buildUiState
import io.github.droidkaigi.confsched2023.ui.handleErrorAndRetry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -18,21 +28,54 @@ const val SEARCH_QUERY = "searchQuery"
@HiltViewModel
class SearchScreenViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val sessionsRepository: SessionsRepository,
userMessageStateHolder: UserMessageStateHolder,
) : ViewModel() {
private val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "")

private val searchFilterUiState: MutableStateFlow<SearchFilterUiState> = MutableStateFlow(
SearchFilterUiState(),
)

private val sessionsStateFlow: StateFlow<Timetable> = sessionsRepository
.getTimetableStream()
.handleErrorAndRetry(
AppStrings.Retry,
userMessageStateHolder,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Timetable(),
)

val uiState: StateFlow<SearchScreenUiState> = buildUiState(
searchQuery,
searchFilterUiState,
) { searchQuery, searchFilterUiState ->
SearchScreenUiState(
searchQuery = searchQuery,
searchFilterUiState = searchFilterUiState,
sessionsStateFlow,
) { searchQuery, searchFilterUiState, sessions ->
val searchedSessions = sessions.filtered(
Filters(
days = searchFilterUiState.selectedDays,
categories = searchFilterUiState.selectedCategories,
searchWord = searchQuery,
),
)
if (searchedSessions.isEmpty()) {
SearchScreenUiState.Empty(
searchQuery = searchQuery,
searchFilterUiState = searchFilterUiState,
)
} else {
SearchScreenUiState.SearchList(
searchQuery = searchQuery,
searchFilterUiState = searchFilterUiState,
searchListUiState = SearchListUiState(
bookmarkedTimetableItemIds = sessions.bookmarks,
timetableItems = searchedSessions.timetableItems,
),
)
}
}

fun onSearchQueryChanged(searchQuery: String) {
Expand Down Expand Up @@ -78,4 +121,10 @@ class SearchScreenViewModel @Inject constructor(
},
)
}

fun onClickTimetableItemBookmark(timetableItem: TimetableItem) {
viewModelScope.launch {
sessionsRepository.toggleBookmark(timetableItem.id)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
data object UserIcon : SessionsStrings()
data object EventDay : SessionsStrings()
data object Category : SessionsStrings()
class SearchResultNotFound(val missedWord: String) : SessionsStrings()
data object SearchPlaceHolder : SessionsStrings()
data object SearchResultNotFound : SessionsStrings()
data object Bookmark : SessionsStrings()
data object BookmarkFilterAllChip : SessionsStrings()
data object BookmarkedItemNotFound : SessionsStrings()
Expand Down Expand Up @@ -45,8 +45,8 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
Category -> "カテゴリー"
Bookmark -> "Bookmark"
BookmarkFilterAllChip -> "全て"
is SearchResultNotFound -> "「${item.missedWord}」と一致する検索結果がありません"
SearchPlaceHolder -> "気になる技術を入力"
SearchResultNotFound -> "この検索条件に一致する結果はありません"
BookmarkedItemNotFound -> "登録されたセッションがありません"
BookmarkedItemNotFoundSideNote -> "気になるセッションをブックマークに追加して\n集めてみましょう!"
Share -> "共有"
Expand Down Expand Up @@ -74,8 +74,8 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
UserIcon -> "User icon"
EventDay -> "Day"
Category -> "Category"
is SearchResultNotFound -> "Nothing matched your search criteria \"${item.missedWord}\""
SearchPlaceHolder -> "Enter some technology"
SearchResultNotFound -> "Nothing matched your search criteria"
Bookmark -> "Bookmark"
BookmarkFilterAllChip -> "All"
BookmarkedItemNotFound -> "No sessions registered"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
Expand All @@ -20,10 +21,11 @@ import io.github.droidkaigi.confsched2023.sessions.SessionsStrings

@Composable
fun EmptySearchResultBody(
missedQuery: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().imePadding(),
contentAlignment = Alignment.Center,
) {
Column(
Expand All @@ -37,7 +39,7 @@ fun EmptySearchResultBody(
)
Spacer(modifier = Modifier.height(28.dp))
Text(
text = SessionsStrings.SearchResultNotFound.asString(),
text = SessionsStrings.SearchResultNotFound(missedQuery).asString(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ private fun SearchTextField(
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = {
Text(
text = SearchPlaceHolder.asString(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (searchQuery.isBlank()) {
Text(
text = SearchPlaceHolder.asString(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
Expand Down Expand Up @@ -43,6 +45,7 @@ import io.github.droidkaigi.confsched2023.ui.rememberAsyncImagePainter
const val TimetableListItemTestTag = "TimetableListItem"
const val TimetableListItemBookmarkIconTestTag = "TimetableListItemBookmarkIconTestTag"

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TimetableListItem(
timetableItem: TimetableItem,
Expand All @@ -58,7 +61,7 @@ fun TimetableListItem(
.clickable { onClick(timetableItem) },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.weight(1F)) {
FlowRow(modifier = Modifier.weight(1F)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

chipContent()
}
IconToggleButton(
Expand Down
Loading
Loading