Hang search variables off view model to make them easier to observe

This commit is contained in:
Jackson Harper
2023-12-01 16:06:19 +08:00
parent 2641151a26
commit af2759cab0
7 changed files with 101 additions and 107 deletions

View File

@ -5,22 +5,13 @@ import SwiftUI
import Utils
@MainActor
class FetcherFilterState: ObservableObject {
struct FetcherFilterState {
let folder: String
@Published var searchTerm = ""
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()
@Published var appliedSort = LinkedItemSort.newest.rawValue
let searchTerm: String
let selectedLabels: [LinkedItemLabel]
let negatedLabels: [LinkedItemLabel]
let appliedSort: String
@Published var appliedFilter: InternalFilter? {
didSet {
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
UserDefaults.standard.setValue(appliedFilter?.name, forKey: filterKey)
}
}
init(folder: String) {
self.folder = folder
}
let appliedFilter: InternalFilter?
}

View File

@ -41,12 +41,11 @@ struct AnimatingCellHeight: AnimatableModifier {
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
@StateObject var viewModel: HomeFeedViewModel
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
init(viewModel: HomeFeedViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
_viewModel = ObservedObject(wrappedValue: viewModel)
}
func loadItems(isRefresh: Bool) {
@ -57,11 +56,10 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.listConfig.hasFeatureCards &&
!viewModel.hideFeatureSection &&
viewModel.fetcher.items.count > 0 &&
viewModel.filterState.searchTerm.isEmpty &&
viewModel.filterState.selectedLabels.isEmpty &&
viewModel.filterState.negatedLabels.isEmpty
/* &&
viewModel.filterState.appliedFilter?.name == "inbox" */
viewModel.searchTerm.isEmpty &&
viewModel.selectedLabels.isEmpty &&
viewModel.negatedLabels.isEmpty &&
viewModel.appliedFilter?.name == "inbox"
}
var body: some View {
@ -72,35 +70,31 @@ struct AnimatingCellHeight: AnimatableModifier {
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
filterState: viewModel.filterState,
showFeatureCards: showFeatureCards
)
.refreshable {
loadItems(isRefresh: true)
}
.onChange(of: viewModel.filterState.appliedFilter?.id) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.presentWebContainer) { _ in
if !viewModel.presentWebContainer {
viewModel.linkRequest = nil
}
}
.onChange(of: viewModel.filterState.searchTerm) { _ in
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
loadItems(isRefresh: true)
}
.onChange(of: viewModel.filterState.selectedLabels) { _ in
.onChange(of: viewModel.selectedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.filterState.negatedLabels) { _ in
.onChange(of: viewModel.negatedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.filterState.appliedFilter) { _ in
.onChange(of: viewModel.appliedFilter) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.filterState.appliedSort) { _ in
.onChange(of: viewModel.appliedSort) { _ in
loadItems(isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
@ -140,10 +134,10 @@ struct AnimatingCellHeight: AnimatableModifier {
if let deepLink = DeepLink.make(from: url) {
switch deepLink {
case let .search(query):
viewModel.filterState.searchTerm = query
viewModel.searchTerm = query
case let .savedSearch(named):
if let filter = viewModel.findFilter(dataService, named: named) {
viewModel.filterState.appliedFilter = filter
viewModel.appliedFilter = filter
}
case let .webAppLinkRequest(requestID):
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
@ -172,7 +166,7 @@ struct AnimatingCellHeight: AnimatableModifier {
if viewModel.fetcher.items.isEmpty {
loadItems(isRefresh: false)
}
await viewModel.loadFilters(dataService: dataService, filterState: viewModel.filterState)
await viewModel.loadFilters(dataService: dataService)
}
.environment(\.editMode, self.$isEditMode)
}
@ -182,9 +176,9 @@ struct AnimatingCellHeight: AnimatableModifier {
ToolbarItem(placement: .barLeading) {
VStack(alignment: .leading) {
let showDate = isListScrolled && !listTitle.isEmpty
if let title = viewModel.filterState.appliedFilter?.name {
if let title = viewModel.appliedFilter?.name {
Text(title)
.font(Font.system(size: showDate ? 10 : 28, weight: .semibold))
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
Text(listTitle)
.font(Font.system(size: 15, weight: .regular))
@ -280,7 +274,6 @@ struct AnimatingCellHeight: AnimatableModifier {
@Binding var isEditMode: EditMode
@Binding var selection: Set<String>
@ObservedObject var viewModel: HomeFeedViewModel
@ObservedObject var filterState: FetcherFilterState
let showFeatureCards: Bool
@ -311,7 +304,6 @@ struct AnimatingCellHeight: AnimatableModifier {
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
filterState: filterState,
showFeatureCards: showFeatureCards
)
} else {
@ -322,11 +314,11 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}.sheet(isPresented: $viewModel.showLabelsSheet) {
FilterByLabelsView(
initiallySelected: filterState.selectedLabels,
initiallyNegated: filterState.negatedLabels
initiallySelected: viewModel.selectedLabels,
initiallyNegated: viewModel.negatedLabels
) {
filterState.selectedLabels = $0
filterState.negatedLabels = $1
viewModel.selectedLabels = $0
viewModel.negatedLabels = $1
}
}
.popup(isPresented: $viewModel.showSnackbar) {
@ -367,7 +359,6 @@ struct AnimatingCellHeight: AnimatableModifier {
@Binding var selection: Set<String>
@ObservedObject var viewModel: HomeFeedViewModel
@ObservedObject var filterState: FetcherFilterState
let showFeatureCards: Bool
@ -375,45 +366,43 @@ struct AnimatingCellHeight: AnimatableModifier {
@State var topItem: Models.LibraryItem?
@ObservedObject var networkMonitor = NetworkMonitor()
init(listTitle: Binding<String>,
isListScrolled: Binding<Bool>,
prefersListLayout: Binding<Bool>,
isEditMode: Binding<EditMode>,
selection: Binding<Set<String>>,
viewModel: HomeFeedViewModel,
filterState: FetcherFilterState,
showFeatureCards: Bool)
{
self._listTitle = listTitle
self._isListScrolled = isListScrolled
self._prefersListLayout = prefersListLayout
self._isEditMode = isEditMode
self._selection = selection
self.viewModel = viewModel
self.filterState = filterState
self.showFeatureCards = showFeatureCards
}
// init(listTitle: Binding<String>,
// isListScrolled: Binding<Bool>,
// prefersListLayout: Binding<Bool>,
// isEditMode: Binding<EditMode>,
// selection: Binding<Set<String>>,
// viewModel: HomeFeedViewModel,
// showFeatureCards: Bool)
// {
// self._listTitle = listTitle
// self._isListScrolled = isListScrolled
// self._prefersListLayout = prefersListLayout
// self._isEditMode = isEditMode
// self._selection = selection
// self.viewModel = viewModel
// self.showFeatureCards = showFeatureCards
// }
var filtersHeader: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.filterState.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.filterState.searchTerm) {
viewModel.filterState.searchTerm = ""
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
Menu(
content: {
ForEach(viewModel.filters) { filter in
Button(filter.name, action: {
viewModel.filterState.appliedFilter = filter
viewModel.appliedFilter = filter
})
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.filterState.appliedFilter?.name ?? "-",
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
@ -422,25 +411,25 @@ struct AnimatingCellHeight: AnimatableModifier {
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.filterState.appliedSort = sort.rawValue })
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.filterState.appliedSort)?.displayName ?? "Sort",
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
color: .systemGray6
)
}
)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
ForEach(viewModel.filterState.selectedLabels, id: \.self) { label in
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.filterState.selectedLabels.removeAll { $0.id == label.id }
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.filterState.negatedLabels, id: \.self) { label in
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.filterState.negatedLabels.removeAll { $0.id == label.id }
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
@ -614,7 +603,7 @@ struct AnimatingCellHeight: AnimatableModifier {
List(selection: $selection) {
Section(content: {
EmptyView().id("TOP")
if let appliedFilter = viewModel.filterState.appliedFilter,
if let appliedFilter = viewModel.appliedFilter,
networkMonitor.status == .disconnected,
!appliedFilter.allowLocalFetch
{
@ -795,20 +784,20 @@ struct AnimatingCellHeight: AnimatableModifier {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.filterState.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.filterState.searchTerm) {
viewModel.filterState.searchTerm = ""
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
Menu(
content: {
ForEach(viewModel.filters, id: \.self) { filter in
Button(filter.name, action: { viewModel.filterState.appliedFilter = filter })
Button(filter.name, action: { viewModel.appliedFilter = filter })
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.filterState.appliedFilter?.name ?? "-",
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
@ -817,25 +806,25 @@ struct AnimatingCellHeight: AnimatableModifier {
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.filterState.appliedSort = sort.rawValue })
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.filterState.appliedSort)?.displayName ?? "Sort",
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
color: .systemGray6
)
}
)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
ForEach(viewModel.filterState.selectedLabels, id: \.self) { label in
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.filterState.selectedLabels.removeAll { $0.id == label.id }
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.filterState.negatedLabels, id: \.self) { label in
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.filterState.negatedLabels.removeAll { $0.id == label.id }
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()

View File

@ -6,7 +6,9 @@ import Utils
import Views
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
var currentDetailViewModel: LinkItemDetailViewModel?
let folder: String
let fetcher: LibraryItemFetcher
let listConfig: LibraryListConfig
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
@ -31,24 +33,34 @@ import Views
@Published var showCommunityModal = false
@Published var featureItems = [Models.LibraryItem]()
@Published var listConfig: LibraryListConfig
@Published var showSnackbar = false
@Published var snackbarOperation: SnackbarOperation?
@Published var filters = [InternalFilter]()
@Published var filterState: FetcherFilterState
@Published var searchTerm = ""
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()
@Published var appliedSort = LinkedItemSort.newest.rawValue
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
let fetcher: LibraryItemFetcher
@Published var appliedFilter: InternalFilter? {
didSet {
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
UserDefaults.standard.setValue(appliedFilter?.name, forKey: filterKey)
}
}
init(fetcher: LibraryItemFetcher, filterState: FetcherFilterState, listConfig: LibraryListConfig) {
private var filterState: FetcherFilterState {
FetcherFilterState(folder: folder, searchTerm: searchTerm, selectedLabels: selectedLabels, negatedLabels: negatedLabels, appliedSort: appliedSort, appliedFilter: appliedFilter)
}
init(folder: String, fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) {
self.folder = folder
self.fetcher = fetcher
self.listConfig = listConfig
self.filterState = filterState
super.init()
}
@ -68,10 +80,10 @@ import Views
}
}
func loadFilters(dataService: DataService, filterState: FetcherFilterState) async {
func loadFilters(dataService: DataService) async {
switch filterState.folder {
case "following":
updateFilters(filterState: filterState, newFilters: InternalFilter.DefaultFollowingFilters)
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters)
default:
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
@ -79,15 +91,15 @@ import Views
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
updateFilters(filterState: filterState, newFilters: InternalFilter.make(from: results))
updateFilters(newFilters: InternalFilter.make(from: results))
}
let hasResults = hasLocalResults
Task.detached {
if let downloadedFilters = try? await dataService.filters() {
await self.updateFilters(filterState: filterState, newFilters: downloadedFilters)
await self.updateFilters(newFilters: downloadedFilters)
} else if !hasResults {
await self.updateFilters(filterState: filterState, newFilters: InternalFilter.DefaultInboxFilters)
await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters)
}
}
}
@ -127,7 +139,7 @@ import Views
}
}
func updateFilters(filterState: FetcherFilterState, newFilters: [InternalFilter]) {
func updateFilters(newFilters: [InternalFilter]) {
let appliedFilterName = UserDefaults.standard.string(forKey: "lastSelected-\(filterState.folder)-filter") ?? filterState.folder
filters = newFilters
@ -135,8 +147,8 @@ import Views
.sorted(by: { $0.position < $1.position })
+ [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter]
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != filterState.appliedFilter?.id {
filterState.appliedFilter = newFilter
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter
}
}

View File

@ -11,8 +11,8 @@ import SwiftUI
struct LibraryListView: View {
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
filterState: FetcherFilterState(folder: "inbox"),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],

View File

@ -24,8 +24,8 @@ struct LibraryTabView: View {
}
@StateObject private var followingViewModel = HomeFeedViewModel(
folder: "following",
fetcher: LibraryItemFetcher(),
filterState: FetcherFilterState(folder: "following"),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
@ -35,8 +35,8 @@ struct LibraryTabView: View {
)
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
filterState: FetcherFilterState(folder: "inbox"),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],

View File

@ -69,8 +69,6 @@ struct ProfileView: View {
Form {
innerBody
}
// .navigationTitle("LocalText.genericProfile")
// .navigationBarTitleDisplayMode(.)
.toolbar {
toolbarItems
}
@ -88,7 +86,7 @@ struct ProfileView: View {
ToolbarItem(placement: .barLeading) {
VStack(alignment: .leading) {
Text(LocalText.genericProfile)
.font(Font.system(size: 28, weight: .semibold))
.font(Font.system(size: 24, weight: .semibold))
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}

View File

@ -2,7 +2,7 @@ import CoreData
import Foundation
import Models
public struct InternalFilter: Encodable, Identifiable, Hashable {
public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
public let id: String
public let name: String
public let folder: String
@ -11,6 +11,10 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
public let position: Int
public let defaultFilter: Bool
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
public static var DownloadedFilter: InternalFilter {
InternalFilter(
id: "downloaded",