// swiftlint:disable file_length import SwiftUI 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 suppressNextSearchSubmit = false @State private var searchTask: Task? @State private var suggestions: [BooruTag] = [] 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? @State private var hasAppearedBefore = false @State private var currentLocalTask: Task? @State private var previousNavigationPathCount = 0 @State private var displayedPosts: [BooruPost] = [] @State private var displayedColumnsData: [[BooruPost]] = [] @State private var folderHierarchy = FolderHierarchy(folders: []) init( selectedTab: Binding, navigationPath: Binding, initialTag: String? = nil ) { self._selectedTab = selectedTab self.initialTag = initialTag self._navigationPath = navigationPath } @Environment(\.isSearching) private var isSearching private var isLoading: Bool { initialTag != nil ? localIsLoading : manager.isLoading } private func currentPostsSource() -> [BooruPost] { initialTag != nil ? localPosts : manager.posts } private func refreshFolderHierarchy() { folderHierarchy = FolderHierarchy(folders: settings.folders) } private func columnsData( for posts: [BooruPost], columnCount: Int ) -> [[BooruPost]] { (0.. { 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 displayedPosts.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 { ThumbnailGridView( items: displayedPosts, columnCount: columnCount, useAlternativeGrid: settings.alternativeThumbnailGrid, columnsData: displayedColumnsData ) { post in waterfallGridContent(post: post) } } var body: some View { ScrollView { gridContent .transition(.opacity) } #if os(iOS) .searchable( text: searchText, isPresented: $isSearchablePresented, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Tags" ) #else .searchable( text: searchText, isPresented: $isSearchablePresented, prompt: "Tags" ) #endif .searchSuggestions { if settings.searchSuggestionsMode != .disabled, isSearchablePresented { SearchSuggestionsView( items: searchSuggestionsItems(), searchText: searchText, suppressNextSearchSubmit: $suppressNextSearchSubmit ) } } .onChange(of: searchText.wrappedValue) { _, newValue in if settings.searchSuggestionsMode == .tags { searchTask?.cancel() searchTask = Task { try? await Task.sleep(nanoseconds: 300_000_000) guard !Task.isCancelled else { return } let searchTag = newValue.split(separator: " ").last.map(String.init) ?? "" if !searchTag.isEmpty { suggestions = await manager.searchTags(name: searchTag) } else { suggestions = [] } } } } .onSubmit(of: .search) { if suppressNextSearchSubmit { suppressNextSearchSubmit = false return } Task(priority: .userInitiated) { if initialTag != nil { await performLocalSearch() } else { await manager.performSearch(settings: settings) } } } .onChange(of: isSearchablePresented) { _, isPresented in if !isPresented, searchText.wrappedValue.isEmpty, !manager.isNavigatingHistory { Task(priority: .userInitiated) { if initialTag != nil { await performLocalSearch() } else { await manager.performSearch() } } } } .onChange(of: navigationPath) { _, newPath in let currentPathCount = newPath.count previousNavigationPathCount = currentPathCount } .onChange(of: localPosts) { refreshDisplayedPosts() } .onChange(of: manager.posts) { refreshDisplayedPosts() } .onChange(of: settings.displayRatings) { refreshDisplayedPosts() } .onChange(of: settings.thumbnailGridColumns) { refreshDisplayedPosts() } .onChange(of: settings.folders) { refreshFolderHierarchy() } .onAppear { refreshFolderHierarchy() refreshDisplayedPosts() previousNavigationPathCount = navigationPath.count if let initialTag { if localSearchText.isEmpty || !hasAppearedBefore { localSearchText = initialTag } if !hasAppearedBefore { hasAppearedBefore = true Task(priority: .userInitiated) { await performLocalSearch() } } else { let currentTags = localSearchText.components(separatedBy: .whitespaces).filter { tag in !tag.isEmpty } let hasPosts = !localPosts.isEmpty let initialTags = initialTag.components(separatedBy: .whitespaces).filter { !$0.isEmpty } let needsFetch = !hasPosts || currentTags != initialTags if needsFetch { currentLocalTask?.cancel() currentLocalTask = Task(priority: .userInitiated) { await fetchLocalPosts( page: 1, tags: currentTags, replace: true ) } } } } else { if manager.posts.isEmpty, !manager.isNavigatingHistory, !manager.isLoading { Task(priority: .userInitiated) { await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) } } } } .toolbar { #if os(macOS) ToolbarItem { Button(action: { if initialTag != nil { currentLocalTask?.cancel() currentLocalTask = Task(priority: .userInitiated) { await fetchLocalPosts( page: 1, tags: localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty }, replace: true ) } } else { Task(priority: .userInitiated) { await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) } } }) { Label("Refresh", systemImage: "arrow.clockwise") } .disabled(isLoading) } #endif #if !os(macOS) PlatformSpecificToolbarItem { Button(action: { Task { isSearchHistoryPresented.toggle() } }) { Label("Search History", systemImage: "clock.arrow.circlepath") } } if #available(iOS 26, *), isLoading || manager.isNavigatingHistory { ToolbarItem(placement: .status) { ProgressView() } } #endif PlatformSpecificToolbarItem(placement: .automatic) { PostGridBookmarkButtonView( tags: initialTag != nil ? localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty } : manager.tags, provider: manager.provider ) .disabled(searchText.wrappedValue.isEmpty) } PlatformSpecificToolbarItem { Button( action: { Task(priority: .userInitiated) { if initialTag != nil { await loadLocalNextPage() } else { await manager.loadNextPage() } } } ) { Label( "Load Next Page", systemImage: "arrow.down.to.line" ) } .disabled(isLoading) } #if !os(macOS) if #unavailable(iOS 26), isLoading || manager.isNavigatingHistory { ToolbarItem(placement: .topBarTrailing) { ProgressView() } } #endif #if os(macOS) let placement = ToolbarItemPlacement.navigation #else let placement = ToolbarItemPlacement.topBarLeading #endif if initialTag == nil { PlatformSpecificToolbarItem(placement: placement) { 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) .id("previousSearchMenu") } PlatformSpecificToolbarItem(placement: placement) { 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) .id("nextSearchMenu") } } } .navigationTitle(initialTag != nil ? initialTag! : "Posts") .refreshable { if initialTag != nil { currentLocalTask?.cancel() currentLocalTask = Task(priority: .userInitiated) { await fetchLocalPosts( page: 1, tags: localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty }, replace: true ) } await currentLocalTask?.value } 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 { Button(action: { let context = PostWithContext( post: post, posts: initialTag != nil ? localPosts : nil, baseSearchText: initialTag != nil ? localSearchText : nil ) navigationPath.append(context) }) { PostGridThumbnailView( post: post, posts: displayedPosts, 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()) .contextMenu { let isFavorited = settings.isFavorite(postId: post.id, provider: manager.provider) Button(action: { if isFavorited { settings.removeFavorite(withPostId: post.id, provider: manager.provider) } else { settings.addFavorite(post: post, provider: manager.provider) } }) { if isFavorited { Label("Remove from Favorites", systemImage: "heart.fill") } else { Label("Add to Favorites", systemImage: "heart") } } Menu { FolderMenuView( folderHierarchy: folderHierarchy, showsTopLevelUncategorized: false, onSelectFolder: { folderIdentifier in settings.addFavorite(post: post, provider: manager.provider, folder: folderIdentifier) }, isFolderDisabled: { folderIdentifier in isFavoritedInFolder(post: post, folderId: folderIdentifier) } ) } label: { Label("Add to Collection", systemImage: "folder.badge.plus") } } .accessibilityElement(children: .ignore) .accessibilityLabel(Text(postAccessibilityLabel(for: post))) .accessibilityValue(Text(postAccessibilityValue(for: post))) .accessibilityHint(Text("Opens post details.")) } private func isFavoritedInFolder(post: BooruPost, folderId: UUID) -> Bool { settings.favorites.contains { favorite in favorite.folder == folderId && favorite.postId == post.id && favorite.provider == manager.provider } } 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 postAccessibilityLabel(for post: BooruPost) -> String { let tagSummary = post.tags .prefix(3) .map { tag in tag.replacingOccurrences(of: "_", with: " ") } .joined(separator: ", ") if tagSummary.isEmpty { return String(localized: "Post \(post.id)") } return tagSummary } private func postAccessibilityValue(for post: BooruPost) -> String { String(localized: "Rating \(post.rating.rawValue). Score \(post.score).") } // MARK: - Local Search Methods private func performLocalSearch() async { let inputTags = localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty } guard !inputTags.isEmpty else { return } let query = BooruSearchQuery( provider: manager.provider, tags: inputTags ) settings.appendToSearchHistory(query) currentLocalTask?.cancel() currentLocalTask = Task(priority: .userInitiated) { await fetchLocalPosts(page: 1, tags: inputTags, replace: true) } await currentLocalTask?.value } private func loadLocalNextPage() async { guard !localIsLoading else { return } localCurrentPage += 1 let inputTags = localSearchText.components(separatedBy: .whitespaces).filter { component in !component.isEmpty } currentLocalTask?.cancel() currentLocalTask = Task(priority: .userInitiated) { await fetchLocalPosts(page: localCurrentPage, tags: inputTags, replace: false) } await currentLocalTask?.value } private func fetchLocalPosts( page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false ) async { guard !localIsLoading else { return } localIsLoading = true localError = nil defer { localIsLoading = false } let flavor = manager.flavor let provider = manager.provider let pageValue = flavor == .gelbooru ? page - 1 : page guard let url = manager.url(forPosts: pageValue, limit: limit, tags: tags) else { return } do { let data = try await manager.requestURL(url) guard !Task.isCancelled else { return } 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) } } guard !Task.isCancelled else { return } withAnimation(nil) { if replace { localPosts = newPosts localCurrentPage = 1 } else { localPosts.append(contentsOf: newPosts) } localEndOfData = newPosts.isEmpty } } catch { if (error as? URLError)?.code != .cancelled { localError = error debugPrint("PostGridView.fetchLocalPosts: \(error)") } } } }