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 @@ -16,19 +18,47 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day
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.model.TimetableItemId
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 kotlinx.collections.immutable.PersistentSet

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 sessions: Timetable,
val bookmarkedTimetableItemIds: PersistentSet<TimetableItemId>,
) : SearchScreenUiState
}

fun NavGraphBuilder.searchScreen(
onNavigationIconClick: () -> Unit,
onTimetableItemClick: (TimetableItem) -> Unit,
) {
composable(searchScreenRoute) {
SearchScreen(
onBackClick = onNavigationIconClick,
onTimetableItemClick = onTimetableItemClick,
)
}
}
Expand All @@ -40,63 +70,74 @@ 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::updateBookmark,
)
}

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,
bookmarkedTimetableItemIds = uiState.bookmarkedTimetableItemIds,
timetableItems = uiState.sessions.timetableItems,
onTimetableItemClick = onTimetableItemClick,
onBookmarkIconClick = onBookmarkClick,
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@ 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.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 +27,52 @@ 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,
sessions = searchedSessions,
bookmarkedTimetableItemIds = sessions.bookmarks,
)
}
}

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

fun updateBookmark(timetableItem: TimetableItem) {
Copy link
Member

Choose a reason for hiding this comment

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

It's my preference but how about using onClickTimetableItemBookmark to promote the best practice that notifying events to ViewModel.

viewModelScope.launch {
sessionsRepository.toggleBookmark(timetableItem.id)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
data object UserIcon : SessionsStrings()
data object EventDay : SessionsStrings()
data object Category : SessionsStrings()
data object SearchResultNotFound : SessionsStrings()
class SearchResultNotFound(val missedWord: String) : SessionsStrings()
data object Bookmark : SessionsStrings()
data object BookmarkFilterAllChip : SessionsStrings()
data object BookmarkedItemNotFound : SessionsStrings()
Expand All @@ -31,6 +31,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
data object WatchVideo : SessionsStrings()
data object Speaker : SessionsStrings()
data object TargetAudience : SessionsStrings()
data object SearchHint : SessionsStrings()
private object Bindings : StringsBindings<SessionsStrings>(
Lang.Japanese to { item, _ ->
when (item) {
Expand All @@ -44,7 +45,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
Category -> "カテゴリー"
Bookmark -> "Bookmark"
BookmarkFilterAllChip -> "全て"
SearchResultNotFound -> "この検索条件に一致する結果はありません"
is SearchResultNotFound -> "「${item.missedWord}」と一致する検索結果がありません"
BookmarkedItemNotFound -> "登録されたセッションがありません"
BookmarkedItemNotFoundSideNote -> "気になるセッションをブックマークに追加して\n集めてみましょう!"
Share -> "共有"
Expand All @@ -60,6 +61,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
TargetAudience -> "対象者"
WatchVideo -> "動画を見る"
Speaker -> "スピーカー"
SearchHint -> "気になる技術を入力"
}
},
Lang.English to { item, bindings ->
Expand All @@ -72,7 +74,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
UserIcon -> "User icon"
EventDay -> "Day"
Category -> "Category"
SearchResultNotFound -> "Nothing matched your search criteria"
is SearchResultNotFound -> "Nothing matched your search criteria \"${item.missedWord}\""
Bookmark -> "Bookmark"
BookmarkFilterAllChip -> "All"
BookmarkedItemNotFound -> "No sessions registered"
Expand All @@ -90,6 +92,7 @@ sealed class SessionsStrings : Strings<SessionsStrings>(Bindings) {
WatchVideo -> "Watch Video"
Speaker -> "Speaker"
TargetAudience -> "Target Audience"
SearchHint -> "Enter the technology you are interested in"
}
},
default = Lang.Japanese,
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 @@ -15,6 +15,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
Expand All @@ -31,6 +32,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import io.github.droidkaigi.confsched2023.sessions.SessionsStrings

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -99,6 +101,13 @@ private fun SearchTextField(
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = {
if (searchQuery.isBlank()) {
Box {
Text(text = SessionsStrings.SearchHint.asString())
}
}
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
Box(modifier = Modifier.offset(x = (-4).dp)) {
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