Skip to content

Commit

Permalink
Implement search screen UI (#263)
Browse files Browse the repository at this point in the history
* feat: implement search screen ui

* feat: add searchScreen navGraph to KaigiNavHost

* refactor: rename searchScreen navGraph

* spotless fix

* fix: TimetableScreenRobot

* test: add screenshot test of navigation to search screen

* refactor: replace string literal of filter chip with SessionsStrings

* refactor: replace string literal of result not found message

* refactor: add getDropDownText in DroidKaigi2023Day

* fix: updated date

* Revert "fix: updated date"

This reverts commit 1290520.
  • Loading branch information
keigomichi authored Jul 14, 2023
1 parent 7ff3a4e commit bdbe2da
Show file tree
Hide file tree
Showing 14 changed files with 682 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import io.github.droidkaigi.confsched2023.main.MainScreenTab.Contributor
import io.github.droidkaigi.confsched2023.main.MainScreenTab.Timetable
import io.github.droidkaigi.confsched2023.main.mainScreen
import io.github.droidkaigi.confsched2023.main.mainScreenRoute
import io.github.droidkaigi.confsched2023.sessions.navigateSearchScreen
import io.github.droidkaigi.confsched2023.sessions.navigateTimetableScreen
import io.github.droidkaigi.confsched2023.sessions.navigateToTimetableItemDetailScreen
import io.github.droidkaigi.confsched2023.sessions.nestedSessionScreens
import io.github.droidkaigi.confsched2023.sessions.searchScreen
import io.github.droidkaigi.confsched2023.sessions.sessionScreens
import io.github.droidkaigi.confsched2023.sessions.timetableScreenRoute

Expand Down Expand Up @@ -63,6 +65,11 @@ private fun KaigiNavHost(
navController.popBackStack()
},
)
searchScreen(
onNavigationIconClick = {
navController.popBackStack()
},
)
}
}

