diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 6befe1230..3990d7375 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -9,9 +9,7 @@ group = "io.github.droidkaigi.confsched2023.buildlogic" repositories { google() mavenCentral() - maven { - url = uri("https://plugins.gradle.org/m2/") - } + gradlePluginPortal() } // If we use jvmToolchain, we need to install JDK 11 diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/TimetableCategory.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/TimetableCategory.kt index df1ac5713..2607d8f92 100644 --- a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/TimetableCategory.kt +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched2023/model/TimetableCategory.kt @@ -3,4 +3,32 @@ package io.github.droidkaigi.confsched2023.model public data class TimetableCategory( val id: Int, val title: MultiLangText, -) +) { + public companion object +} + +fun TimetableCategory.Companion.fakes(): List { + return listOf( + TimetableCategory( + id = 1, + title = MultiLangText( + jaTitle = "Kotlin", + enTitle = "Kotlin", + ), + ), + TimetableCategory( + id = 2, + title = MultiLangText( + jaTitle = "Security / Identity / Privacy", + enTitle = "Security / Identity / Privacy", + ), + ), + TimetableCategory( + id = 3, + title = MultiLangText( + jaTitle = "UI・UX・デザイン", + enTitle = "UI・UX・Design", + ), + ), + ) +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/SearchScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/SearchScreenRobot.kt new file mode 100644 index 000000000..e99834223 --- /dev/null +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched2023/testing/robot/SearchScreenRobot.kt @@ -0,0 +1,101 @@ +package io.github.droidkaigi.confsched2023.testing.robot + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import com.github.takahirom.roborazzi.captureRoboImage +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched2023.sessions.SearchScreen +import io.github.droidkaigi.confsched2023.sessions.SearchScreenTestTag +import io.github.droidkaigi.confsched2023.sessions.component.DropdownFilterChipItemTestTag +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterTestTag +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.coroutines.runTestWithLogging +import kotlinx.coroutines.test.TestDispatcher +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +class SearchScreenRobot @Inject constructor( + private val testDispatcher: TestDispatcher, +) { + @Inject lateinit var robotTestRule: RobotTestRule + + private lateinit var composeTestRule: AndroidComposeTestRule<*, *> + + operator fun invoke( + block: SearchScreenRobot.() -> Unit, + ) { + runTestWithLogging(timeout = 30.seconds) { + this@SearchScreenRobot.composeTestRule = robotTestRule.composeTestRule + block() + } + } + + fun setupSearchScreenContent() { + composeTestRule.setContent { + KaigiTheme { + SearchScreen( + onBackClick = {}, + onTimetableItemClick = {}, + ) + } + } + waitUntilIdle() + } + + fun clickFilterChip(filterChipTestTag: String) { + composeTestRule + .onNode(hasTestTag(filterChipTestTag)) + .performClick() + waitUntilIdle() + } + + fun scrollSearchFilterToLeft() { + composeTestRule + .onNode(hasTestTag(SearchFilterTestTag)) + .performTouchInput { + swipeLeft( + startX = visibleSize.width.toFloat(), + endX = visibleSize.width / 2f, + ) + } + } + + fun clickFirstDropdownMenuItem() { + composeTestRule + .onAllNodes(hasTestTag(DropdownFilterChipItemTestTag)) + .onFirst() + .performClick() + waitUntilIdle() + } + + fun clickLastDropdownMenuItem() { + composeTestRule + .onAllNodes(hasTestTag(DropdownFilterChipItemTestTag)) + .onLast() + .performClick() + waitUntilIdle() + } + + fun checkScreenCapture() { + composeTestRule + .onNode(isRoot()) + .captureRoboImage() + } + + fun checkSearchScreenCapture() { + composeTestRule + .onNode(hasTestTag(SearchScreenTestTag)) + .captureRoboImage() + } + + fun waitUntilIdle() { + composeTestRule.waitForIdle() + testDispatcher.scheduler.advanceUntilIdle() + } +} diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt index e683de3ed..73b19076b 100644 --- a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/AboutStrings.kt @@ -23,6 +23,7 @@ sealed class AboutStrings : Strings(Bindings) { object License : AboutStrings() object PrivacyPolicy : AboutStrings() object AppVersion : AboutStrings() + object LicenceDescription : AboutStrings() private object Bindings : StringsBindings( Lang.Japanese to { item, _ -> @@ -43,6 +44,7 @@ sealed class AboutStrings : Strings(Bindings) { License -> "ライセンス" PrivacyPolicy -> "プライバシーポリシー" AppVersion -> "アプリバージョン" + LicenceDescription -> "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License." } }, Lang.English to { item, bindings -> @@ -63,6 +65,7 @@ sealed class AboutStrings : Strings(Bindings) { License -> "License" PrivacyPolicy -> "Privacy Policy" AppVersion -> "App Version" + LicenceDescription -> bindings.defaultBinding(item, bindings) } }, default = Lang.Japanese, diff --git a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/component/AboutFooterLinks.kt b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/component/AboutFooterLinks.kt index 0d02a5616..2c7ec580b 100644 --- a/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/component/AboutFooterLinks.kt +++ b/feature/about/src/main/java/io/github/droidkaigi/confsched2023/about/component/AboutFooterLinks.kt @@ -1,5 +1,6 @@ package io.github.droidkaigi.confsched2023.about.component +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +14,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.github.droidkaigi.confsched2023.about.AboutStrings import io.github.droidkaigi.confsched2023.designsystem.preview.MultiLanguagePreviews @@ -24,6 +27,9 @@ const val AboutFooterLinksYouTubeItemTestTag = "AboutFooterLinksYouTubeItem" const val AboutFooterLinksXItemTestTag = "AboutFooterLinksXItem" const val AboutFooterLinksMediumItemTestTag = "AboutFooterLinksMediumItem" +private val licenseDescriptionLight = Color(0xFF6D7256) +private val licenseDescriptionDark = Color(0xFFFFFFFF) + @Composable fun AboutFooterLinks( versionName: String?, @@ -71,6 +77,15 @@ fun AboutFooterLinks( style = MaterialTheme.typography.labelLarge, ) } + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = AboutStrings.LicenceDescription.asString(), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = if (isSystemInDarkTheme()) licenseDescriptionDark else licenseDescriptionLight, + ) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/FloorLevelSwitcher.kt b/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/FloorLevelSwitcher.kt index 3a5620601..839cb5475 100644 --- a/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/FloorLevelSwitcher.kt +++ b/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/FloorLevelSwitcher.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews @@ -108,28 +109,15 @@ private fun FloorLevelSwitcherButton( } } -// TODO Use PreviewParameterProvider to display the Preview once the Linter issue is resolved. -// https://github.com/DroidKaigi/conference-app-2023/pull/557#discussion_r1295780974 @MultiThemePreviews @Composable -fun FloorLevelSwitcherGroundPreview() { - KaigiTheme { - Surface { - FloorLevelSwitcher( - selectingFloorLevel = FloorLevel.Ground, - onClickFloorLevelSwitcher = {}, - ) - } - } -} - -@MultiThemePreviews -@Composable -fun FloorLevelSwitcherBasementPreview() { +internal fun FloorLevelSwitcherPreview( + @PreviewParameter(PreviewFloorMapSwitcherFloorLevelProvider::class) floorLevel: FloorLevel, +) { KaigiTheme { Surface { FloorLevelSwitcher( - selectingFloorLevel = FloorLevel.Basement, + selectingFloorLevel = floorLevel, onClickFloorLevelSwitcher = {}, ) } diff --git a/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/PreviewFloorMapSwitcherFloorLevelProvider.kt b/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/PreviewFloorMapSwitcherFloorLevelProvider.kt new file mode 100644 index 000000000..a2a3121f7 --- /dev/null +++ b/feature/floor-map/src/main/java/io/github/droidkaigi/confsched2023/floormap/component/PreviewFloorMapSwitcherFloorLevelProvider.kt @@ -0,0 +1,9 @@ +package io.github.droidkaigi.confsched2023.floormap.component + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.github.droidkaigi.confsched2023.model.FloorLevel + +class PreviewFloorMapSwitcherFloorLevelProvider : PreviewParameterProvider { + override val values: Sequence + get() = FloorLevel.values().asSequence() +} 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 index 40d4c18e7..422b1de36 100644 --- 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 @@ -25,9 +25,9 @@ import io.github.droidkaigi.confsched2023.model.TimetableSessionType 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.SearchFilter +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState import io.github.droidkaigi.confsched2023.sessions.section.SearchList import io.github.droidkaigi.confsched2023.sessions.section.SearchListUiState @@ -36,16 +36,25 @@ const val SearchScreenTestTag = "SearchScreen" sealed interface SearchScreenUiState { val searchQuery: String - val searchFilterUiState: SearchFilterUiState + val searchFilterDayUiState: SearchFilterUiState + val searchFilterCategoryUiState: SearchFilterUiState + val searchFilterSessionTypeUiState: SearchFilterUiState + val searchFilterLanguageUiState: SearchFilterUiState data class Empty( override val searchQuery: String, - override val searchFilterUiState: SearchFilterUiState, + override val searchFilterDayUiState: SearchFilterUiState, + override val searchFilterCategoryUiState: SearchFilterUiState, + override val searchFilterSessionTypeUiState: SearchFilterUiState, + override val searchFilterLanguageUiState: SearchFilterUiState, ) : SearchScreenUiState data class SearchList( override val searchQuery: String, - override val searchFilterUiState: SearchFilterUiState, + override val searchFilterDayUiState: SearchFilterUiState, + override val searchFilterCategoryUiState: SearchFilterUiState, + override val searchFilterSessionTypeUiState: SearchFilterUiState, + override val searchFilterLanguageUiState: SearchFilterUiState, val searchListUiState: SearchListUiState, ) : SearchScreenUiState } @@ -121,7 +130,10 @@ private fun SearchScreen( color = MaterialTheme.colorScheme.outline, ) SearchFilter( - searchFilterUiState = uiState.searchFilterUiState, + searchFilterDayUiState = uiState.searchFilterDayUiState, + searchFilterCategoryUiState = uiState.searchFilterCategoryUiState, + searchFilterSessionTypeUiState = uiState.searchFilterSessionTypeUiState, + searchFilterLanguageUiState = uiState.searchFilterLanguageUiState, onDaySelected = onDaySelected, onCategoriesSelected = onCategoriesSelected, onSessionTypesSelected = onSessionTypesSelected, 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 index 075058954..e263c0020 100644 --- 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 @@ -13,11 +13,12 @@ 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.TimetableSessionType -import io.github.droidkaigi.confsched2023.sessions.component.SearchFilterUiState +import io.github.droidkaigi.confsched2023.sessions.section.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.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -68,24 +69,20 @@ class SearchScreenViewModel @Inject constructor( if (searchedSessions.isEmpty()) { SearchScreenUiState.Empty( searchQuery = searchQuery, - searchFilterUiState = SearchFilterUiState( - selectedDays = filters.days, - selectedCategories = filters.categories, - selectedSessionTypes = filters.sessionTypes, - selectedLanguages = filters.languages, - ), + searchFilterDayUiState = searchFilterDayUiState(filters.days), + searchFilterCategoryUiState = searchFilterCategoryUiState(filters.categories), + searchFilterSessionTypeUiState = searchFilterSessionTypeUiState(filters.sessionTypes), + searchFilterLanguageUiState = searchFilterLanguageUiState(filters.languages), ) } else { SearchScreenUiState.SearchList( searchQuery = searchQuery, - searchFilterUiState = SearchFilterUiState( - categories = sessions.categories, - sessionTypes = sessions.sessionTypes, - selectedDays = filters.days, - selectedCategories = filters.categories, - selectedSessionTypes = filters.sessionTypes, - selectedLanguages = filters.languages, - ), + searchFilterDayUiState = searchFilterDayUiState(filters.days), + searchFilterCategoryUiState = + searchFilterCategoryUiState(filters.categories, sessions.categories), + searchFilterSessionTypeUiState = + searchFilterSessionTypeUiState(filters.sessionTypes, sessions.sessionTypes), + searchFilterLanguageUiState = searchFilterLanguageUiState(filters.languages), searchListUiState = SearchListUiState( bookmarkedTimetableItemIds = sessions.bookmarks, timetableItems = searchedSessions.timetableItems, @@ -94,6 +91,52 @@ class SearchScreenViewModel @Inject constructor( } } + private fun searchFilterDayUiState( + selectedDays: List, + ): SearchFilterUiState { + return SearchFilterUiState( + selectedItems = selectedDays.toImmutableList(), + items = DroidKaigi2023Day.entries.toImmutableList(), + isSelected = selectedDays.isNotEmpty(), + selectedValues = selectedDays.joinToString { it.name }, + ) + } + + private fun searchFilterCategoryUiState( + selectedCategories: List, + categories: List? = null, + ): SearchFilterUiState { + return SearchFilterUiState( + selectedItems = selectedCategories.toImmutableList(), + items = categories.orEmpty().toImmutableList(), + isSelected = selectedCategories.isNotEmpty(), + selectedValues = selectedCategories.joinToString { it.title.currentLangTitle }, + ) + } + + private fun searchFilterSessionTypeUiState( + selectedSessionTypes: List, + sessionTypes: List? = null, + ): SearchFilterUiState { + return SearchFilterUiState( + selectedItems = selectedSessionTypes.toImmutableList(), + items = sessionTypes.orEmpty().toImmutableList(), + isSelected = selectedSessionTypes.isNotEmpty(), + selectedValues = selectedSessionTypes.joinToString { it.label.currentLangTitle }, + ) + } + + private fun searchFilterLanguageUiState( + selectedLanguages: List, + ): SearchFilterUiState { + return SearchFilterUiState( + selectedItems = selectedLanguages.toImmutableList(), + items = listOf(Lang.JAPANESE, Lang.ENGLISH).toImmutableList(), + isSelected = selectedLanguages.isNotEmpty(), + selectedValues = selectedLanguages.joinToString { it.tagName }, + ) + } + fun onSearchQueryChanged(searchQuery: String) { savedStateHandle[SEARCH_QUERY] = searchQuery } 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 2ce03dfc9..6f5b6ef58 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 @@ -37,6 +37,7 @@ import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day import io.github.droidkaigi.confsched2023.model.Timetable import io.github.droidkaigi.confsched2023.model.TimetableItem +import io.github.droidkaigi.confsched2023.model.TimetableUiType import io.github.droidkaigi.confsched2023.sessions.component.TimetableTopArea import io.github.droidkaigi.confsched2023.sessions.component.rememberTimetableScreenScrollState import io.github.droidkaigi.confsched2023.sessions.section.TimetableHeader @@ -102,6 +103,7 @@ fun TimetableScreen( data class TimetableScreenUiState( val contentUiState: TimetableSheetUiState, + val timetableUiType: TimetableUiType, ) private val timetableTopBackgroundLight = Color(0xFFF6FFD3) @@ -165,6 +167,7 @@ private fun TimetableScreen( }, topBar = { TimetableTopArea( + timetableUiType = uiState.timetableUiType, onTimetableUiChangeClick, onSearchClick, onBookmarkIconClick, @@ -223,6 +226,7 @@ fun PreviewTimetableScreenDark() { ), ), ), + TimetableUiType.Grid, ), SnackbarHostState(), {}, diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreenViewModel.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreenViewModel.kt index 6c4da2871..00a6e59cb 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreenViewModel.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/TimetableScreenViewModel.kt @@ -87,6 +87,7 @@ class TimetableScreenViewModel @Inject constructor( ) { sessionListUiState -> TimetableScreenUiState( contentUiState = sessionListUiState, + timetableUiType = timetableUiTypeStateFlow.value, ) } diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/BookmarkFilters.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/BookmarkFilters.kt index 624c0f971..51821248b 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/BookmarkFilters.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/BookmarkFilters.kt @@ -1,8 +1,11 @@ package io.github.droidkaigi.confsched2023.sessions.component +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api @@ -34,7 +37,11 @@ fun BookmarkFilters( onDayThirdChipClick: () -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier) { + Row( + modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { BookmarkFilterChip( labelText = SessionsStrings.BookmarkFilterAllChip.asString(), isSelected = isAll, diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/DropdownFilterChip.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/DropdownFilterChip.kt new file mode 100644 index 000000000..1179d1303 --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/DropdownFilterChip.kt @@ -0,0 +1,143 @@ +package io.github.droidkaigi.confsched2023.sessions.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState + +const val DropdownFilterChipItemTestTag = "DropdownFilterChipItem" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownFilterChip( + searchFilterUiState: SearchFilterUiState, + onSelected: (T, Boolean) -> Unit, + filterChipLabel: @Composable () -> Unit, + dropdownMenuItemText: @Composable (T) -> Unit, + modifier: Modifier = Modifier, + filterChipLeadingIcon: @Composable (() -> Unit)? = null, + filterChipTrailingIcon: @Composable (() -> Unit)? = null, + onFilterChipClick: (() -> Unit)? = null, + dropdownMenuItemLeadingIcon: @Composable ((T) -> Unit)? = null, + dropdownMenuItemTrailingIcon: @Composable ((T) -> Unit)? = null, +) { + var expanded by remember { mutableStateOf(false) } + val onSelectedUpdated by rememberUpdatedState(newValue = onSelected) + + val expandMenu = { expanded = true } + val shrinkMenu = { expanded = false } + + val selectedItems = searchFilterUiState.selectedItems + + Box( + modifier = modifier.wrapContentSize(Alignment.TopStart), + ) { + FilterChip( + selected = searchFilterUiState.isSelected, + onClick = { + onFilterChipClick?.invoke() + expandMenu() + }, + label = filterChipLabel, + leadingIcon = filterChipLeadingIcon, + trailingIcon = filterChipTrailingIcon, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = shrinkMenu, + ) { + searchFilterUiState.items.forEach { item -> + DropdownMenuItem( + text = { + dropdownMenuItemText(item) + }, + leadingIcon = dropdownMenuItemLeadingIcon?.let { icon -> + { + icon(item) + } + }, + trailingIcon = dropdownMenuItemTrailingIcon?.let { icon -> + { + icon(item) + } + }, + onClick = { + onSelectedUpdated( + item, + selectedItems.contains(item).not(), + ) + shrinkMenu() + }, + modifier = Modifier.testTag(DropdownFilterChipItemTestTag), + ) + } + } + } +} + +@Composable +fun DropdownFilterChip( + searchFilterUiState: SearchFilterUiState, + onSelected: (T, Boolean) -> Unit, + filterChipLabelDefaultText: String, + dropdownMenuItemText: (T) -> String, + modifier: Modifier = Modifier, + onFilterChipClick: (() -> Unit)? = null, +) { + DropdownFilterChip( + modifier = modifier, + searchFilterUiState = searchFilterUiState, + onSelected = onSelected, + filterChipLabel = { + Text( + text = searchFilterUiState.selectedValues.ifEmpty { + filterChipLabelDefaultText + }, + ) + }, + filterChipTrailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + ) + }, + onFilterChipClick = onFilterChipClick, + dropdownMenuItemText = { item -> + Text( + text = dropdownMenuItemText(item), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + dropdownMenuItemLeadingIcon = { item -> + if (searchFilterUiState.selectedItems.contains(item)) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) +} 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 index afc2f78d4..d778b9d85 100644 --- 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 @@ -1,94 +1,72 @@ 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 androidx.compose.ui.platform.testTag +import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.model.TimetableCategory +import io.github.droidkaigi.confsched2023.model.fakes import io.github.droidkaigi.confsched2023.sessions.SessionsStrings -import kotlinx.collections.immutable.ImmutableList +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState +import kotlinx.collections.immutable.toImmutableList + +const val FilterCategoryChipTestTag = "FilterCategoryChip" -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilterCategoryChip( - selectedCategories: ImmutableList, - categories: ImmutableList, + searchFilterUiState: SearchFilterUiState, 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) + DropdownFilterChip( + searchFilterUiState = searchFilterUiState, + onSelected = onCategoriesSelected, + filterChipLabelDefaultText = SessionsStrings.Category.asString(), + onFilterChipClick = onFilterCategoryChipClicked, + dropdownMenuItemText = { category -> + category.title.currentLangTitle + }, + modifier = modifier.testTag(FilterCategoryChipTestTag), + ) +} - 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, +@MultiThemePreviews +@Composable +fun PreviewFilterCategoryChip() { + var uiState by remember { + mutableStateOf( + SearchFilterUiState( + selectedItems = emptyList().toImmutableList(), + items = TimetableCategory.fakes().toImmutableList(), + ), + ) + } + + KaigiTheme { + FilterCategoryChip( + searchFilterUiState = uiState, + onCategoriesSelected = { category, isSelected -> + val selectedCategories = uiState.selectedItems.toMutableList() + val newSelectedCategories = selectedCategories.apply { + if (isSelected) { + add(category) + } else { + remove(category) + } + } + uiState = uiState.copy( + selectedItems = newSelectedCategories.toImmutableList(), + isSelected = newSelectedCategories.isNotEmpty(), + selectedValues = newSelectedCategories.joinToString { it.title.currentLangTitle }, ) }, + onFilterCategoryChipClicked = {}, ) - 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 index 6bcc0ecfd..bc2aa5340 100644 --- 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 @@ -1,91 +1,69 @@ 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 androidx.compose.ui.platform.testTag +import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day import io.github.droidkaigi.confsched2023.sessions.SessionsStrings -import kotlinx.collections.immutable.ImmutableList +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState +import kotlinx.collections.immutable.toImmutableList import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) +const val FilterDayChipTestTag = "FilterDayChip" + @Composable fun FilterDayChip( - selectedDays: ImmutableList, - kaigiDays: ImmutableList, + searchFilterUiState: SearchFilterUiState, onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit, modifier: Modifier = Modifier, - isSelected: Boolean = false, - selectedDaysValues: String = "", ) { - var expanded by remember { mutableStateOf(false) } - val onDaySelectedUpdated by rememberUpdatedState(newValue = onDaySelected) + DropdownFilterChip( + searchFilterUiState = searchFilterUiState, + onSelected = onDaySelected, + filterChipLabelDefaultText = SessionsStrings.EventDay.asString(), + dropdownMenuItemText = { kaigiDay -> + kaigiDay.getDropDownText(Locale.getDefault().language) + }, + modifier = modifier.testTag(FilterDayChipTestTag), + ) +} - 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, +@MultiThemePreviews +@Composable +fun PreviewFilterDayChip() { + var uiState by remember { + mutableStateOf( + SearchFilterUiState( + selectedItems = emptyList().toImmutableList(), + items = DroidKaigi2023Day.entries.toImmutableList(), + ), + ) + } + + KaigiTheme { + FilterDayChip( + searchFilterUiState = uiState, + onDaySelected = { kaigiDay, isSelected -> + val selectedDays = uiState.selectedItems.toMutableList() + val newSelectedDays = selectedDays.apply { + if (isSelected) { + add(kaigiDay) + } else { + remove(kaigiDay) + } + }.sortedBy(DroidKaigi2023Day::start) + uiState = uiState.copy( + selectedItems = newSelectedDays.toImmutableList(), + isSelected = newSelectedDays.isNotEmpty(), + selectedValues = newSelectedDays.joinToString { it.name }, ) }, ) - 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/FilterLanguageChip.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterLanguageChip.kt index 09e839230..4eb4b3e9d 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterLanguageChip.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterLanguageChip.kt @@ -1,94 +1,73 @@ 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 androidx.compose.ui.platform.testTag +import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.model.Lang +import io.github.droidkaigi.confsched2023.model.Lang.ENGLISH +import io.github.droidkaigi.confsched2023.model.Lang.JAPANESE import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.SupportedLanguages -import kotlinx.collections.immutable.ImmutableList +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState +import kotlinx.collections.immutable.toImmutableList + +const val FilterLanguageChipTestTag = "FilterLanguageChip" -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilterLanguageChip( - selectedLanguages: ImmutableList, - languages: ImmutableList, + searchFilterUiState: SearchFilterUiState, onLanguagesSelected: (Lang, Boolean) -> Unit, modifier: Modifier = Modifier, - isSelected: Boolean = false, - selectedLanguagesValues: String = "", onFilterLanguageChipClicked: () -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val onLanguagesSelectedUpdated by rememberUpdatedState(newValue = onLanguagesSelected) + DropdownFilterChip( + searchFilterUiState = searchFilterUiState, + onSelected = onLanguagesSelected, + filterChipLabelDefaultText = SupportedLanguages.asString(), + onFilterChipClick = onFilterLanguageChipClicked, + dropdownMenuItemText = { language -> + language.tagName + }, + modifier = modifier.testTag(FilterLanguageChipTestTag), + ) +} - Box( - modifier = modifier, - ) { - FilterChip( - selected = isSelected, - onClick = { - onFilterLanguageChipClicked() - expanded = true - }, - label = { Text(text = selectedLanguagesValues.ifEmpty { SupportedLanguages.asString() }) }, - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, +@MultiThemePreviews +@Composable +fun PreviewFilterLanguageChip() { + var uiState by remember { + mutableStateOf( + SearchFilterUiState( + selectedItems = emptyList().toImmutableList(), + items = listOf(JAPANESE, ENGLISH).toImmutableList(), + ), + ) + } + + KaigiTheme { + FilterLanguageChip( + searchFilterUiState = uiState, + onLanguagesSelected = { language, isSelected -> + val selectedLanguages = uiState.selectedItems.toMutableList() + val newSelectedLanguages = selectedLanguages.apply { + if (isSelected) { + add(language) + } else { + remove(language) + } + } + uiState = uiState.copy( + selectedItems = newSelectedLanguages.toImmutableList(), + isSelected = newSelectedLanguages.isNotEmpty(), + selectedValues = newSelectedLanguages.joinToString { it.tagName }, ) }, + onFilterLanguageChipClicked = {}, ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - languages.forEach { language -> - DropdownMenuItem( - text = { - Text( - text = language.tagName, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingIcon = { - if (selectedLanguages.contains(language)) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onLanguagesSelectedUpdated( - language, - selectedLanguages - .contains(language) - .not(), - ) - expanded = false - }, - ) - } - } } } diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterSessionTypeChip.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterSessionTypeChip.kt index 31aa8cbae..937ed4529 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterSessionTypeChip.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/FilterSessionTypeChip.kt @@ -1,94 +1,71 @@ 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 androidx.compose.ui.platform.testTag +import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews +import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.model.TimetableSessionType import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.SessionType -import kotlinx.collections.immutable.ImmutableList +import io.github.droidkaigi.confsched2023.sessions.section.SearchFilterUiState +import kotlinx.collections.immutable.toImmutableList + +const val FilterSessionTypeChipTestTag = "FilterSessionTypeChip" -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilterSessionTypeChip( - selectedSessionTypes: ImmutableList, - sessionTypes: ImmutableList, + searchFilterUiState: SearchFilterUiState, onSessionTypeSelected: (TimetableSessionType, Boolean) -> Unit, modifier: Modifier = Modifier, - isSelected: Boolean = false, - selectedSessionTypesValues: String = "", onFilterSessionTypeChipClicked: () -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val onLanguagesSelectedUpdated by rememberUpdatedState(newValue = onSessionTypeSelected) + DropdownFilterChip( + searchFilterUiState = searchFilterUiState, + onSelected = onSessionTypeSelected, + filterChipLabelDefaultText = SessionType.asString(), + onFilterChipClick = onFilterSessionTypeChipClicked, + dropdownMenuItemText = { sessionType -> + sessionType.label.currentLangTitle + }, + modifier = modifier.testTag(FilterSessionTypeChipTestTag), + ) +} - Box( - modifier = modifier, - ) { - FilterChip( - selected = isSelected, - onClick = { - onFilterSessionTypeChipClicked() - expanded = true - }, - label = { Text(text = selectedSessionTypesValues.ifEmpty { SessionType.asString() }) }, - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, +@MultiThemePreviews +@Composable +fun PreviewFilterSessionTypeChip() { + var uiState by remember { + mutableStateOf( + SearchFilterUiState( + selectedItems = emptyList().toImmutableList(), + items = TimetableSessionType.entries.toImmutableList(), + ), + ) + } + + KaigiTheme { + FilterSessionTypeChip( + searchFilterUiState = uiState, + onSessionTypeSelected = { sessionType, isSelected -> + val selectedSessionTypes = uiState.selectedItems.toMutableList() + val newSelectedSessionTypes = selectedSessionTypes.apply { + if (isSelected) { + add(sessionType) + } else { + remove(sessionType) + } + } + uiState = uiState.copy( + selectedItems = newSelectedSessionTypes.toImmutableList(), + isSelected = newSelectedSessionTypes.isNotEmpty(), + selectedValues = newSelectedSessionTypes.joinToString { it.label.currentLangTitle }, ) }, + onFilterSessionTypeChipClicked = {}, ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - sessionTypes.forEach { sessionType -> - DropdownMenuItem( - text = { - Text( - text = sessionType.label.currentLangTitle, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingIcon = { - if (selectedSessionTypes.contains(sessionType)) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onLanguagesSelectedUpdated( - sessionType, - selectedSessionTypes - .contains(sessionType) - .not(), - ) - expanded = false - }, - ) - } - } } } diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/PreviewTimeTableItemRoomProvider.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/PreviewTimeTableItemRoomProvider.kt index 605ae947d..500b58ad8 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/PreviewTimeTableItemRoomProvider.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/PreviewTimeTableItemRoomProvider.kt @@ -18,6 +18,7 @@ class PreviewTimeTableItemRoomProvider : PreviewParameterProvider Session.fake().copy(room = Session.fake().room.copy(type = RoomA)), Session.fake().copy(room = Session.fake().room.copy(type = RoomB)), Session.fake().copy(room = Session.fake().room.copy(type = RoomD)), + Session.fake().copy(speakers = persistentListOf(Session.fake().speakers.first())), Session.fake().copy(speakers = persistentListOf()), ) } 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 deleted file mode 100644 index 4205a9d12..000000000 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/SearchFilter.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.github.droidkaigi.confsched2023.sessions.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.unit.dp -import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day -import io.github.droidkaigi.confsched2023.model.Lang -import io.github.droidkaigi.confsched2023.model.TimetableCategory -import io.github.droidkaigi.confsched2023.model.TimetableSessionType -import kotlinx.collections.immutable.toImmutableList - -data class SearchFilterUiState( - val categories: List = emptyList(), - val sessionTypes: List = emptyList(), - val selectedDays: List = emptyList(), - val selectedCategories: List = emptyList(), - val selectedSessionTypes: List = emptyList(), - val selectedLanguages: 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 selectedSessionTypesValue: String - get() = selectedSessionTypes.joinToString { it.label.currentLangTitle } - - val selectedLanguagesValue: String - get() = selectedLanguages.joinToString { it.tagName } - - val isCategoriesSelected: Boolean - get() = selectedCategories.isNotEmpty() - - val isLanguagesSelected: Boolean - get() = selectedLanguages.isNotEmpty() - - val isSessionTypeSelected: Boolean - get() = selectedSessionTypes.isNotEmpty() -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun SearchFilter( - searchFilterUiState: SearchFilterUiState, - modifier: Modifier = Modifier, - onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit = { _, _ -> }, - onCategoriesSelected: (TimetableCategory, Boolean) -> Unit = { _, _ -> }, - onSessionTypesSelected: (TimetableSessionType, Boolean) -> Unit = { _, _ -> }, - onLanguagesSelected: (Lang, Boolean) -> Unit = { _, _ -> }, -) { - val keyboardController = LocalSoftwareKeyboardController.current - - LazyRow( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - FilterDayChip( - isSelected = searchFilterUiState.isDaySelected, - selectedDays = searchFilterUiState.selectedDays.toImmutableList(), - selectedDaysValues = searchFilterUiState.selectedDaysValues, - kaigiDays = DroidKaigi2023Day.entries.toImmutableList(), - onDaySelected = onDaySelected, - ) - } - item { - FilterCategoryChip( - isSelected = searchFilterUiState.isCategoriesSelected, - selectedCategories = searchFilterUiState.selectedCategories.toImmutableList(), - selectedCategoriesValues = searchFilterUiState.selectedCategoriesValue, - categories = searchFilterUiState.categories.toImmutableList(), - onCategoriesSelected = onCategoriesSelected, - onFilterCategoryChipClicked = { keyboardController?.hide() }, - ) - } - item { - FilterSessionTypeChip( - isSelected = searchFilterUiState.isSessionTypeSelected, - selectedSessionTypes = searchFilterUiState.selectedSessionTypes.toImmutableList(), - selectedSessionTypesValues = searchFilterUiState.selectedSessionTypesValue, - sessionTypes = searchFilterUiState.sessionTypes.toImmutableList(), - onSessionTypeSelected = onSessionTypesSelected, - onFilterSessionTypeChipClicked = { keyboardController?.hide() }, - ) - } - item { - FilterLanguageChip( - isSelected = searchFilterUiState.isLanguagesSelected, - selectedLanguages = searchFilterUiState.selectedLanguages.toImmutableList(), - selectedLanguagesValues = searchFilterUiState.selectedLanguagesValue, - languages = listOf(Lang.JAPANESE, Lang.ENGLISH).toImmutableList(), - onLanguagesSelected = onLanguagesSelected, - onFilterLanguageChipClicked = { keyboardController?.hide() }, - ) - } - } -} diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt index 9b5e2f64c..be7beb8c3 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt @@ -1,8 +1,11 @@ package io.github.droidkaigi.confsched2023.sessions.component +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -23,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle @@ -35,6 +39,7 @@ import io.github.droidkaigi.confsched2023.designsystem.preview.MultiLanguagePrev import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched2023.designsystem.theme.hallColors +import io.github.droidkaigi.confsched2023.designsystem.theme.md_theme_light_outline import io.github.droidkaigi.confsched2023.model.TimetableItem import io.github.droidkaigi.confsched2023.model.TimetableItem.Session import io.github.droidkaigi.confsched2023.model.TimetableSpeaker @@ -44,6 +49,8 @@ import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.UserIcon import io.github.droidkaigi.confsched2023.sessions.section.TimetableSizes import io.github.droidkaigi.confsched2023.ui.previewOverride import io.github.droidkaigi.confsched2023.ui.rememberAsyncImagePainter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.minus import kotlin.math.ceil @@ -61,6 +68,7 @@ fun TimetableGridItem( val localDensity = LocalDensity.current val speaker = timetableItem.speakers.firstOrNull() + val speakers = timetableItem.speakers val hallColor = hallColors() val backgroundColor = timetableItem.room.color @@ -86,10 +94,10 @@ fun TimetableGridItem( Box( modifier = modifier .background( - color = if (speaker != null) { - backgroundColor - } else { + color = if (speakers.isEmpty()) { MaterialTheme.colorScheme.surfaceVariant + } else { + backgroundColor }, shape = RoundedCornerShape(4.dp), ) @@ -130,31 +138,70 @@ fun TimetableGridItem( .defaultMinSize(minHeight = 8.dp), ) - // TODO: Dealing with more than one speaker - if (speaker != null) { - Row( - modifier = Modifier.height(TimetableGridItemSizes.speakerHeight), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - painter = previewOverride(previewPainter = { rememberVectorPainter(image = Icons.Default.Person) }) { - rememberAsyncImagePainter(speaker.iconUrl) - }, - contentDescription = UserIcon.asString(), - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = speaker.name, - style = MaterialTheme.typography.labelMedium, - color = textColor, - ) - } + when (speakers.size) { + 0 -> Unit + 1 -> SingleSpeaker(speaker = speakers.first(), textColor = textColor) + else -> MultiSpeakers(speakers = speakers) } } } } +@Composable +private fun SingleSpeaker( + speaker: TimetableSpeaker, + textColor: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.height(TimetableGridItemSizes.speakerHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + SpeakerIcon(iconUrl = speaker.iconUrl) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = speaker.name, + style = MaterialTheme.typography.labelMedium, + color = textColor, + ) + } +} + +@Composable +private fun MultiSpeakers( + speakers: PersistentList, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.height(TimetableGridItemSizes.speakerHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + speakers.forEach { speaker -> + SpeakerIcon(speaker.iconUrl) + } + } +} + +@Composable +private fun SpeakerIcon( + iconUrl: String, + modifier: Modifier = Modifier, +) { + Image( + painter = previewOverride(previewPainter = { rememberVectorPainter(image = Icons.Default.Person) }) { + rememberAsyncImagePainter(iconUrl) + }, + contentDescription = UserIcon.asString(), + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .border( + BorderStroke(1.dp, md_theme_light_outline), + RoundedCornerShape(8.dp), + ), + ) +} + /** * * Calculate the font size and line height of the title by the height of the session grid item. @@ -301,7 +348,8 @@ fun PreviewTimetableGridItem() { KaigiTheme { Surface { TimetableGridItem( - timetableItem = Session.fake(), + timetableItem = Session.fake() + .copy(speakers = persistentListOf(Session.fake().speakers.first())), onTimetableItemClick = {}, gridItemHeightPx = 350, ) @@ -329,7 +377,7 @@ fun PreviewTimetableGridLongTitleItem() { jaTitle = it.title.jaTitle.repeat(2), enTitle = it.title.enTitle.repeat(2), ) - it.copy(title = longTitle) + it.copy(title = longTitle, speakers = persistentListOf(it.speakers.first())) }, onTimetableItemClick = {}, gridItemHeightPx = height, @@ -338,6 +386,20 @@ fun PreviewTimetableGridLongTitleItem() { } } +@MultiThemePreviews +@Composable +fun PreviewTimetableGridMultiSpeakersItem() { + KaigiTheme { + Surface { + TimetableGridItem( + timetableItem = Session.fake(), + onTimetableItemClick = {}, + gridItemHeightPx = 350, + ) + } + } +} + @MultiThemePreviews @Composable internal fun PreviewTimetableGridItem( 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 d20be8eeb..0f231428c 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 @@ -1,9 +1,10 @@ package io.github.droidkaigi.confsched2023.sessions.component import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Bookmarks +import androidx.compose.material.icons.outlined.GridView +import androidx.compose.material.icons.outlined.ViewTimeline import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -15,6 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import io.github.droidkaigi.confsched2023.feature.sessions.R +import io.github.droidkaigi.confsched2023.model.TimetableUiType import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.Bookmark import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.Search import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.Timetable @@ -26,6 +28,7 @@ const val TimetableBookmarksIconTestTag = "TimetableBookmarksIconTestTag" @Composable @OptIn(ExperimentalMaterial3Api::class) fun TimetableTopArea( + timetableUiType: TimetableUiType, onTimetableUiChangeClick: () -> Unit, onSearchClick: () -> Unit, onTopAreaBookmarkIconClick: () -> Unit, @@ -63,7 +66,11 @@ fun TimetableTopArea( onClick = { onTimetableUiChangeClick() }, ) { Icon( - imageVector = Icons.Default.GridView, + imageVector = if (timetableUiType != TimetableUiType.Grid) { + Icons.Outlined.GridView + } else { + Icons.Outlined.ViewTimeline + }, contentDescription = Timetable.asString(), ) } diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/BookmarkSheet.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/BookmarkSheet.kt index 75073b7c1..357123266 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/BookmarkSheet.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/BookmarkSheet.kt @@ -7,7 +7,6 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -84,7 +83,7 @@ fun BookmarkSheet( onDayFirstChipClick = onDayFirstChipClick, onDaySecondChipClick = onDaySecondChipClick, onDayThirdChipClick = onDayThirdChipClick, - modifier = Modifier.padding(start = 16.dp), + modifier = Modifier, ) when (uiState) { is Empty -> { diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/SearchFilter.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/SearchFilter.kt new file mode 100644 index 000000000..6c07c78a0 --- /dev/null +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/SearchFilter.kt @@ -0,0 +1,79 @@ +package io.github.droidkaigi.confsched2023.sessions.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched2023.model.DroidKaigi2023Day +import io.github.droidkaigi.confsched2023.model.Lang +import io.github.droidkaigi.confsched2023.model.TimetableCategory +import io.github.droidkaigi.confsched2023.model.TimetableSessionType +import io.github.droidkaigi.confsched2023.sessions.component.FilterCategoryChip +import io.github.droidkaigi.confsched2023.sessions.component.FilterDayChip +import io.github.droidkaigi.confsched2023.sessions.component.FilterLanguageChip +import io.github.droidkaigi.confsched2023.sessions.component.FilterSessionTypeChip +import kotlinx.collections.immutable.ImmutableList + +data class SearchFilterUiState( + val selectedItems: ImmutableList, + val items: ImmutableList, + val isSelected: Boolean = false, + val selectedValues: String = "", +) + +const val SearchFilterTestTag = "SearchFilter" + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SearchFilter( + searchFilterDayUiState: SearchFilterUiState, + searchFilterCategoryUiState: SearchFilterUiState, + searchFilterSessionTypeUiState: SearchFilterUiState, + searchFilterLanguageUiState: SearchFilterUiState, + modifier: Modifier = Modifier, + onDaySelected: (DroidKaigi2023Day, Boolean) -> Unit = { _, _ -> }, + onCategoriesSelected: (TimetableCategory, Boolean) -> Unit = { _, _ -> }, + onSessionTypesSelected: (TimetableSessionType, Boolean) -> Unit = { _, _ -> }, + onLanguagesSelected: (Lang, Boolean) -> Unit = { _, _ -> }, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + LazyRow( + modifier = modifier.testTag(SearchFilterTestTag), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { + FilterDayChip( + searchFilterUiState = searchFilterDayUiState, + onDaySelected = onDaySelected, + ) + } + item { + FilterCategoryChip( + searchFilterUiState = searchFilterCategoryUiState, + onCategoriesSelected = onCategoriesSelected, + onFilterCategoryChipClicked = { keyboardController?.hide() }, + ) + } + item { + FilterSessionTypeChip( + searchFilterUiState = searchFilterSessionTypeUiState, + onSessionTypeSelected = onSessionTypesSelected, + onFilterSessionTypeChipClicked = { keyboardController?.hide() }, + ) + } + item { + FilterLanguageChip( + searchFilterUiState = searchFilterLanguageUiState, + onLanguagesSelected = onLanguagesSelected, + onFilterLanguageChipClicked = { keyboardController?.hide() }, + ) + } + } +} diff --git a/feature/sessions/src/test/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenTest.kt b/feature/sessions/src/test/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenTest.kt new file mode 100644 index 000000000..5d3e971db --- /dev/null +++ b/feature/sessions/src/test/java/io/github/droidkaigi/confsched2023/sessions/SearchScreenTest.kt @@ -0,0 +1,106 @@ +package io.github.droidkaigi.confsched2023.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.droidkaigi.confsched2023.sessions.component.FilterCategoryChipTestTag +import io.github.droidkaigi.confsched2023.sessions.component.FilterDayChipTestTag +import io.github.droidkaigi.confsched2023.sessions.component.FilterLanguageChipTestTag +import io.github.droidkaigi.confsched2023.sessions.component.FilterSessionTypeChipTestTag +import io.github.droidkaigi.confsched2023.testing.HiltTestActivity +import io.github.droidkaigi.confsched2023.testing.RobotTestRule +import io.github.droidkaigi.confsched2023.testing.category.ScreenshotTests +import io.github.droidkaigi.confsched2023.testing.robot.SearchScreenRobot +import org.junit.Rule +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import javax.inject.Inject + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@HiltAndroidTest +@Config( + qualifiers = RobolectricDeviceQualifiers.NexusOne, +) +class SearchScreenTest { + + @get:Rule + @BindValue val robotTestRule: RobotTestRule = RobotTestRule(this) + + @Inject + lateinit var searchScreenRobot: SearchScreenRobot + + @Test + @Category(ScreenshotTests::class) + fun checkLaunchShot() { + searchScreenRobot { + setupSearchScreenContent() + checkScreenCapture() + } + } + + @Test + @Category(ScreenshotTests::class) + fun checkFilterDayChipShot() { + searchScreenRobot { + checkFilterChipShot(FilterDayChipTestTag) + } + } + + @Test + @Category(ScreenshotTests::class) + fun checkFilterCategoryChipShot() { + searchScreenRobot { + checkFilterChipShot(FilterCategoryChipTestTag) + } + } + + @Test + @Category(ScreenshotTests::class) + fun checkFilterSessionTypeChipShot() { + searchScreenRobot { + checkFilterChipShot(FilterSessionTypeChipTestTag) { + scrollSearchFilterToLeft() + } + } + } + + @Test + @Category(ScreenshotTests::class) + fun checkFilterLanguageChipShot() { + searchScreenRobot { + checkFilterChipShot(FilterLanguageChipTestTag) { + scrollSearchFilterToLeft() + } + } + } + + private fun SearchScreenRobot.checkFilterChipShot( + filterChipTestTag: String, + scrollToLeft: (() -> Unit)? = null, + ) { + setupSearchScreenContent() + scrollToLeft?.invoke() + + // select item + clickFilterChip(filterChipTestTag) + clickFirstDropdownMenuItem() + checkSearchScreenCapture() + + // select other item + clickFilterChip(filterChipTestTag) + clickLastDropdownMenuItem() + checkSearchScreenCapture() + + // remove all items + clickFilterChip(filterChipTestTag) + clickFirstDropdownMenuItem() + clickFilterChip(filterChipTestTag) + clickLastDropdownMenuItem() + checkSearchScreenCapture() + } +}