Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> {
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<String> {
return listOf(
">=$this-01-01",
"<${this + 1}-01-01",
)
}

Expand All @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ data class SubjectSearchQuery(
val type: SubjectType = SubjectType.ANIME,
// val useOldSearchApi: Boolean = true,
val tags: List<String>? = null,
val year: Int? = null,
val season: AnimeSeasonId? = null,
val rating: RatingRange? = null,
// val rank: Pair<String?, String?> = Pair(null, null),
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions app/shared/src/commonMain/kotlin/ui/main/SearchViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -318,6 +320,7 @@ class SearchViewModel(
private fun SubjectSearchQuery.shouldTriggerSearch(): Boolean {
return keywords.isNotEmpty() ||
!tags.isNullOrEmpty() ||
year != null ||
season != null ||
rating != null ||
nsfw != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -91,11 +100,21 @@ data class SearchFilterChipState(
get() = selected.isNotEmpty()
}

@Immutable
data class SearchTimeFilterState(
val selectedYear: Int?,
val selectedSeason: AnimeSeason?,
val availableYears: List<Int> = 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)) {
Expand All @@ -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) },
)
}
}
}
}
Expand Down Expand Up @@ -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 <T> SearchSingleSelectChip(
label: String,
options: List<T>,
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<Int> {
val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year
return (currentYear + 1 downTo currentYear - 40).toList()
}

private fun defaultSearchYearSelection(availableYears: List<Int>): Int? {
val currentYear = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year
return availableYears.firstOrNull { it == currentYear } ?: availableYears.firstOrNull()
}

private fun renderChipLabel(
state: SearchFilterChipState,
labels: SearchFilterLabels,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
}
Expand Down
Loading