Expand All @@ -71,6 +78,9 @@ private fun NavGraphBuilder.mainScreen(navController: NavHostController) {
mainNestedGraphStateHolder = KaigiAppMainNestedGraphStateHolder(),
mainNestedGraph = { mainNestedNavController, padding ->
nestedSessionScreens(
onSearchClick = {
navController.navigateSearchScreen()
},
onTimetableItemClick = { timetableitem ->
navController.navigateToTimetableItemDetailScreen(
timetableitem.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,14 @@ class KaigiAppTest {
capture()
}
}

@Test
fun checkNavigateToSearchShot() {
kaigiAppRobot(robotTestRule) {
timetableScreenRobot(robotTestRule) {
clickSearchButton()
}
capture()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime

public enum class DroidKaigi2023Day(
public val start: Instant,
Expand Down Expand Up @@ -47,4 +48,30 @@ public enum class DroidKaigi2023Day(
return Day1.start < Clock.System.now()
}
}

fun getDropDownText(language: String): String {
val japanese = "ja"

val date = this.start.toLocalDateTime(TimeZone.currentSystemDefault())

val year = if (language == japanese) {
"${date.year}"
} else {
"${date.year}"
}

val month = if (language == japanese) {
"${date.monthNumber}"
} else {
date.month.name.lowercase().replaceFirstChar { it.uppercase() }
}

val day = if (language == japanese) {
"${date.dayOfMonth}"
} else {
"${date.dayOfMonth}th"
}

return "${this.name} ($year $month $day)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme
import io.github.droidkaigi.confsched2023.sessions.TimetableScreen
import io.github.droidkaigi.confsched2023.sessions.TimetableScreenTestTag
import io.github.droidkaigi.confsched2023.sessions.component.SearchButtonTestTag
import io.github.droidkaigi.confsched2023.sessions.component.TimetableListItemTestTag
import io.github.droidkaigi.confsched2023.sessions.component.TimetableUiTypeChangeButtonTestTag
import io.github.droidkaigi.confsched2023.testing.RobotTestRule
Expand All @@ -36,6 +37,7 @@ class TimetableScreenRobot @Inject constructor(
composeTestRule.setContent {
KaigiTheme {
TimetableScreen(
onSearchClick = { },
onTimetableItemClick = { },
)
}
Expand All @@ -59,6 +61,12 @@ class TimetableScreenRobot @Inject constructor(
waitUntilIdle()
}

fun clickSearchButton() {
composeTestRule
.onNode(hasTestTag(SearchButtonTestTag))
.performClick()
}

fun clickTimetableUiTypeChangeButton() {
composeTestRule
.onNode(hasTestTag(TimetableUiTypeChangeButtonTestTag))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package io.github.droidkaigi.confsched2023.sessions

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.TimetableCategory
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

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

fun NavGraphBuilder.searchScreen(onNavigationIconClick: () -> Unit) {
composable(searchScreenRoute) {
SearchScreen(
onBackClick = onNavigationIconClick,
)
}
}

fun NavController.navigateSearchScreen() {
navigate(searchScreenRoute)
}

@Composable
fun SearchScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchScreenViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()

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

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

@Composable
private fun SearchScreen(
searchFilterUiState: SearchFilterUiState,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
searchQuery: String = "",
onSearchQueryChanged: (String) -> Unit = {},
onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit = { _, _ -> },
onFilterCategoryChipClicked: () -> Unit = {},
onCategoriesSelected: (TimetableCategory, Boolean) -> Unit = { _, _ -> },
) {
Scaffold(
modifier = modifier.testTag(SearchScreenTestTag),
topBar = {
SearchTextFieldAppBar(
searchQuery = searchQuery,
onSearchQueryChanged = onSearchQueryChanged,
onBackClick = onBackClick,
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
) {
Divider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline,
)
SearchFilter(
searchFilterUiState = searchFilterUiState,
onDaySelected = onDaySelected,
onFilterCategoryChipClicked = onFilterCategoryChipClicked,
onCategoriesSelected = onCategoriesSelected,
)
EmptySearchResultBody()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.github.droidkaigi.confsched2023.sessions

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day
import io.github.droidkaigi.confsched2023.model.TimetableCategory
import io.github.droidkaigi.confsched2023.sessions.component.SearchFilterUiState
import io.github.droidkaigi.confsched2023.ui.buildUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

const val SEARCH_QUERY = "searchQuery"

@HiltViewModel
class SearchScreenViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "")

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

val uiState: StateFlow<SearchScreenUiState> = buildUiState(
searchQuery,
searchFilterUiState,
) { searchQuery, searchFilterUiState ->
SearchScreenUiState(
searchQuery = searchQuery,
searchFilterUiState = searchFilterUiState,
)
}

fun onSearchQueryChanged(searchQuery: String) {
savedStateHandle[SEARCH_QUERY] = searchQuery
}

fun onDaySelected(day: DroidKaigi2023Day, isSelected: Boolean) {
val selectedDays = searchFilterUiState.value.selectedDays.toMutableList()
searchFilterUiState.value = searchFilterUiState.value.copy(
selectedDays = selectedDays.apply {
if (isSelected) {
add(day)
} else {
remove(day)
}
}.sortedBy(DroidKaigi2023Day::start),
)
}

fun onFilterCategoryChipClicked() {
viewModelScope.launch {
// TODO: Implement SessionsRepository.getCategories()
val categories = emptyList<TimetableCategory>()
if (categories.isEmpty()) {
return@launch
}

searchFilterUiState.value = SearchFilterUiState(
categories = categories,
)
}
}

fun onCategoriesSelected(category: TimetableCategory, isSelected: Boolean) {
val selectedCategories = searchFilterUiState.value.selectedCategories.toMutableList()
searchFilterUiState.value = searchFilterUiState.value.copy(
selectedCategories = selectedCategories.apply {
if (isSelected) {
add(category)
} else {
remove(category)
}
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ import kotlin.math.roundToInt

const val timetableScreenRoute = "timetable"
fun NavGraphBuilder.nestedSessionScreens(
onSearchClick: () -> Unit,
onTimetableItemClick: (TimetableItem) -> Unit,
) {
composable(timetableScreenRoute) {
TimetableScreen(
onSearchClick = onSearchClick,
onTimetableItemClick = onTimetableItemClick,
)
}
Expand All @@ -46,6 +48,7 @@ const val TimetableScreenTestTag = "TimetableScreen"

@Composable
fun TimetableScreen(
onSearchClick: () -> Unit,
onTimetableItemClick: (TimetableItem) -> Unit,
viewModel: TimetableScreenViewModel = hiltViewModel<TimetableScreenViewModel>(),
) {
Expand All @@ -61,6 +64,7 @@ fun TimetableScreen(
snackbarHostState = snackbarHostState,
onTimetableItemClick = onTimetableItemClick,
onBookmarkClick = viewModel::onBookmarkClick,
onSearchClick = onSearchClick,
onTimetableUiChangeClick = viewModel::onUiTypeChange,
)
}
Expand All @@ -75,6 +79,7 @@ private fun TimetableScreen(
snackbarHostState: SnackbarHostState,
onTimetableItemClick: (TimetableItem) -> Unit,
onBookmarkClick: (TimetableItem) -> Unit,
onSearchClick: () -> Unit,
onTimetableUiChangeClick: () -> Unit,
) {
val state = rememberTimetableScreenScrollState()
Expand All @@ -89,7 +94,7 @@ private fun TimetableScreen(
)
},
topBar = {
TimetableTopArea(state, onTimetableUiChangeClick)
TimetableTopArea(state, onSearchClick, onTimetableUiChangeClick)
},
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentWindowInsets = WindowInsets(0.dp),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.github.droidkaigi.confsched2023.sessions.component

import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.droidkaigi.confsched2023.sessions.strings.SessionsStrings

@Composable
fun EmptySearchResultBody(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(58.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.height(28.dp))
Text(
text = SessionsStrings.SearchResultNotFound.asString(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
Loading

0 comments on commit bdbe2da

Please sign in to comment.