From bdbe2da007bc1c0c8d344fe6632a6e46dca9eefd Mon Sep 17 00:00:00 2001 From: Keigo Kato Date: Fri, 14 Jul 2023 13:30:34 +0900 Subject: [PATCH] Implement search screen UI (#263) * 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 129052031648b6dadda9ef39d273bf70262aa109. --- .../droidkaigi/confsched2023/KaigiApp.kt | 10 ++ .../droidkaigi/confsched2023/KaigiAppTest.kt | 10 ++ .../confsched2023/model/DroidKaigi2023Day.kt | 27 ++++ .../testing/robot/TimetableScreenRobot.kt | 8 ++ .../confsched2023/sessions/SearchScreen.kt | 102 ++++++++++++++ .../sessions/SearchScreenViewModel.kt | 81 ++++++++++++ .../confsched2023/sessions/TimetableScreen.kt | 7 +- .../component/EmptySearchResultBody.kt | 47 +++++++ .../sessions/component/FilterCategoryChip.kt | 93 +++++++++++++ .../sessions/component/FilterDayChip.kt | 90 +++++++++++++ .../sessions/component/SearchFilter.kt | 59 +++++++++ .../component/SearchTextFieldAppBar.kt | 124 ++++++++++++++++++ .../sessions/component/TimetableTopArea.kt | 13 ++ .../sessions/strings/SessionsStrings.kt | 12 ++ 14 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreen.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenViewModel.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/EmptySearchResultBody.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterCategoryChip.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterDayChip.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchFilter.kt create mode 100644 feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchTextFieldAppBar.kt diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt index 4c406a8e2..0049baf58 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched2023/KaigiApp.kt @@ -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 @@ -63,6 +65,11 @@ private fun KaigiNavHost( navController.popBackStack() }, ) + searchScreen( + onNavigationIconClick = { + navController.popBackStack() + }, + ) } } @@ -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, diff --git a/app-android/src/test/java/io/github/droidkaigi/confsched2023/KaigiAppTest.kt b/app-android/src/test/java/io/github/droidkaigi/confsched2023/KaigiAppTest.kt index fa11fe30b..318221987 100644 --- a/app-android/src/test/java/io/github/droidkaigi/confsched2023/KaigiAppTest.kt +++ b/app-android/src/test/java/io/github/droidkaigi/confsched2023/KaigiAppTest.kt @@ -63,4 +63,14 @@ class KaigiAppTest { capture() } } + + @Test + fun checkNavigateToSearchShot() { + kaigiAppRobot(robotTestRule) { + timetableScreenRobot(robotTestRule) { + clickSearchButton() + } + capture() + } + } } diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/DroidKaigi2023Day.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/DroidKaigi2023Day.kt index 9b64b6c2f..4d016623f 100644 --- a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/DroidKaigi2023Day.kt +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/DroidKaigi2023Day.kt @@ -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, @@ -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)" + } } diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/TimetableScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/TimetableScreenRobot.kt index 8cf0b800a..40253fb7f 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/TimetableScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/TimetableScreenRobot.kt @@ -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 @@ -36,6 +37,7 @@ class TimetableScreenRobot @Inject constructor( composeTestRule.setContent { KaigiTheme { TimetableScreen( + onSearchClick = { }, onTimetableItemClick = { }, ) } @@ -59,6 +61,12 @@ class TimetableScreenRobot @Inject constructor( waitUntilIdle() } + fun clickSearchButton() { + composeTestRule + .onNode(hasTestTag(SearchButtonTestTag)) + .performClick() + } + fun clickTimetableUiTypeChangeButton() { composeTestRule .onNode(hasTestTag(TimetableUiTypeChangeButtonTestTag)) diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreen.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreen.kt new file mode 100644 index 000000000..57a646950 --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreen.kt @@ -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() + } + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenViewModel.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenViewModel.kt new file mode 100644 index 000000000..2bfffcaca --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenViewModel.kt @@ -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 = MutableStateFlow( + SearchFilterUiState(), + ) + + val uiState: StateFlow = 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() + 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) + } + }, + ) + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreen.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreen.kt index 44304909a..f7a7527bb 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreen.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreen.kt @@ -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, ) } @@ -46,6 +48,7 @@ const val TimetableScreenTestTag = "TimetableScreen" @Composable fun TimetableScreen( + onSearchClick: () -> Unit, onTimetableItemClick: (TimetableItem) -> Unit, viewModel: TimetableScreenViewModel = hiltViewModel(), ) { @@ -61,6 +64,7 @@ fun TimetableScreen( snackbarHostState = snackbarHostState, onTimetableItemClick = onTimetableItemClick, onBookmarkClick = viewModel::onBookmarkClick, + onSearchClick = onSearchClick, onTimetableUiChangeClick = viewModel::onUiTypeChange, ) } @@ -75,6 +79,7 @@ private fun TimetableScreen( snackbarHostState: SnackbarHostState, onTimetableItemClick: (TimetableItem) -> Unit, onBookmarkClick: (TimetableItem) -> Unit, + onSearchClick: () -> Unit, onTimetableUiChangeClick: () -> Unit, ) { val state = rememberTimetableScreenScrollState() @@ -89,7 +94,7 @@ private fun TimetableScreen( ) }, topBar = { - TimetableTopArea(state, onTimetableUiChangeClick) + TimetableTopArea(state, onSearchClick, onTimetableUiChangeClick) }, containerColor = MaterialTheme.colorScheme.surfaceVariant, contentWindowInsets = WindowInsets(0.dp), diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/EmptySearchResultBody.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/EmptySearchResultBody.kt new file mode 100644 index 000000000..2e4fdb4a4 --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/EmptySearchResultBody.kt @@ -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, + ) + } + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterCategoryChip.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterCategoryChip.kt new file mode 100644 index 000000000..ba8d8a881 --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterCategoryChip.kt @@ -0,0 +1,93 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.model.TimetableCategory +import io.github.droidkaigi.confsched2023.sessions.strings.SessionsStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterCategoryChip( + selectedCategories: List, + categories: List, + onCategoriesSelected: (TimetableCategory, Boolean) -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + selectedCategoriesValues: String = "", + onFilterCategoryChipClicked: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val onCategoriesSelectedUpdated by rememberUpdatedState(newValue = onCategoriesSelected) + + Box( + modifier = modifier, + ) { + FilterChip( + selected = isSelected, + onClick = { + onFilterCategoryChipClicked() + expanded = true + }, + label = { Text(text = selectedCategoriesValues.ifEmpty { SessionsStrings.Category.asString() }) }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + categories.forEach { category -> + DropdownMenuItem( + text = { + Text( + text = category.title.currentLangTitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingIcon = { + if (selectedCategories.contains(category)) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onCategoriesSelectedUpdated( + category, + selectedCategories + .contains(category) + .not(), + ) + expanded = false + }, + ) + } + } + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterDayChip.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterDayChip.kt new file mode 100644 index 000000000..03bf9230c --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterDayChip.kt @@ -0,0 +1,90 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day +import io.github.droidkaigi.confsched2023.sessions.strings.SessionsStrings +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterDayChip( + selectedDays: List, + kaigiDays: List, + onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + selectedDaysValues: String = "", +) { + var expanded by remember { mutableStateOf(false) } + val onDaySelectedUpdated by rememberUpdatedState(newValue = onDaySelected) + + Box( + modifier = modifier, + ) { + FilterChip( + selected = isSelected, + onClick = { expanded = true }, + label = { Text(text = selectedDaysValues.ifEmpty { SessionsStrings.EventDay.asString() }) }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + kaigiDays.forEach { kaigiDay -> + DropdownMenuItem( + text = { + Text( + text = kaigiDay.getDropDownText(Locale.getDefault().language), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingIcon = { + if (selectedDays.contains(kaigiDay)) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onDaySelectedUpdated( + kaigiDay, + selectedDays + .contains(kaigiDay) + .not(), + ) + expanded = false + }, + ) + } + } + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchFilter.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchFilter.kt new file mode 100644 index 000000000..d26d83a5f --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchFilter.kt @@ -0,0 +1,59 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day +import io.github.droidkaigi.confsched2023.model.TimetableCategory + +data class SearchFilterUiState( + val categories: List = emptyList(), + val selectedCategories: List = emptyList(), + val selectedDays: List = emptyList(), + val isFavoritesOn: Boolean = false, +) { + val selectedDaysValues: String + get() = selectedDays.joinToString { it.name } + + val isDaySelected: Boolean + get() = selectedDays.isNotEmpty() + + val selectedCategoriesValue: String + get() = selectedCategories.joinToString { it.title.currentLangTitle } + + val isCategoriesSelected: Boolean + get() = selectedCategories.isNotEmpty() +} + +@Composable +fun SearchFilter( + searchFilterUiState: SearchFilterUiState, + modifier: Modifier = Modifier, + onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit = { _, _ -> }, + onCategoriesSelected: (TimetableCategory, Boolean) -> Unit = { _, _ -> }, + onFilterCategoryChipClicked: () -> Unit = {}, +) { + Row( + modifier = modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterDayChip( + isSelected = searchFilterUiState.isDaySelected, + selectedDays = searchFilterUiState.selectedDays, + selectedDaysValues = searchFilterUiState.selectedDaysValues, + kaigiDays = DroidKaigi2023Day.values().toList(), + onDaySelected = onDaySelected, + ) + FilterCategoryChip( + isSelected = searchFilterUiState.isCategoriesSelected, + selectedCategories = searchFilterUiState.selectedCategories, + selectedCategoriesValues = searchFilterUiState.selectedCategoriesValue, + categories = searchFilterUiState.categories, + onCategoriesSelected = onCategoriesSelected, + onFilterCategoryChipClicked = onFilterCategoryChipClicked, + ) + } +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchTextFieldAppBar.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchTextFieldAppBar.kt new file mode 100644 index 000000000..7608f6f5c --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchTextFieldAppBar.kt @@ -0,0 +1,124 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchTextFieldAppBar( + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = null, + ) + } + }, + title = { + SearchTextField( + searchQuery = searchQuery, + onSearchQueryChanged = onSearchQueryChanged, + ) + }, + ) +} + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun SearchTextField( + modifier: Modifier = Modifier, + searchQuery: String = "", + onSearchQueryChanged: (String) -> Unit = {}, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChanged, + modifier = modifier + .height(56.0.dp) + .fillMaxWidth(1.0f) + .focusRequester(focusRequester), + enabled = enabled, + textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = searchQuery, + innerTextField = innerTextField, + enabled = enabled, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + Box(modifier = Modifier.offset(x = (-4).dp)) { + IconButton( + onClick = { + onSearchQueryChanged("") + }, + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + }, + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(0.dp), + container = {}, + ) + }, + ) +} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTopArea.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTopArea.kt index 7b3ccbd43..7b654ebbb 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTopArea.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableTopArea.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -19,14 +20,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.sessions.strings.SessionsStrings.Search import io.github.droidkaigi.confsched2023.sessions.strings.SessionsStrings.Timetable +const val SearchButtonTestTag = "SearchButton" const val TimetableUiTypeChangeButtonTestTag = "TimetableUiTypeChangeButton" @Composable @OptIn(ExperimentalMaterial3Api::class) fun TimetableTopArea( state: TimetableScreenScrollState, + onSearchClick: () -> Unit, onTimetableUiChangeClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -37,6 +41,15 @@ fun TimetableTopArea( Text(text = "KaigiApp") }, actions = { + IconButton( + modifier = Modifier.testTag(SearchButtonTestTag), + onClick = { onSearchClick() }, + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = Search.asString(), + ) + } IconButton( modifier = Modifier.testTag(TimetableUiTypeChangeButtonTestTag), onClick = { onTimetableUiChangeClick() }, diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/strings/SessionsStrings.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/strings/SessionsStrings.kt index 29f5b07f2..83b87c4fe 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/strings/SessionsStrings.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/strings/SessionsStrings.kt @@ -5,29 +5,41 @@ import io.github.droidkaigi.confsched2023.designsystem.strings.Strings import io.github.droidkaigi.confsched2023.designsystem.strings.StringsBindings sealed class SessionsStrings : Strings(Bindings) { + object Search : SessionsStrings() object Timetable : SessionsStrings() object Hoge : SessionsStrings() class Time(val hours: Int, val minutes: Int) : SessionsStrings() object ScheduleIcon : SessionsStrings() object UserIcon : SessionsStrings() + object EventDay : SessionsStrings() + object Category : SessionsStrings() + object SearchResultNotFound : SessionsStrings() private object Bindings : StringsBindings( Lang.Japanese to { item, _ -> when (item) { + Search -> "検索" Timetable -> "タイムテーブル" Hoge -> "ホゲ" is Time -> "${item.hours}時${item.minutes}分" ScheduleIcon -> "スケジュールアイコン" UserIcon -> "ユーザーアイコン" + EventDay -> "開催日" + Category -> "カテゴリー" + SearchResultNotFound -> "この検索条件に一致する結果はありません" } }, Lang.English to { item, bindings -> when (item) { + Search -> "Search" Timetable -> "Timetable" Hoge -> bindings.defaultBinding(item, bindings) is Time -> "${item.hours}:${item.minutes}" ScheduleIcon -> "Schedule icon" UserIcon -> "User icon" + EventDay -> "Day" + Category -> "Category" + SearchResultNotFound -> "Nothing matched your search criteria" } }, default = Lang.Japanese,