// 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 @Binding var viewStates: [UUID: PostGridViewState] @Binding var viewStateSelection: UUID? @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] = [] @Environment(\.isSearching) private var isSearching private var queryID: UUID { manager.searchHistory[manager.historyIndex].id } private var activePosts: [BooruPost] { guard manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count else { return [] } return viewStates[queryID]?.posts .filter { settings.displayRatings.contains($0.rating) } ?? [] } var body: some View { ScrollViewReader { proxy in ScrollView { Group { if let error = manager.error { ContentUnavailableView( "Provider Error", systemImage: "exclamationmark.triangle.fill", description: Text(error.localizedDescription) ) } if activePosts.isEmpty, manager.isLoading { 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) } else { let columnCount = settings.thumbnailGridColumns if settings.alternativeThumbnailGrid { let columnsData = (0..= 0 && manager.historyIndex < manager.searchHistory.count { updateViewState(for: queryID, posts: newPosts) } } .onChange(of: manager.currentPage) { _, newPage in if manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count { let queryID = manager.searchHistory[manager.historyIndex].id updateViewState(for: queryID, currentPage: newPage) } } .onChange(of: manager.selectedPost) { _, newPost in let queryID = manager.searchHistory.last { $0.tags == manager.tags }?.id ?? UUID() updateViewState(for: queryID, selectedPost: newPost, resetSelectedPost: newPost == nil) } .onChange(of: manager.historyIndex) { _, newIndex in guard newIndex >= 0 && newIndex < manager.searchHistory.count else { return } let queryID = manager.searchHistory[newIndex].id if let state = viewStates[queryID] { manager.posts = state.posts manager.currentPage = state.currentPage manager.selectedPost = state.selectedPost } else { manager.posts = [] manager.currentPage = 1 Task(priority: .userInitiated) { await manager.fetchPosts( page: 1, tags: manager.searchHistory[newIndex].tags, replace: true ) } } proxy.scrollTo(queryID, anchor: .top) } .toolbar { #if os(macOS) ToolbarItem { Button(action: { Task { await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) } }) { Label("Refresh", systemImage: "arrow.clockwise") } .disabled(manager.isLoading) } #endif #if !os(macOS) PlatformSpecificToolbarItem { Button(action: { Task { isSearchHistoryPresented.toggle() } }) { Label("Search History", systemImage: "clock.arrow.circlepath") } } if #available(iOS 26, *), manager.isLoading || manager.isNavigatingHistory { ToolbarItem(placement: .status) { ProgressView() } } #endif PlatformSpecificToolbarItem { PostGridBookmarkButtonView() .disabled(manager.tags.isEmpty) } PlatformSpecificToolbarItem { Button( action: { Task(priority: .userInitiated) { await manager.loadNextPage() } } ) { Label( "Manually Load Next Page", systemImage: "arrow.down.to.line" ) } .disabled(manager.isLoading) } #if !os(macOS) if #unavailable(iOS 26), manager.isLoading || manager.isNavigatingHistory { ToolbarItem(placement: .topBarTrailing) { ProgressView() } } #endif PlatformSpecificToolbarItem(placement: .navigation) { Menu { ForEach( Array(manager.searchHistory.enumerated().filter { $0.offset < manager.historyIndex }), id: \.offset ) { offset, query in Button(action: { manager.historyIndex = offset }) { Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " ")) } } } label: { Label("Previous Search", systemImage: "chevron.left") } primaryAction: { withAnimation { manager.goBackInHistory() } } .disabled(!manager.canGoBackInHistory) } PlatformSpecificToolbarItem(placement: .navigation) { Menu { ForEach( Array(manager.searchHistory.enumerated().filter { $0.offset > 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) } } .navigationTitle("Posts") .refreshable { 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 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 { Button { if !manager.isLoading { manager.selectedPost = post } } label: { PostGridThumbnailView(post: post, posts: activePosts) } .buttonStyle(PlainButtonStyle()) .contextMenu { Button(action: { manager.selectedPost = post }) { Label("Select Post", systemImage: "arrow.right.circle") } } } 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 [] } } private func updateViewState( for queryID: UUID, posts: [BooruPost] = [], currentPage: Int? = nil, selectedPost: BooruPost? = nil, resetSelectedPost: Bool = false, ) { let wasNewlyCreated = viewStates[queryID] == nil var state = viewStates[queryID] ?? PostGridViewState() if !posts.isEmpty { state.posts = posts } if let currentPage { state.currentPage = currentPage } if let selectedPost { state.selectedPost = selectedPost } else if resetSelectedPost { state.selectedPost = nil } viewStates[queryID] = state if wasNewlyCreated { viewStateSelection = queryID } } }