// swiftlint:disable file_length import SwiftUI import WaterfallGrid struct PostGridView: View { // swiftlint:disable:this type_body_length @EnvironmentObject var settings: SettingsManager @EnvironmentObject var manager: BooruManager @State private var isSearchHistoryPresented = false @Binding var selectedTab: Int @State private var isSearchablePresented = false @State private var cachedSuggestions: [Either] = [] @State private var suppressNextSearchSubmit = false @State private var searchTask: Task? @State private var suggestions: [BooruTag] = [] @State private var cachedColumnsData: ColumnsDataCache? let initialTag: String? @Binding var navigationPath: NavigationPath @State private var localPosts: [BooruPost] = [] @State private var localIsLoading = false @State private var localCurrentPage = 1 @State private var localSearchText = "" @State private var localEndOfData = false @State private var localError: Error? init( selectedTab: Binding, navigationPath: Binding, initialTag: String? = nil ) { self._selectedTab = selectedTab self.initialTag = initialTag self._navigationPath = navigationPath } @Environment(\.isSearching) private var isSearching private var activePosts: [BooruPost] { let posts = initialTag != nil ? localPosts : manager.posts return posts.filter { settings.displayRatings.contains($0.rating) } } private var isLoading: Bool { initialTag != nil ? localIsLoading : manager.isLoading } private var searchText: Binding { if initialTag != nil { return Binding( get: { localSearchText }, set: { localSearchText = $0 } ) } return Binding( get: { manager.searchText }, set: { manager.searchText = $0 } ) } @ViewBuilder private var gridContent: some View { if let error = (initialTag != nil ? localError : manager.error) { ContentUnavailableView( "Provider Error", systemImage: "exclamationmark.triangle.fill", description: Text(error.localizedDescription) ) } if activePosts.isEmpty, isLoading { placeholderGrid } else { gridView(columnCount: settings.thumbnailGridColumns) } } @ViewBuilder private var placeholderGrid: some View { let gridItems = Array( repeating: GridItem(.flexible()), count: settings.thumbnailGridColumns ) LazyVGrid(columns: gridItems) { ForEach(0..<(50 / settings.thumbnailGridColumns), id: \.self) { _ in PostGridThumbnailPlaceholderView() } } #if os(macOS) .padding(8) #else .padding(.horizontal) #endif .transition(.opacity) } @ViewBuilder private func gridView(columnCount: Int) -> some View { if settings.alternativeThumbnailGrid { let columnsData = getColumnsData(columnCount: columnCount) HStack(alignment: .top) { ForEach(0.. [[BooruPost]] { if let cached = cachedColumnsData, cached == ColumnsDataCache( data: cached.data, columnCount: columnCount, posts: activePosts ) { return cached.data } let computedData = (0.. manager.historyIndex }), id: \.offset ) { offset, query in Button(action: { manager.historyIndex = offset }) { Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " ")) } } } label: { Label("Next Search", systemImage: "chevron.right") } primaryAction: { withAnimation { manager.goForwardInHistory() } } .disabled(!manager.canGoForwardInHistory) .id("nextSearchMenu") } } } .navigationTitle(initialTag != nil ? initialTag! : "Posts") .refreshable { if initialTag != nil { await fetchLocalPosts( page: 1, tags: localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty }, replace: true ) } else { manager.clearCachedPages() Task(priority: .userInitiated) { await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) } } } .sheet(isPresented: $isSearchHistoryPresented) { PostGridSearchHistoryView( selectedTab: $selectedTab, isPresented: $isSearchHistoryPresented ) } #if os(iOS) .gesture( DragGesture() .onEnded { value in if initialTag == nil { if value.startLocation.x < 50 && value.translation.width > 100 { withAnimation { manager.goBackInHistory() } debugPrint("ContentView: Swipe left, \(manager.searchHistory)") } if value.startLocation.x > (UIScreen.main.bounds.width - 50) && value.translation.width < -100 { withAnimation { manager.goForwardInHistory() } debugPrint("ContentView: Swipe right, \(manager.searchHistory)") } } } ) #endif } private func waterfallGridContent(post: BooruPost) -> some View { NavigationLink( value: PostWithContext(post: post, posts: initialTag != nil ? localPosts : nil) ) { PostGridThumbnailView( post: post, posts: activePosts, isNestedView: initialTag != nil, endOfData: initialTag != nil ? localEndOfData : manager.endOfData, onLoadNextPage: { if initialTag != nil { await loadLocalNextPage() } else { await manager.loadNextPage() } }, selectedPost: initialTag != nil ? nil : manager.selectedPost ) } .buttonStyle(PlainButtonStyle()) } private func searchSuggestionsItems() -> [Either] { switch settings.searchSuggestionsMode { case .tags: return suggestions.map { .left($0) } case .history: return settings.searchHistory.map { .right($0) } case .disabled: return [] } } // MARK: - Local Search Methods private func performLocalSearch() async { let inputTags = localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty } await fetchLocalPosts(page: 1, tags: inputTags, replace: true) } private func loadLocalNextPage() async { guard !localIsLoading else { return } localCurrentPage += 1 let inputTags = localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty } await fetchLocalPosts(page: localCurrentPage, tags: inputTags, replace: false) } private func fetchLocalPosts( page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false ) async { guard !localIsLoading else { return } localIsLoading = true defer { localIsLoading = false } let flavor = manager.flavor let provider = manager.provider let pageValue = flavor == .gelbooru ? page - 1 : page guard let url = manager.urlForPosts(page: pageValue, limit: limit, tags: tags) else { return } do { let data = try await manager.requestURL(url) let newPosts = await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let parsedPosts = BooruManager.parsePosts( from: data, flavor: flavor, provider: provider ) .sorted { $0.createdAt > $1.createdAt } continuation.resume(returning: parsedPosts) } } withAnimation(nil) { if replace { localPosts = newPosts localCurrentPage = 1 } else { localPosts.append(contentsOf: newPosts) } localEndOfData = newPosts.isEmpty } } catch { localError = error debugPrint("PostGridView.fetchLocalPosts: \(error)") } } }