Added year filter

This commit is contained in:
OmniaX-Dev 2026-06-24 00:38:04 +02:00
parent 889166c88a
commit 40ca9b7090
2 changed files with 86 additions and 7 deletions

View file

@ -82,16 +82,21 @@ fun RecordingListScreen(
val sortMode by vm.sortMode.collectAsStateWithLifecycle() val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle() val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
val availableArtists by vm.availableArtists.collectAsStateWithLifecycle() val availableArtists by vm.availableArtists.collectAsStateWithLifecycle()
val hiddenYears by vm.hiddenYears.collectAsStateWithLifecycle()
val availableYears by vm.availableYears.collectAsStateWithLifecycle()
var showControls by rememberSaveable { mutableStateOf(false) } var showControls by rememberSaveable { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showArtistFilter by rememberSaveable { mutableStateOf(false) } var showArtistFilter by rememberSaveable { mutableStateOf(false) }
var showYearFilter by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Bug fix: when the sort mode changes, jump back to the TOP of the list // Jump back to the TOP of the list whenever the ordering or the active
// instead of the LazyColumn trying (and failing) to preserve scroll position. // filters change — otherwise the LazyColumn keeps its old scroll offset and
LaunchedEffect(sortMode) { // can end up stranded (e.g. mid-list or at the bottom) after the set of
// visible items shrinks then grows again.
LaunchedEffect(sortMode, hiddenArtists, hiddenYears) {
listState.scrollToItem(0) listState.scrollToItem(0)
} }
@ -145,7 +150,7 @@ fun RecordingListScreen(
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty() val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty() || hiddenYears.isNotEmpty()
// The recording matching the active track, if any — used by the mini-player. // The recording matching the active track, if any — used by the mini-player.
// We look in the full unfiltered list so the bar stays even if a filter would // We look in the full unfiltered list so the bar stays even if a filter would
@ -302,6 +307,14 @@ fun RecordingListScreen(
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
} }
TextButton(onClick = { showYearFilter = !showYearFilter }) {
Text(
if (hiddenYears.isEmpty()) "Years"
else "Years (${hiddenYears.size} hidden)",
style = MaterialTheme.typography.labelMedium
)
}
} }
AnimatedVisibility( AnimatedVisibility(
@ -333,6 +346,35 @@ fun RecordingListScreen(
} }
} }
AnimatedVisibility(
visible = showYearFilter,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { vm.showAllYears() }) {
Text("All", style = MaterialTheme.typography.labelSmall)
}
TextButton(onClick = { vm.hideAllYears() }) {
Text("None", style = MaterialTheme.typography.labelSmall)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
availableYears.forEach { year ->
FilterChip(
selected = year !in hiddenYears,
onClick = { vm.toggleYear(year) },
label = { Text(year.toString(), style = MaterialTheme.typography.labelSmall) }
)
}
}
}
}
HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
} }
} }

View file

@ -3,10 +3,10 @@ package com.keylightpiano.pianotimeline.ui
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.data.MusicRepository import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.SettingsStore import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.data.DeepLinkParser
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager import com.keylightpiano.pianotimeline.player.PlayerManager
@ -106,6 +106,12 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val _hiddenArtists = MutableStateFlow<Set<String>>(emptySet()) private val _hiddenArtists = MutableStateFlow<Set<String>>(emptySet())
val hiddenArtists: StateFlow<Set<String>> = _hiddenArtists.asStateFlow() val hiddenArtists: StateFlow<Set<String>> = _hiddenArtists.asStateFlow()
/**
* Set of HIDDEN years. Empty = every year visible. Same pattern as artists.
*/
private val _hiddenYears = MutableStateFlow<Set<Int>>(emptySet())
val hiddenYears: StateFlow<Set<Int>> = _hiddenYears.asStateFlow()
// ── Derived: all unique grouping artists, for the filter UI ──────── // ── Derived: all unique grouping artists, for the filter UI ────────
/** All recordings straight from Room (unfiltered). */ /** All recordings straight from Room (unfiltered). */
@ -123,15 +129,27 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
.sorted() .sorted()
} }
/**
* Distinct years present in the library, newest first (matches the default sort).
*/
val availableYears: StateFlow<List<Int>> = allRecordings
.mapToState(viewModelScope) { recordings ->
recordings.map { it.date.year }
.distinct()
.sortedDescending()
}
// ── The final filtered + sorted list the UI renders ──────────────── // ── The final filtered + sorted list the UI renders ────────────────
val recordings: StateFlow<List<Recording>> = combine( val recordings: StateFlow<List<Recording>> = combine(
allRecordings, allRecordings,
_searchQuery, _searchQuery,
_sortMode, _sortMode,
_hiddenArtists _hiddenArtists,
) { all, query, sort, hidden -> _hiddenYears
) { all, query, sort, hidden, hiddenYears ->
all.filterByArtist(hidden) all.filterByArtist(hidden)
.filterByYear(hiddenYears)
.filterBySearch(query) .filterBySearch(query)
.sortedWith(sort.comparator()) .sortedWith(sort.comparator())
// Favorites float to the top, preserving the sort order within each group. // Favorites float to the top, preserving the sort order within each group.
@ -198,6 +216,20 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
_hiddenArtists.value = availableArtists.value.toSet() _hiddenArtists.value = availableArtists.value.toSet()
} }
fun toggleYear(year: Int) {
_hiddenYears.value = _hiddenYears.value.let { current ->
if (year in current) current - year else current + year
}
}
fun showAllYears() {
_hiddenYears.value = emptySet()
}
fun hideAllYears() {
_hiddenYears.value = availableYears.value.toSet()
}
fun toggleFavorite(recording: Recording) { fun toggleFavorite(recording: Recording) {
viewModelScope.launch { viewModelScope.launch {
repo.setFavorite(recording.id, !recording.isFavorite) repo.setFavorite(recording.id, !recording.isFavorite)
@ -512,6 +544,11 @@ private fun List<Recording>.filterByArtist(hidden: Set<String>): List<Recording>
return filter { it.groupingArtist !in hidden } return filter { it.groupingArtist !in hidden }
} }
private fun List<Recording>.filterByYear(hidden: Set<Int>): List<Recording> {
if (hidden.isEmpty()) return this
return filter { it.date.year !in hidden }
}
private fun SortMode.comparator(): Comparator<Recording> = when (this) { private fun SortMode.comparator(): Comparator<Recording> = when (this) {
SortMode.DATE_DESC -> compareByDescending<Recording> { it.date } SortMode.DATE_DESC -> compareByDescending<Recording> { it.date }
.thenByDescending { it.performanceNumber } .thenByDescending { it.performanceNumber }