Skip to content

Commit

Permalink
[iOS & tvOS] FilterViewModel - Cleanup (#1412)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
JPKribs and LePips authored Feb 15, 2025
1 parent c934ac4 commit 0235793
Show file tree
Hide file tree
Showing 10 changed files with 211 additions and 97 deletions.
62 changes: 35 additions & 27 deletions Shared/Components/SelectorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,27 @@ 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>>,
sources: [Element],
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 {
Expand All @@ -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
Expand All @@ -69,49 +71,55 @@ 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
)
}

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
Expand Down
2 changes: 1 addition & 1 deletion Shared/Objects/Stateful.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
149 changes: 142 additions & 7 deletions Shared/ViewModels/FilterViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,168 @@
// 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 {

// 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
private(set) var currentFilters: ItemFilterCollection

/// All filters available
@Published
var currentFilters: ItemFilterCollection
private(set) var allFilters: ItemFilterCollection = .all

/// ViewModel Background State(s)
@Published
var allFilters: ItemFilterCollection = .all
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 {
self.allFilters.itemTypes = parent.supportedItemTypes
}
}

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
Expand All @@ -44,15 +176,18 @@ 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,
parentID: parent?.id
)

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {

// tasks

private var filterQueryTask: AnyCancellable?
private var pagingTask: AnyCancellable?
private var randomItemTask: AnyCancellable?

Expand Down Expand Up @@ -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 }
Expand Down
14 changes: 10 additions & 4 deletions Shared/ViewModels/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 0235793

Please sign in to comment.