// swiftlint:disable file_length import SwiftUI // swiftlint:disable:next type_body_length struct GenericListView: View { @EnvironmentObject private var settings: SettingsManager @EnvironmentObject private var manager: BooruManager @Binding var selectedTab: Int @State private var searchText: String = "" @State private var isShowingRemoveAllConfirmation = false @Binding var isPresented: Bool @State private var isNewCollectionAlertPresented = false @State private var newCollectionName = "" @State private var itemPendingCollectionAssignment: UUID? @State private var isCollectionErrorAlertPresented = false @State private var selectedCollectionOption: CollectionPickerOption = .all @State private var sort: SettingsBookmarkSort = .dateAdded @State private var isCollectionPickerPresented = false @State private var isProviderPickerPresented = false @State private var selectedProvider: BooruProvider? let allowBookmarking: Bool let title: String let emptyMessage: String let emptyIcon: String let emptyDescription: String let removeAllMessage: String let removeAllButtonText: String let items: [T] let removeAction: (IndexSet) -> Void let removeActionUUID: (UUID) -> Void let removeAllAction: () -> Void @State private var folderHierarchy = FolderHierarchy(folders: []) @State private var displayedItems: [T] = [] @State private var shuffleOrderByIdentifier: [UUID: Int] = [:] @State private var shuffleSourceIdentifiers: [UUID] = [] @State private var shouldRefreshShuffleOrder = true private func refreshFolderHierarchy() { folderHierarchy = FolderHierarchy(folders: settings.folders) } private func matchesSelectedCollection(for item: T) -> Bool { switch selectedCollectionOption { case .all: return true case .uncategorized: return item.folder == nil case .folder(let folderIdentifier): return item.folder == folderIdentifier case .topLevelFolder(let topLevelName): guard let itemFolderIdentifier = item.folder, let itemFolder = folderHierarchy.folder(for: itemFolderIdentifier) else { return false } return itemFolder.topLevelName == topLevelName case .topLevelUncategorized(let topLevelName): guard let itemFolderIdentifier = item.folder, let itemFolder = folderHierarchy.folder(for: itemFolderIdentifier) else { return false } return itemFolder.name == topLevelName } } private func sortedItems(from unsortedItems: [T]) -> [T] { if allowBookmarking { return unsortedItems.sorted { $0.date > $1.date } } return unsortedItems.sorted { (leftItem: T, rightItem: T) in switch sort { case .dateAdded: return leftItem.date > rightItem.date case .lastVisited: return leftItem.lastVisit > rightItem.lastVisit case .visitCount: return leftItem.visitedCount > rightItem.visitedCount case .shuffle: return leftItem.date > rightItem.date } } } private func refreshShuffleOrder(for itemsToShuffle: [T]) { let currentIdentifiers = itemsToShuffle.map(\.id) if shouldRefreshShuffleOrder || shuffleSourceIdentifiers != currentIdentifiers { var refreshedShuffleOrderByIdentifier: [UUID: Int] = [:] let shuffledIdentifiers = currentIdentifiers.shuffled() for (identifierIndex, identifier) in shuffledIdentifiers.enumerated() { refreshedShuffleOrderByIdentifier[identifier] = identifierIndex } shuffleOrderByIdentifier = refreshedShuffleOrderByIdentifier shuffleSourceIdentifiers = currentIdentifiers shouldRefreshShuffleOrder = false } } private func shuffledItems(from itemsToShuffle: [T]) -> [T] { itemsToShuffle.sorted { leftItem, rightItem in let leftIndex = shuffleOrderByIdentifier[leftItem.id] ?? Int.max let rightIndex = shuffleOrderByIdentifier[rightItem.id] ?? Int.max if leftIndex != rightIndex { return leftIndex < rightIndex } return leftItem.id.uuidString < rightItem.id.uuidString } } private func refreshDisplayedItems() { let normalizedSearchText = searchText.lowercased() let matchingItems = items.filter { item in let matchesSearch = normalizedSearchText.isEmpty || item.tags .joined(separator: " ") .lowercased() .contains(normalizedSearchText) let matchesProvider = selectedProvider == nil || item.provider == selectedProvider return matchesSelectedCollection(for: item) && matchesSearch && matchesProvider } if !allowBookmarking, sort == .shuffle { refreshShuffleOrder(for: matchingItems) displayedItems = shuffledItems(from: matchingItems) return } displayedItems = sortedItems(from: matchingItems) } @ViewBuilder private var listContent: some View { List { if displayedItems.isEmpty, !searchText.isEmpty || selectedCollectionOption != .all { Text("No matching items found") .foregroundColor(.secondary) } ForEach(displayedItems, id: \.id) { item in itemButtonContent(item: item) } .onDelete { offsets in let itemsToRemove = offsets.compactMap { index in index < displayedItems.count ? displayedItems[index] : nil } let originalIndices = IndexSet( itemsToRemove.compactMap { itemToRemove in items.firstIndex { $0.id == itemToRemove.id } } ) removeAction(originalIndices) } } } private func isProviderBookmarked(_ provider: BooruProvider) -> Bool { settings.bookmarks.contains { $0.provider == provider } } private func isCollectionPopulated(_ folder: UUID?) -> Bool { settings.bookmarks.contains { $0.folder == folder } } var body: some View { NavigationStack { VStack(spacing: 0) { if items.isEmpty { ContentUnavailableView( emptyMessage, systemImage: emptyIcon, description: Text(emptyDescription) ) } else { #if os(macOS) if !settings.folders.isEmpty, !allowBookmarking { Picker("Sort", selection: $sort) { ForEach(SettingsBookmarkSort.allCases, id: \.rawValue) { sortMode in Text(sortMode.rawValue).tag(sortMode) } } .padding() .pickerStyle(.menu) Picker("Collection", selection: $selectedCollectionOption) { Text(CollectionPickerOption.all.name(settings: settings)) .tag(CollectionPickerOption.all) if items.contains(where: { $0.folder == nil }) { Text(CollectionPickerOption.uncategorized.name(settings: settings)) .tag(CollectionPickerOption.uncategorized) } ForEach(folderHierarchy.rootFolders, id: \.id) { folder in Text(folder.name).tag(CollectionPickerOption.folder(folder.id)) } ForEach(folderHierarchy.sortedTopLevelNames, id: \.self) { topLevelName in Menu { Button(action: { selectedCollectionOption = .topLevelFolder(topLevelName) }) { Text("All \(topLevelName)") } if folderHierarchy.hasTopLevelUncategorized(forTopLevelName: topLevelName) { Button(action: { selectedCollectionOption = .topLevelUncategorized(topLevelName) }) { Text("Uncategorized") } } Divider() ForEach( folderHierarchy.folders(forTopLevelName: topLevelName), id: \.id ) { folder in Button(action: { selectedCollectionOption = .folder(folder.id) }) { Text(folder.shortName) } } } label: { Text(topLevelName) } } } .padding(.bottom) .padding(.horizontal) .pickerStyle(.menu) Picker("Provider", selection: $selectedProvider) { Text("All").tag(nil as BooruProvider?) ForEach(BooruProvider.allCases, id: \.rawValue) { provider in if isProviderBookmarked(provider) { Text(provider.rawValue).tag(provider) } } } .padding(.bottom) .padding(.horizontal) .pickerStyle(.menu) } #endif if allowBookmarking { listContent #if os(iOS) .searchable(text: $searchText, placement: .navigationBarDrawer) #endif } else { listContent.searchable(text: $searchText) } } } } .navigationTitle(title) .refreshable { settings.syncFromCloud() } .toolbar { #if os(macOS) ToolbarItem { Button(action: { settings.syncFromCloud() }) { Label("Refresh", systemImage: "arrow.clockwise") } } #endif #if os(macOS) ToolbarItem { Button( role: .destructive, action: { isShowingRemoveAllConfirmation = true } ) { Label("Remove All", systemImage: "trash") } } #endif #if os(iOS) ToolbarItemGroup(placement: .secondaryAction) { if !allowBookmarking { Menu { Picker("Sort By", selection: $sort) { ForEach(SettingsBookmarkSort.allCases, id: \.rawValue) { sortMode in Text(sortMode.rawValue).tag(sortMode) } } } label: { Label("Sort By", systemImage: "arrow.up.arrow.down") } Menu { Button(action: { selectedCollectionOption = .all }) { Label("All", systemImage: "folder") } if items.contains(where: { $0.folder == nil }) { Button(action: { selectedCollectionOption = .uncategorized }) { Label("Uncategorized", systemImage: "folder.badge.questionmark") } } ForEach(folderHierarchy.rootFolders, id: \.id) { folder in Button(action: { selectedCollectionOption = .folder(folder.id) }) { Label(folder.name, systemImage: "folder") } } ForEach(folderHierarchy.sortedTopLevelNames, id: \.self) { topLevelName in Menu { Button(action: { selectedCollectionOption = .topLevelFolder(topLevelName) }) { Label("All \(topLevelName)", systemImage: "folder.fill") } if folderHierarchy.hasTopLevelUncategorized(forTopLevelName: topLevelName) { Button(action: { selectedCollectionOption = .topLevelUncategorized(topLevelName) }) { Label("Uncategorized", systemImage: "folder.badge.questionmark") } } Divider() ForEach( folderHierarchy.folders(forTopLevelName: topLevelName), id: \.id ) { folder in Button(action: { selectedCollectionOption = .folder(folder.id) }) { Label(folder.shortName, systemImage: "folder") } } } label: { Label(topLevelName, systemImage: "folder.fill") } } } label: { Label("Collection", systemImage: "folder") } Menu { Picker("Provider", selection: $selectedProvider) { Text("All").tag(nil as BooruProvider?) ForEach(BooruProvider.allCases, id: \.rawValue) { provider in Text(provider.rawValue) .tag(provider) .selectionDisabled(!isProviderBookmarked(provider)) } } } label: { Label("Provider", systemImage: "globe") } Button( role: .destructive, action: { isShowingRemoveAllConfirmation = true } ) { Label("Delete All", systemImage: "trash") } } } #endif } .onAppear { refreshFolderHierarchy() refreshDisplayedItems() } .onChange(of: items) { refreshDisplayedItems() } .onChange(of: searchText) { refreshDisplayedItems() } .onChange(of: selectedCollectionOption) { refreshDisplayedItems() } .onChange(of: selectedProvider) { refreshDisplayedItems() } .onChange(of: sort) { if sort == .shuffle { shouldRefreshShuffleOrder = true } refreshDisplayedItems() } .onChange(of: settings.folders) { refreshFolderHierarchy() refreshDisplayedItems() } .alert( removeAllMessage, isPresented: $isShowingRemoveAllConfirmation ) { Button(removeAllButtonText, role: .destructive) { removeAllAction() } Button("Cancel", role: .cancel) { () } } .collectionAlerts( isNewCollectionAlertPresented: $isNewCollectionAlertPresented, newCollectionName: $newCollectionName, isCollectionErrorAlertPresented: $isCollectionErrorAlertPresented ) { newCollectionName in let newFolder = SettingsFolder(name: newCollectionName) settings.folders.append(newFolder) if let id = itemPendingCollectionAssignment { settings.updateBookmarkFolder(withID: id, folder: newFolder.id) } itemPendingCollectionAssignment = nil } } func itemButtonContent(item: T) -> some View { Button(action: { let previousProvider = settings.preferredBooru settings.preferredBooru = item.provider manager.searchText = item.tags.joined(separator: " ") selectedTab = 0 isPresented.toggle() if previousProvider == settings.preferredBooru { let localManager = manager let localSettings = settings Task(priority: .userInitiated) { await localManager.performSearch(settings: localSettings) } } if !allowBookmarking { settings.incrementBookmarkVisitCount(withID: item.id) settings.updateBookmarkLastVisit(withID: item.id) } }) { GenericItemView( item: item, removeAction: removeActionUUID ) } .contextMenu { Button(action: { manager.searchText += " \(item.tags.joined(separator: " "))" manager.selectedPost = nil let localManager = manager let localSettings = settings isPresented.toggle() Task(priority: .userInitiated) { await localManager.performSearch(settings: localSettings) } }) { Label("Add Tag to Search", systemImage: "plus") } if allowBookmarking { let isBookmarked = settings.bookmarks.contains { $0.tags == item.tags } Button(action: { if isBookmarked { settings.removeBookmark(withTags: item.tags) } else { settings.addBookmark(provider: settings.preferredBooru, tags: item.tags) } }) { if isBookmarked { Label("Remove Bookmark", systemImage: "bookmark.fill") } else { Label("Bookmark Tag\(item.tags.count == 1 ? "" : "s")", systemImage: "bookmark") } } } Menu { FolderMenuView( folderHierarchy: folderHierarchy, onSelectFolder: { folderIdentifier in settings.updateBookmarkFolder(withID: item.id, folder: folderIdentifier) }, onCreateTopLevelUncategorized: { topLevelName in let newFolder = SettingsFolder(name: topLevelName) settings.folders.append(newFolder) settings.updateBookmarkFolder(withID: item.id, folder: newFolder.id) }, isFolderDisabled: { folderIdentifier in item.folder == folderIdentifier } ) Button(action: { itemPendingCollectionAssignment = item.id isNewCollectionAlertPresented = true }) { Label("New Collection", systemImage: "plus") } } label: { Label("\(item.folder != nil ? "Move" : "Add") to Collection", systemImage: "folder") } if item.folder != nil { Button(action: { settings.updateBookmarkFolder(withID: item.id, folder: nil) }) { Label("Remove from Collection", systemImage: "folder.badge.minus") } } Button( role: .destructive, action: { removeActionUUID(item.id) } ) { Label("Delete", systemImage: "trash") } } #if os(macOS) .buttonStyle(.plain) #endif } }