From 40ca9b70904661dd1b3ab902830467cf3b02ff86 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Wed, 24 Jun 2026 00:38:04 +0200 Subject: [PATCH] Added year filter --- .../pianotimeline/ui/RecordingListScreen.kt | 50 +++++++++++++++++-- .../ui/RecordingListViewModel.kt | 43 ++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt index 51f797d..46913f8 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt @@ -82,16 +82,21 @@ fun RecordingListScreen( val sortMode by vm.sortMode.collectAsStateWithLifecycle() val hiddenArtists by vm.hiddenArtists.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 showSortMenu by remember { mutableStateOf(false) } var showArtistFilter by rememberSaveable { mutableStateOf(false) } + var showYearFilter by rememberSaveable { mutableStateOf(false) } val listState = rememberLazyListState() - // Bug fix: when the sort mode changes, jump back to the TOP of the list - // instead of the LazyColumn trying (and failing) to preserve scroll position. - LaunchedEffect(sortMode) { + // Jump back to the TOP of the list whenever the ordering or the active + // filters change — otherwise the LazyColumn keeps its old scroll offset and + // 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) } @@ -145,7 +150,7 @@ fun RecordingListScreen( } 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. // 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 ) } + + TextButton(onClick = { showYearFilter = !showYearFilter }) { + Text( + if (hiddenYears.isEmpty()) "Years" + else "Years (${hiddenYears.size} hidden)", + style = MaterialTheme.typography.labelMedium + ) + } } 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)) } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt index 83f0593..25f9c81 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt @@ -3,10 +3,10 @@ package com.keylightpiano.pianotimeline.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.keylightpiano.pianotimeline.data.DeepLinkParser import com.keylightpiano.pianotimeline.data.MusicRepository import com.keylightpiano.pianotimeline.data.SettingsStore import com.keylightpiano.pianotimeline.data.SyncManager -import com.keylightpiano.pianotimeline.data.DeepLinkParser import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlayerManager @@ -106,6 +106,12 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { private val _hiddenArtists = MutableStateFlow>(emptySet()) val hiddenArtists: StateFlow> = _hiddenArtists.asStateFlow() + /** + * Set of HIDDEN years. Empty = every year visible. Same pattern as artists. + */ + private val _hiddenYears = MutableStateFlow>(emptySet()) + val hiddenYears: StateFlow> = _hiddenYears.asStateFlow() + // ── Derived: all unique grouping artists, for the filter UI ──────── /** All recordings straight from Room (unfiltered). */ @@ -123,15 +129,27 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) { .sorted() } + /** + * Distinct years present in the library, newest first (matches the default sort). + */ + val availableYears: StateFlow> = allRecordings + .mapToState(viewModelScope) { recordings -> + recordings.map { it.date.year } + .distinct() + .sortedDescending() + } + // ── The final filtered + sorted list the UI renders ──────────────── val recordings: StateFlow> = combine( allRecordings, _searchQuery, _sortMode, - _hiddenArtists - ) { all, query, sort, hidden -> + _hiddenArtists, + _hiddenYears + ) { all, query, sort, hidden, hiddenYears -> all.filterByArtist(hidden) + .filterByYear(hiddenYears) .filterBySearch(query) .sortedWith(sort.comparator()) // 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() } + 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) { viewModelScope.launch { repo.setFavorite(recording.id, !recording.isFavorite) @@ -512,6 +544,11 @@ private fun List.filterByArtist(hidden: Set): List return filter { it.groupingArtist !in hidden } } +private fun List.filterByYear(hidden: Set): List { + if (hidden.isEmpty()) return this + return filter { it.date.year !in hidden } +} + private fun SortMode.comparator(): Comparator = when (this) { SortMode.DATE_DESC -> compareByDescending { it.date } .thenByDescending { it.performanceNumber }