// swiftlint:disable file_length import SwiftUI struct FavoritesView: View { // swiftlint:disable:this type_body_length @EnvironmentObject var settings: SettingsManager @EnvironmentObject 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? @State private var navigationPath = NavigationPath() @State private var isDetailsSheetPresented = false @State private var selectedFavoriteForDetails: SettingsFavoritePost? @State private var folderHierarchy = FolderHierarchy(folders: []) @State private var displayedFavorites: [SettingsFavoritePost] = [] @State private var displayedPosts: [BooruPost] = [] @State private var displayedColumnsData: [[SettingsFavoritePost]] = [] @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 favorite: SettingsFavoritePost) -> Bool { switch selectedCollectionOption { case .all: return true case .uncategorized: return favorite.folder == nil case .folder(let folderIdentifier): return favorite.folder == folderIdentifier case .topLevelFolder(let topLevelName): guard let favoriteFolderIdentifier = favorite.folder, let favoriteFolder = folderHierarchy.folder(for: favoriteFolderIdentifier) else { return false } return favoriteFolder.topLevelName == topLevelName case .topLevelUncategorized(let topLevelName): guard let favoriteFolderIdentifier = favorite.folder, let favoriteFolder = folderHierarchy.folder(for: favoriteFolderIdentifier) else { return false } return favoriteFolder.name == topLevelName } } private func orderedFavorites(from unsortedFavorites: [SettingsFavoritePost]) -> [SettingsFavoritePost] { unsortedFavorites.sorted { leftFavorite, rightFavorite in switch sort { case .dateAdded: return leftFavorite.date > rightFavorite.date case .lastVisited: return leftFavorite.lastVisit > rightFavorite.lastVisit case .visitCount: return leftFavorite.visitedCount > rightFavorite.visitedCount case .shuffle: return leftFavorite.date > rightFavorite.date } } } private func refreshShuffleOrder(for favoritesToShuffle: [SettingsFavoritePost]) { let currentIdentifiers = favoritesToShuffle.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 shuffledFavorites(from favoritesToShuffle: [SettingsFavoritePost]) -> [SettingsFavoritePost] { favoritesToShuffle.sorted { leftFavorite, rightFavorite in let leftIndex = shuffleOrderByIdentifier[leftFavorite.id] ?? Int.max let rightIndex = shuffleOrderByIdentifier[rightFavorite.id] ?? Int.max if leftIndex != rightIndex { return leftIndex < rightIndex } return leftFavorite.id.uuidString < rightFavorite.id.uuidString } } private func columnsData( for favorites: [SettingsFavoritePost], columnCount: Int ) -> [[SettingsFavoritePost]] { (0.. Bool { settings.favorites.contains { $0.provider == provider } } private func isCollectionPopulated(_ folder: UUID?) -> Bool { settings.favorites.contains { $0.folder == folder } } var body: some View { NavigationStack(path: $navigationPath) { VStack(spacing: 0) { if settings.favorites.isEmpty { ContentUnavailableView( "No Favorites", systemImage: "heart", description: Text("Use the heart button on a post to add it to Favorites.") ) } else { #if os(macOS) if !settings.folders.isEmpty { 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 settings.favorites.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 isProviderFavorited(provider) { Text(provider.rawValue).tag(provider) } } } .padding(.bottom) .padding(.horizontal) .pickerStyle(.menu) } #endif ScrollView { if displayedFavorites.isEmpty, !searchText.isEmpty || selectedCollectionOption != .all { ContentUnavailableView( "No matching favorites found", systemImage: "heart.slash", description: Text("Try adjusting your search or collection filter.") ) } else { gridView(columnCount: settings.thumbnailGridColumns) } } .searchable(text: $searchText) } } } .navigationTitle("Favorites") .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) { 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 settings.favorites.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(!isProviderFavorited(provider)) } } } label: { Label("Provider", systemImage: "globe") } Button( role: .destructive, action: { isShowingRemoveAllConfirmation = true } ) { Label("Delete All", systemImage: "trash") } } #endif } .onAppear { refreshFolderHierarchy() refreshDisplayedFavorites() } .onChange(of: settings.favorites) { refreshDisplayedFavorites() } .onChange(of: searchText) { refreshDisplayedFavorites() } .onChange(of: selectedCollectionOption) { refreshDisplayedFavorites() } .onChange(of: selectedProvider) { refreshDisplayedFavorites() } .onChange(of: sort) { if sort == .shuffle { shouldRefreshShuffleOrder = true } refreshDisplayedFavorites() } .onChange(of: settings.folders) { refreshFolderHierarchy() refreshDisplayedFavorites() } .onChange(of: settings.thumbnailGridColumns) { refreshDisplayedFavorites() } .alert( "Are you sure you want to remove all favorites? This action cannot be undone.", isPresented: $isShowingRemoveAllConfirmation ) { Button("Remove All Favorites", role: .destructive) { settings.favorites.removeAll() } 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.updateFavoriteFolder(withID: id, folder: newFolder.id) } itemPendingCollectionAssignment = nil } .navigationDestination(for: PostWithContext.self) { context in PostDetailsView( post: context.post, navigationPath: $navigationPath, posts: context.posts, baseSearchText: context.baseSearchText ) } .navigationDestination(for: String.self) { tag in PostGridView( selectedTab: $selectedTab, navigationPath: $navigationPath, initialTag: tag ) } .sheet(isPresented: $isDetailsSheetPresented) { if let favorite = selectedFavoriteForDetails { NavigationView { VStack(alignment: .leading, spacing: 16) { Text("Favorite Details") .font(.title2) .fontWeight(.bold) VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Tags") .font(.headline) Text(favorite.tags.joined(separator: ", ").lowercased()) .font(.body) .foregroundStyle(Color.secondary) } VStack(alignment: .leading, spacing: 4) { Text("Favorited") .font(.headline) Text(favorite.createdAt.formatted()) .font(.body) .foregroundStyle(Color.secondary) } VStack(alignment: .leading, spacing: 4) { Text("Provider") .font(.headline) Text(favorite.provider.rawValue) .font(.body) .foregroundStyle(Color.secondary) } if let folder = favorite.folder, let folderName = settings.folderName(forID: folder) { VStack(alignment: .leading, spacing: 4) { Text("Collection") .font(.headline) Text(folderName) .font(.body) .foregroundStyle(Color.secondary) } } VStack(alignment: .leading, spacing: 4) { Text("Visit Count") .font(.headline) Text("\(favorite.visitedCount) times") .font(.body) .foregroundStyle(Color.secondary) } if favorite.lastVisit != favorite.createdAt { VStack(alignment: .leading, spacing: 4) { Text("Last Visited") .font(.headline) Text(favorite.lastVisit.formatted()) .font(.body) .foregroundStyle(Color.secondary) } } } Spacer() } .padding() .navigationTitle("Details") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { #if os(iOS) ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { isDetailsSheetPresented = false } } #else ToolbarItem(placement: .cancellationAction) { Button("Done") { isDetailsSheetPresented = false } } #endif } } } } } @ViewBuilder private func gridView(columnCount: Int) -> some View { ThumbnailGridView( items: displayedFavorites, columnCount: columnCount, useAlternativeGrid: settings.alternativeThumbnailGrid, columnsData: displayedColumnsData ) { favorite in favoriteGridContent(favorite: favorite, posts: displayedPosts) } } private func favoriteGridContent( favorite: SettingsFavoritePost, posts: [BooruPost] ) -> some View { NavigationLink( value: PostWithContext( post: favorite.toBooruPost(), posts: posts, baseSearchText: nil ) ) { FavoritePostThumbnailView( favorite: favorite, ) { settings.removeFavorite(withID: favorite.id) } } .buttonStyle(PlainButtonStyle()) .contextMenu { Button(action: { selectedFavoriteForDetails = favorite isDetailsSheetPresented = true }) { Label("Show Details", systemImage: "info.circle") } Button(action: { manager.searchText += " \(favorite.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") } Menu { FolderMenuView( folderHierarchy: folderHierarchy, onSelectFolder: { folderIdentifier in settings.updateFavoriteFolder(withID: favorite.id, folder: folderIdentifier) }, onCreateTopLevelUncategorized: { topLevelName in let newFolder = SettingsFolder(name: topLevelName) settings.folders.append(newFolder) settings.updateFavoriteFolder(withID: favorite.id, folder: newFolder.id) }, isFolderDisabled: { folderIdentifier in favorite.folder == folderIdentifier } ) Button(action: { itemPendingCollectionAssignment = favorite.id isNewCollectionAlertPresented = true }) { Label("New Collection", systemImage: "plus") } } label: { Label("\(favorite.folder != nil ? "Move" : "Add") to Collection", systemImage: "folder") } if favorite.folder != nil { Button(action: { settings.updateFavoriteFolder(withID: favorite.id, folder: nil) }) { Label("Remove from Collection", systemImage: "folder.badge.minus") } } Button( role: .destructive, action: { settings.removeFavorite(withID: favorite.id) } ) { Label("Delete", systemImage: "trash") } } .accessibilityElement(children: .ignore) .accessibilityLabel(Text(favoriteAccessibilityLabel(for: favorite))) .accessibilityValue(Text(favoriteAccessibilityValue(for: favorite))) .accessibilityHint(Text("Opens post details.")) } private func favoriteAccessibilityLabel(for favorite: SettingsFavoritePost) -> String { let tagSummary = favorite.tags .prefix(3) .map { tag in tag.replacingOccurrences(of: "_", with: " ") } .joined(separator: ", ") if tagSummary.isEmpty { return String(localized: "Favorite post \(favorite.postId)") } return tagSummary } private func favoriteAccessibilityValue(for favorite: SettingsFavoritePost) -> String { String(localized: "Rating \(favorite.rating.rawValue). Provider \(favorite.provider.rawValue).") } } extension SettingsFavoritePost { func toBooruPost() -> BooruPost { BooruPost( id: postId, height: height, score: "0", fileURL: fileUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, parentID: "0", sampleURL: previewUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, sampleWidth: width, sampleHeight: height, previewURL: thumbnailUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, rating: rating, tags: tags, width: width, change: nil, md5: "", creatorID: "0", authorID: nil, createdAt: createdAt, status: "active", source: "", previewWidth: width, previewHeight: height ) } } #Preview { FavoritesView(selectedTab: .constant(1), isPresented: .constant(false)) .environmentObject(SettingsManager()) .environmentObject(BooruManager(.yandere)) }