Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[iOS & tvOS] FilterViewModel - Cleanup #1412

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
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
219 changes: 217 additions & 2 deletions Shared/ViewModels/FilterViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,255 @@
// 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 refresh
case cancel
case reset(ItemFilterType? = nil)
case update(ItemFilterType, [AnyItemFilter])
}

// MARK: - Background State

enum BackgroundState: Hashable {
case refreshing
case updating
}

// MARK: - State

enum State: Hashable {
case initial
case content
}

/// Tracks the current filters
@Published
var currentFilters: ItemFilterCollection

/// Tracks modified filters as a tuple of value and modification state
@Published
var modifiedFilters: Set<ItemFilterType>

/// All filters available
@Published
var allFilters: ItemFilterCollection = .all

/// ViewModel Background State(s)
@Published
var backgroundStates: OrderedSet<BackgroundState> = []

/// ViewModel State
@Published
var state: State = .initial

// MARK: - Filter Variables

private let parent: (any LibraryParent)?
private let itemTypes: [BaseItemKind]?

// MARK: - Tasks

private var backgroundTask: AnyCancellable?
private var task: AnyCancellable?

// MARK: - Initialize from Library Parent

init(
parent: (any LibraryParent)? = nil,
currentFilters: ItemFilterCollection = .default
) {
self.parent = parent
self.itemTypes = nil
self.currentFilters = currentFilters

let defaultFilters: ItemFilterCollection = .default

var modifiedFiltersSet: Set<ItemFilterType> = []

for type in ItemFilterType.allCases {
let isModified = currentFilters[keyPath: type.collectionAnyKeyPath] != defaultFilters[keyPath: type.collectionAnyKeyPath]
if isModified {
modifiedFiltersSet.insert(type)
}
}

self.modifiedFilters = modifiedFiltersSet

super.init()
}

// MARK: - Initialize from Item Type

init(
itemTypes: [BaseItemKind],
currentFilters: ItemFilterCollection = .default
) {
self.parent = nil
self.itemTypes = itemTypes
self.currentFilters = currentFilters

let defaultFilters: ItemFilterCollection = .default

var modifiedFiltersSet: Set<ItemFilterType> = []

for type in ItemFilterType.allCases {
let isModified = currentFilters[keyPath: type.collectionAnyKeyPath] != defaultFilters[keyPath: type.collectionAnyKeyPath]
if isModified {
modifiedFiltersSet.insert(type)
}
}

self.modifiedFilters = modifiedFiltersSet

super.init()
}

// MARK: - Respond to Action

func respond(to action: Action) -> State {
switch action {
case .cancel:
backgroundTask?.cancel()
task?.cancel()

return state

case .refresh:
backgroundTask?.cancel()
backgroundTask = Task {
do {
await MainActor.run {
self.state = .initial
_ = self.backgroundStates.append(.refreshing)
}

await self.setQueryFilters()

await MainActor.run {
self.state = .content
_ = self.backgroundStates.remove(.refreshing)
}
}
}
.asAnyCancellable()

return state

case let .reset(type):
task?.cancel()
task = Task {
await MainActor.run {
_ = backgroundStates.append(.updating)
}

if let type {
resetCurrentFilters(for: type)
toggleModifiedState(for: type)
} else {
self.currentFilters = .default
self.modifiedFilters.removeAll()
}

await MainActor.run {
_ = backgroundStates.remove(.updating)
}
}
.asAnyCancellable()

return state

case let .update(type, filters):
task?.cancel()
task = Task {
do {
await MainActor.run {
_ = backgroundStates.append(.updating)
}

updateCurrentFilters(for: type, with: filters)
toggleModifiedState(for: type)

await MainActor.run {
_ = backgroundStates.remove(.updating)
}
}
}
.asAnyCancellable()

return state
}
}

// MARK: - Toggle Modified Filter State

/// Check if the current filter for a specific type has been modified and update `modifiedFilters`
private func toggleModifiedState(for type: ItemFilterType) {

if currentFilters[keyPath: type.collectionAnyKeyPath] != ItemFilterCollection.default[keyPath: type.collectionAnyKeyPath] {
self.modifiedFilters.insert(type)
} else {
self.modifiedFilters.remove(type)
}
}

// 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 {
private func setQueryFilters() async {
let queryFilters = await getQueryFilters()

await MainActor.run {
Expand All @@ -52,6 +264,9 @@ final class FilterViewModel: ViewModel {
}
}

// MARK: - Get Query Filters

/// Gets the query filters from the parent
private func getQueryFilters() async -> (genres: [ItemGenre], tags: [ItemTag], years: [ItemYear]) {
let parameters = Paths.GetQueryFiltersLegacyParameters(
userID: userSession.user.id,
Expand Down
Loading