[iOS & tvOS] FilterViewModel - Cleanup (#1412)

* Filter Changes

* Use `viewModel.modifiedFilters` for tracking if the filter has been modified. Update the init and update. Hold only the modified filters in `modifiedFilters` instead of `(modifiedFilters, bool)` since that's just clunky and unnecessary.

* Reset button should be disabled when only THAT filter is non-default.

* ...

* PagingLIbraryViewModel.filterQueryTask is no longer in use since that should now be handled on the FilterViewModel

* fix merge

* cleanup

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-02-15 15:05:34 -07:00 committed by GitHub
parent c934ac4219
commit 0235793bc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 212 additions and 98 deletions

View File

@ -22,8 +22,13 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
@Default(.accentColor)
private var accentColor
@StateObject
private var selection: BindingBox<Set<Element>>
@State
private var selectedItems: Set<Element>
private let selectionBinding: Binding<Set<Element>>
private let sources: [Element]
private var label: (Element) -> Label
private let type: SelectorType
private init(
selection: Binding<Set<Element>>,
@ -31,16 +36,13 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
label: @escaping (Element) -> Label,
type: SelectorType
) {
self._selection = StateObject(wrappedValue: BindingBox(source: selection))
self.selectionBinding = selection
self._selectedItems = State(initialValue: selection.wrappedValue)
self.sources = sources
self.label = label
self.type = type
}
private let sources: [Element]
private var label: (Element) -> Label
private let type: SelectorType
var body: some View {
List(sources, id: \.hashValue) { element in
Button {
@ -56,7 +58,7 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
Spacer()
if selection.value.contains(element) {
if selectedItems.contains(element) {
Image(systemName: "checkmark.circle.fill")
.resizable()
.backport
@ -69,33 +71,37 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
}
}
}
.onChange(of: selectionBinding.wrappedValue) { newValue in
selectedItems = newValue
}
}
private func handleSingleSelect(with element: Element) {
selection.value = [element]
selectedItems = [element]
selectionBinding.wrappedValue = selectedItems
}
private func handleMultiSelect(with element: Element) {
if selection.value.contains(element) {
selection.value.remove(element)
if selectedItems.contains(element) {
selectedItems.remove(element)
} else {
selection.value.insert(element)
selectedItems.insert(element)
}
selectionBinding.wrappedValue = selectedItems
}
}
extension SelectorView where Label == Text {
init(selection: Binding<[Element]>, sources: [Element], type: SelectorType) {
let selectionBinding = Binding {
Set(selection.wrappedValue)
} set: { newValue in
selection.wrappedValue = sources.intersection(newValue)
}
let setBinding = Binding<Set<Element>>(
get: { Set(selection.wrappedValue) },
set: { newValue in
selection.wrappedValue = Array(newValue)
}
)
self.init(
selection: selectionBinding,
selection: setBinding,
sources: sources,
label: { Text($0.displayTitle).foregroundColor(.primary) },
type: type
@ -103,15 +109,17 @@ extension SelectorView where Label == Text {
}
init(selection: Binding<Element>, sources: [Element]) {
let selectionBinding = Binding {
Set([selection.wrappedValue])
} set: { newValue in
selection.wrappedValue = newValue.first!
}
let setBinding = Binding<Set<Element>>(
get: { Set([selection.wrappedValue]) },
set: { newValue in
if let first = newValue.first {
selection.wrappedValue = first
}
}
)
self.init(
selection: selectionBinding,
selection: setBinding,
sources: sources,
label: { Text($0.displayTitle).foregroundColor(.primary) },
type: .single

View File

@ -16,7 +16,7 @@ import OrderedCollections
// parent class actions
// TODO: official way for a cleaner `respond` method so it doesn't have all Task
// construction and get bloated
// TODO: make Action: Hashable just for consistency
// TODO: move backgroundStates to just a `Set`
protocol Stateful: AnyObject {

View File

@ -6,26 +6,65 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import Foundation
import JellyfinAPI
import OrderedCollections
import SwiftUI
final class FilterViewModel: ViewModel {
final class FilterViewModel: ViewModel, Stateful {
@Published
var currentFilters: ItemFilterCollection
// MARK: - Action
enum Action: Equatable {
case cancel
case getQueryFilters
case reset(ItemFilterType? = nil)
case update(ItemFilterType, [AnyItemFilter])
}
// MARK: - Background State
enum BackgroundState: Hashable {
case gettingQueryFilters
case failedToGetQueryFilters
}
// MARK: - State
enum State: Hashable {
case content
}
/// Tracks the current filters
@Published
var allFilters: ItemFilterCollection = .all
private(set) var currentFilters: ItemFilterCollection
/// All filters available
@Published
private(set) var allFilters: ItemFilterCollection = .all
/// ViewModel Background State(s)
@Published
var backgroundStates: OrderedSet<BackgroundState> = []
/// ViewModel State
@Published
var state: State = .content
private let parent: (any LibraryParent)?
private var queryFiltersTask: AnyCancellable?
// MARK: - Initialize from Library Parent
init(
parent: (any LibraryParent)? = nil,
currentFilters: ItemFilterCollection = .default
) {
self.parent = parent
self.currentFilters = currentFilters
super.init()
if let parent {
@ -33,9 +72,102 @@ final class FilterViewModel: ViewModel {
}
}
func isFilterSelected(type: ItemFilterType) -> Bool {
currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection.default[keyPath: type.collectionAnyKeyPath]
}
// MARK: - Respond to Action
func respond(to action: Action) -> State {
switch action {
case .cancel:
queryFiltersTask?.cancel()
backgroundStates.removeAll()
case .getQueryFilters:
queryFiltersTask?.cancel()
queryFiltersTask = Task {
do {
await MainActor.run {
_ = self.backgroundStates.append(.gettingQueryFilters)
}
try await setQueryFilters()
} catch {
await MainActor.run {
_ = self.backgroundStates.append(.failedToGetQueryFilters)
}
}
await MainActor.run {
_ = self.backgroundStates.remove(.gettingQueryFilters)
}
}
.asAnyCancellable()
case let .reset(type):
if let type {
resetCurrentFilters(for: type)
} else {
currentFilters = .default
}
case let .update(type, filters):
updateCurrentFilters(for: type, with: filters)
}
return state
}
// MARK: - Reset Current Filters
/// Reset the filter for a specific type to its default value
private func resetCurrentFilters(for type: ItemFilterType) {
switch type {
case .genres:
currentFilters.genres = ItemFilterCollection.default.genres
case .letter:
currentFilters.letter = ItemFilterCollection.default.letter
case .sortBy:
currentFilters.sortBy = ItemFilterCollection.default.sortBy
case .sortOrder:
currentFilters.sortOrder = ItemFilterCollection.default.sortOrder
case .tags:
currentFilters.tags = ItemFilterCollection.default.tags
case .traits:
currentFilters.traits = ItemFilterCollection.default.traits
case .years:
currentFilters.years = ItemFilterCollection.default.years
}
}
// MARK: - Update Current Filters
/// Update the filter for a specific type with new values
private func updateCurrentFilters(for type: ItemFilterType, with newValue: [AnyItemFilter]) {
switch type {
case .genres:
currentFilters.genres = newValue.map(ItemGenre.init)
case .letter:
currentFilters.letter = newValue.map(ItemLetter.init)
case .sortBy:
currentFilters.sortBy = newValue.map(ItemSortBy.init)
case .sortOrder:
currentFilters.sortOrder = newValue.map(ItemSortOrder.init)
case .tags:
currentFilters.tags = newValue.map(ItemTag.init)
case .traits:
currentFilters.traits = newValue.map(ItemTrait.init)
case .years:
currentFilters.years = newValue.map(ItemYear.init)
}
}
// MARK: - Set Query Filters
/// Sets the query filters from the parent
func setQueryFilters() async {
let queryFilters = await getQueryFilters()
private func setQueryFilters() async throws {
let queryFilters = try await getQueryFilters()
await MainActor.run {
allFilters.genres = queryFilters.genres
@ -44,7 +176,10 @@ final class FilterViewModel: ViewModel {
}
}
private func getQueryFilters() async -> (genres: [ItemGenre], tags: [ItemTag], years: [ItemYear]) {
// MARK: - Get Query Filters
/// Gets the query filters from the parent
private func getQueryFilters() async throws -> (genres: [ItemGenre], tags: [ItemTag], years: [ItemYear]) {
let parameters = Paths.GetQueryFiltersLegacyParameters(
userID: userSession.user.id,
@ -52,7 +187,7 @@ final class FilterViewModel: ViewModel {
)
let request = Paths.getQueryFiltersLegacy(parameters: parameters)
guard let response = try? await userSession.client.send(request) else { return ([], [], []) }
let response = try await userSession.client.send(request)
let genres: [ItemGenre] = (response.value.genres ?? [])
.map(ItemGenre.init)

View File

@ -119,7 +119,6 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
// tasks
private var filterQueryTask: AnyCancellable?
private var pagingTask: AnyCancellable?
private var randomItemTask: AnyCancellable?
@ -252,14 +251,10 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
return .error(error)
case .refresh:
filterQueryTask?.cancel()
pagingTask?.cancel()
randomItemTask?.cancel()
filterQueryTask = Task {
await filterViewModel?.setQueryFilters()
}
.asAnyCancellable()
filterViewModel?.send(.getQueryFilters)
pagingTask = Task { [weak self] in
guard let self else { return }

View File

@ -117,10 +117,7 @@ final class SearchViewModel: ViewModel, Stateful {
return .searching
}
case .getSuggestions:
Task {
await filterViewModel.setQueryFilters()
}
.store(in: &cancellables)
filterViewModel.send(.getQueryFilters)
Task {
let suggestions = try await getSuggestions()
@ -223,6 +220,15 @@ final class SearchViewModel: ViewModel, Stateful {
parameters.tags = filters.tags.map(\.value)
parameters.years = filters.years.map(\.intValue)
if filters.letter.first?.value == "#" {
parameters.nameLessThan = "A"
} else {
parameters.nameStartsWith = filters.letter
.map(\.value)
.filter { $0 != "#" }
.first
}
let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters)
let response = try await userSession.client.send(request)

View File

@ -31,10 +31,10 @@ extension LetterPickerBar {
var body: some View {
Button {
if !viewModel.currentFilters.letter.contains(letter) {
viewModel.currentFilters.letter = [ItemLetter(stringLiteral: letter.value)]
if viewModel.currentFilters.letter.contains(letter) {
viewModel.send(.update(.letter, []))
} else {
viewModel.currentFilters.letter = []
viewModel.send(.update(.letter, [ItemLetter(stringLiteral: letter.value).asAnyItemFilter]))
}
} label: {
ZStack {

View File

@ -16,9 +16,11 @@ extension NavigationBarFilterDrawer {
@Default(.accentColor)
private var accentColor
@Environment(\.isSelected)
private var isSelected
private let systemName: String?
private let title: String
private let activated: Bool
private var onSelect: () -> Void
var body: some View {
@ -43,12 +45,12 @@ extension NavigationBarFilterDrawer {
.padding(.vertical, 5)
.background {
Capsule()
.foregroundColor(activated ? accentColor : Color(UIColor.secondarySystemFill))
.foregroundColor(isSelected ? accentColor : Color(UIColor.secondarySystemFill))
.opacity(0.5)
}
.overlay {
Capsule()
.stroke(activated ? accentColor : Color(UIColor.secondarySystemFill), lineWidth: 1)
.stroke(isSelected ? accentColor : Color(UIColor.secondarySystemFill), lineWidth: 1)
}
}
}
@ -57,20 +59,18 @@ extension NavigationBarFilterDrawer {
extension NavigationBarFilterDrawer.FilterDrawerButton {
init(title: String, activated: Bool) {
init(title: String) {
self.init(
systemName: nil,
title: title,
activated: activated,
onSelect: {}
)
}
init(systemName: String, activated: Bool) {
init(systemName: String) {
self.init(
systemName: systemName,
title: "",
activated: activated,
onSelect: {}
)
}

View File

@ -24,22 +24,25 @@ struct NavigationBarFilterDrawer: View {
if viewModel.currentFilters.hasFilters {
Menu {
Button(L10n.reset, role: .destructive) {
viewModel.currentFilters = .default
viewModel.send(.reset())
}
} label: {
FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill", activated: true)
FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill")
.environment(\.isSelected, true)
}
}
ForEach(filterTypes, id: \.self) { type in
FilterDrawerButton(
title: type.displayTitle,
activated: viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection
.default[keyPath: type.collectionAnyKeyPath]
title: type.displayTitle
)
.onSelect {
onSelect(.init(type: type, viewModel: viewModel))
}
.environment(
\.isSelected,
viewModel.isFilterSelected(type: type)
)
}
}
.padding(.horizontal)

View File

@ -9,8 +9,6 @@
import JellyfinAPI
import SwiftUI
// Note: Keep all of the ItemFilterCollection/ItemFilter/AnyItemFilter KeyPath wackiness in this file
// TODO: multiple filter types?
// - for sort order and sort by combined
struct FilterView: View {
@ -39,27 +37,11 @@ struct FilterView: View {
}
.topBarTrailing {
Button(L10n.reset) {
switch type {
case .genres:
viewModel.currentFilters.genres = ItemFilterCollection.default.genres
case .letter:
viewModel.currentFilters.letter = ItemFilterCollection.default.letter
case .sortBy:
viewModel.currentFilters.sortBy = ItemFilterCollection.default.sortBy
case .sortOrder:
viewModel.currentFilters.sortOrder = ItemFilterCollection.default.sortOrder
case .tags:
viewModel.currentFilters.tags = ItemFilterCollection.default.tags
case .traits:
viewModel.currentFilters.traits = ItemFilterCollection.default.traits
case .years:
viewModel.currentFilters.years = ItemFilterCollection.default.years
}
viewModel.send(.reset(type))
}
.environment(
\.isEnabled,
viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection
.default[keyPath: type.collectionAnyKeyPath]
viewModel.isFilterSelected(type: type)
)
}
}
@ -75,22 +57,7 @@ extension FilterView {
let selectionBinding: Binding<[AnyItemFilter]> = Binding {
viewModel.currentFilters[keyPath: type.collectionAnyKeyPath]
} set: { newValue in
switch type {
case .genres:
viewModel.currentFilters.genres = newValue.map(ItemGenre.init)
case .letter:
viewModel.currentFilters.letter = newValue.map(ItemLetter.init)
case .sortBy:
viewModel.currentFilters.sortBy = newValue.map(ItemSortBy.init)
case .sortOrder:
viewModel.currentFilters.sortOrder = newValue.map(ItemSortOrder.init)
case .tags:
viewModel.currentFilters.tags = newValue.map(ItemTag.init)
case .traits:
viewModel.currentFilters.traits = newValue.map(ItemTrait.init)
case .years:
viewModel.currentFilters.years = newValue.map(ItemYear.init)
}
viewModel.send(.update(type, newValue))
}
self.init(

View File

@ -24,6 +24,6 @@ struct FontPickerView: View {
.foregroundColor(.primary)
.font(.custom(fontFamily, size: 18))
}
.navigationTitle("Font")
.navigationTitle(L10n.subtitleFont)
}
}