From fbbfb8008d74866482df20e57ac9a5c47a1a4efc Mon Sep 17 00:00:00 2001 From: MasenWen <12411610@mail.sustech.edu.cn> Date: Sun, 17 May 2026 15:57:04 +0800 Subject: [PATCH] feat(search): add year and season filters --- .../subject/SubjectSearchRepository.kt | 40 +++- .../domain/search/SubjectSearchQuery.kt | 3 +- .../kotlin/ui/main/SearchViewModel.kt | 3 + .../ui/exploration/search/SearchFilter.kt | 173 ++++++++++++++++++ .../ui/exploration/search/SearchPage.kt | 34 ++++ 5 files changed, 247 insertions(+), 6 deletions(-) diff --git a/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt b/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt index c9c90858da..406053cf56 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/repository/subject/SubjectSearchRepository.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import me.him188.ani.app.data.models.schedule.AnimeSeasonId -import me.him188.ani.app.data.models.schedule.yearMonths import me.him188.ani.app.data.network.AniSubjectSearchService import me.him188.ani.app.data.network.BangumiSearchFilters import me.him188.ani.app.data.network.BangumiSubjectSearchService @@ -131,17 +130,23 @@ class SubjectSearchRepository( private fun SubjectSearchQuery.toBangumiSearchFilters(): BangumiSearchFilters { return BangumiSearchFilters( tags, - airDates = season?.toBangumiAirDates(), + airDates = season?.toBangumiAirDates() ?: year?.toBangumiAirDates(), ratings = rating?.toBangumiRatings(), nsfw = nsfw, ) } private fun AnimeSeasonId.toBangumiAirDates(): List { - val (begin, _, end) = this.yearMonths return listOf( - ">=${begin.first}-${begin.second}-01", - "<${end.first}-${end.second}-31", + ">=${year}-${season.searchStartMonth()}-01", + "<${season.nextSeasonStartYear(year)}-${season.nextSeasonStartMonth()}-01", + ) + } + + private fun Int.toBangumiAirDates(): List { + return listOf( + ">=$this-01-01", + "<${this + 1}-01-01", ) } @@ -153,6 +158,31 @@ class SubjectSearchRepository( ) } + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.searchStartMonth(): String { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.WINTER -> "01" + me.him188.ani.app.data.models.schedule.AnimeSeason.SPRING -> "04" + me.him188.ani.app.data.models.schedule.AnimeSeason.SUMMER -> "07" + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> "10" + } + } + + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.nextSeasonStartMonth(): String { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.WINTER -> "04" + me.him188.ani.app.data.models.schedule.AnimeSeason.SPRING -> "07" + me.him188.ani.app.data.models.schedule.AnimeSeason.SUMMER -> "10" + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> "01" + } + } + + private fun me.him188.ani.app.data.models.schedule.AnimeSeason.nextSeasonStartYear(year: Int): Int { + return when (this) { + me.him188.ani.app.data.models.schedule.AnimeSeason.AUTUMN -> year + 1 + else -> year + } + } + /** * 将数据过滤从View提升到分页层,不然会导致 #2380 */ diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt b/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt index ba1ee0c74a..44fd458da6 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/search/SubjectSearchQuery.kt @@ -16,6 +16,7 @@ data class SubjectSearchQuery( val type: SubjectType = SubjectType.ANIME, // val useOldSearchApi: Boolean = true, val tags: List? = null, + val year: Int? = null, val season: AnimeSeasonId? = null, val rating: RatingRange? = null, // val rank: Pair = Pair(null, null), @@ -27,7 +28,7 @@ data class SubjectSearchQuery( } fun hasFilters(): Boolean { - return tags != null || season != null || rating != null || nsfw != null || sort != SearchSort.MATCH + return tags != null || year != null || season != null || rating != null || nsfw != null || sort != SearchSort.MATCH } fun hasSearchRequest(): Boolean { diff --git a/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt b/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt index cad5726184..d4df1f800d 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt @@ -250,6 +250,8 @@ class SearchViewModel( put("has_query", updatedQuery.keywords.isNotEmpty()) put("tags", updatedQuery.tags.orEmpty().joinToString(",")) put("tag_count", updatedQuery.tags.orEmpty().size) + put("year", updatedQuery.year) + put("season", updatedQuery.season?.id) } } refreshSearch(updatedQuery) @@ -318,6 +320,7 @@ class SearchViewModel( private fun SubjectSearchQuery.shouldTriggerSearch(): Boolean { return keywords.isNotEmpty() || !tags.isNullOrEmpty() || + year != null || season != null || rating != null || nsfw != null diff --git a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt index 905fa31912..68b6e45d0a 100644 --- a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt +++ b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchFilter.kt @@ -13,14 +13,18 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme @@ -36,9 +40,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import me.him188.ani.app.data.models.schedule.AnimeSeason import me.him188.ani.app.data.models.subject.CanonicalTagKind import me.him188.ani.app.ui.foundation.ProvideCompositionLocalsForPreview import me.him188.ani.app.ui.lang.Lang @@ -91,11 +100,21 @@ data class SearchFilterChipState( get() = selected.isNotEmpty() } +@Immutable +data class SearchTimeFilterState( + val selectedYear: Int?, + val selectedSeason: AnimeSeason?, + val availableYears: List = defaultSearchYearOptions(), +) + @Composable fun SearchFilterChipsRow( state: SearchFilterState, onClickItemText: (SearchFilterChipState, value: String) -> Unit, onCheckedChange: (SearchFilterChipState, value: String) -> Unit, + timeFilterState: SearchTimeFilterState? = null, + onYearSelected: ((Int?) -> Unit)? = null, + onSeasonSelected: ((AnimeSeason?) -> Unit)? = null, modifier: Modifier = Modifier, ) { FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -105,6 +124,20 @@ fun SearchFilterChipsRow( { onClickItemText(chipState, it) }, { onCheckedChange(chipState, it) }, ) + if (chipState.kind == CanonicalTagKind.Region && timeFilterState != null) { + SearchYearFilterChip( + state = timeFilterState, + onYearSelected = { onYearSelected?.invoke(it) }, + ) + SearchSingleSelectChip( + label = timeFilterState.selectedSeason?.displayName() ?: "\u5B63\u5EA6", + options = listOf(null) + AnimeSeason.entries, + enabled = timeFilterState.selectedYear != null, + isSelected = { it == timeFilterState.selectedSeason }, + renderOption = { it?.displayName() ?: "\u5168\u90E8\u5B63\u5EA6" }, + onSelect = { onSeasonSelected?.invoke(it) }, + ) + } } } } @@ -174,6 +207,146 @@ fun SearchFilterChip( } } +@Composable +private fun SearchYearFilterChip( + state: SearchTimeFilterState, + onYearSelected: (Int?) -> Unit, + modifier: Modifier = Modifier, +) { + val maxYear = state.availableYears.firstOrNull() + val minYear = state.availableYears.lastOrNull() + Row(modifier) { + InputChip( + selected = state.selectedYear != null, + onClick = { + onYearSelected( + if (state.selectedYear == null) { + defaultSearchYearSelection(state.availableYears) + } else { + null + }, + ) + }, + label = { + Text( + state.selectedYear?.let { "\u5E74\u4EFD\uff1a$it" } ?: "\u5E74\u4EFD\uff1a\u5168\u90E8", + Modifier.widthIn(min = 88.dp, max = 112.dp), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + softWrap = false, + maxLines = 1, + ) + }, + leadingIcon = { + IconButton( + enabled = state.selectedYear != minYear, + onClick = { + val current = state.selectedYear ?: defaultSearchYearSelection(state.availableYears) + if (current != null) { + onYearSelected((current - 1).coerceAtLeast(minYear ?: current)) + } + }, + ) { + Icon( + Icons.Rounded.ChevronLeft, + contentDescription = "\u51CF\u5C11\u5E74\u4EFD", + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + } + }, + trailingIcon = { + IconButton( + enabled = state.selectedYear != maxYear, + onClick = { + val current = state.selectedYear ?: defaultSearchYearSelection(state.availableYears) + if (current != null) { + onYearSelected((current + 1).coerceAtMost(maxYear ?: current)) + } + }, + ) { + Icon( + Icons.Rounded.ChevronRight, + contentDescription = "\u589E\u52A0\u5E74\u4EFD", + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + } + }, + ) + } +} + +@Composable +private fun SearchSingleSelectChip( + label: String, + options: List, + isSelected: (T) -> Boolean, + renderOption: (T) -> String, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + var showDropdown by rememberSaveable { mutableStateOf(false) } + Box(modifier) { + InputChip( + selected = enabled && options.any(isSelected), + onClick = { if (enabled) showDropdown = true }, + label = { + Text( + label, + Modifier.widthIn(max = 128.dp), + overflow = TextOverflow.Ellipsis, + softWrap = false, + maxLines = 1, + ) + }, + enabled = enabled, + trailingIcon = { + Icon( + Icons.Rounded.ArrowDropDown, + null, + Modifier.size(InputChipDefaults.IconSize), + ) + }, + ) + + DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) { + for (option in options) { + DropdownMenuItem( + text = { Text(renderOption(option)) }, + onClick = { + onSelect(option) + showDropdown = false + }, + leadingIcon = { + Checkbox( + checked = isSelected(option), + onCheckedChange = null, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 12.dp), + ) + } + } + } +} + +private fun AnimeSeason.displayName(): String = when (this) { + AnimeSeason.WINTER -> "\u4E00\u6708\u756A" + AnimeSeason.SPRING -> "\u56DB\u6708\u756A" + AnimeSeason.SUMMER -> "\u4E03\u6708\u756A" + AnimeSeason.AUTUMN -> "\u5341\u6708\u756A" +} + +private fun defaultSearchYearOptions(): List { + val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year + return (currentYear + 1 downTo currentYear - 40).toList() +} + +private fun defaultSearchYearSelection(availableYears: List): Int? { + val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year + return availableYears.firstOrNull { it == currentYear } ?: availableYears.firstOrNull() +} + private fun renderChipLabel( state: SearchFilterChipState, labels: SearchFilterLabels, diff --git a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt index 4df8fd1545..0deede1cde 100644 --- a/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt +++ b/app/shared/ui-exploration/src/commonMain/kotlin/ui/exploration/search/SearchPage.kt @@ -79,6 +79,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import me.him188.ani.app.data.models.schedule.AnimeSeasonId import me.him188.ani.app.data.repository.RepositoryNetworkException import me.him188.ani.app.domain.search.SearchSort import me.him188.ani.app.ui.adaptive.AdaptiveSearchBar @@ -246,6 +247,39 @@ fun SearchPage( ), ) }, + timeFilterState = SearchTimeFilterState( + selectedYear = state.query.year, + selectedSeason = state.query.season?.season, + ), + onYearSelected = { year -> + val currentSeason = state.query.season?.season + onIntent( + SearchPageIntent.UpdateQuery( + state.query.copy( + year = year, + season = if (year != null && currentSeason != null) { + AnimeSeasonId(year, currentSeason) + } else { + null + }, + ), + ), + ) + }, + onSeasonSelected = { season -> + val year = state.query.year + onIntent( + SearchPageIntent.UpdateQuery( + state.query.copy( + season = if (year != null && season != null) { + AnimeSeasonId(year, season) + } else { + null + }, + ), + ), + ) + }, modifier = Modifier.fillMaxWidth(), ) }