diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 3078e3e44..d56fe3e74 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -31,6 +31,7 @@ struct AnimatingCellHeight: AnimatableModifier { @State var settingsPresented = false @State var isListScrolled = false @State var listTitle = "" + @State var isEditMode: EditMode = .inactive @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -39,6 +40,8 @@ struct AnimatingCellHeight: AnimatableModifier { @AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true @ObservedObject var viewModel: HomeFeedViewModel + @State private var selection = Set() + func loadItems(isRefresh: Bool) { Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } } @@ -58,6 +61,7 @@ struct AnimatingCellHeight: AnimatableModifier { listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, + selection: $selection, viewModel: viewModel, showFeatureCards: showFeatureCards ) @@ -95,7 +99,6 @@ struct AnimatingCellHeight: AnimatableModifier { FilterSelectorView(viewModel: viewModel) } } - // .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarItems } @@ -129,21 +132,6 @@ struct AnimatingCellHeight: AnimatableModifier { } } } -// .formSheet(isPresented: $viewModel.snoozePresented) { -// SnoozeView( -// snoozePresented: $viewModel.snoozePresented, -// itemToSnoozeID: $viewModel.itemToSnoozeID -// ) { snoozeParams in -// Task { -// await viewModel.snoozeUntil( -// dataService: dataService, -// linkId: snoozeParams.feedItemId, -// until: snoozeParams.snoozeUntilDate, -// successMessage: snoozeParams.successMessage -// ) -// } -// } -// } .fullScreenCover(isPresented: $searchPresented) { LibrarySearchView(homeFeedViewModel: self.viewModel) } @@ -162,6 +150,7 @@ struct AnimatingCellHeight: AnimatableModifier { loadItems(isRefresh: false) } } + .environment(\.editMode, self.$isEditMode) } var toolbarItems: some ToolbarContent { @@ -216,13 +205,11 @@ struct AnimatingCellHeight: AnimatableModifier { ToolbarItem(placement: .barTrailing) { if UIDevice.isIPhone { Menu(content: { -// Button(action: { -// // withAnimation { -// viewModel.isInMultiSelectMode.toggle() -// // } -// }, label: { -// Label(viewModel.isInMultiSelectMode ? "End Multiselect" : "Select Multiple", systemImage: "checkmark.circle") -// }) + Button(action: { + isEditMode = isEditMode == .inactive ? .active : .inactive + }, label: { + Text(isEditMode == .inactive ? "Select Multiple" : "End Multiselect") + }) Button(action: { addLinkPresented = true }, label: { Label("Add Link", systemImage: "plus.circle") }) @@ -238,15 +225,22 @@ struct AnimatingCellHeight: AnimatableModifier { EmptyView() } } -// if viewModel.isInMultiSelectMode { -// ToolbarItemGroup(placement: .bottomBar) { -// Button(action: {}, label: { Image(systemName: "archivebox") }) -// Button(action: {}, label: { Image(systemName: "trash") }) -// Button(action: {}, label: { Image.label }) -// Spacer() -// Button(action: { viewModel.isInMultiSelectMode = false }, label: { Text("Cancel") }) -// } -// } + ToolbarItemGroup(placement: .bottomBar) { + if isEditMode == .active { + Button(action: { + viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection)) + isEditMode = .inactive + }, label: { Image(systemName: "archivebox") }) + Button(action: { + viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection)) + isEditMode = .inactive + }, label: { Image(systemName: "trash") }) + Spacer() + Text("\(selection.count) selected").font(.footnote) + Spacer() + Button(action: { isEditMode = .inactive }, label: { Text("Cancel") }) + } + } } } } @@ -258,6 +252,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var listTitle: String @Binding var isListScrolled: Bool @Binding var prefersListLayout: Bool + @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel let showFeatureCards: Bool @@ -281,7 +276,14 @@ struct AnimatingCellHeight: AnimatableModifier { } if prefersListLayout || !enableGrid { - HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel, showFeatureCards: showFeatureCards) + HomeFeedListView( + listTitle: $listTitle, + isListScrolled: $isListScrolled, + prefersListLayout: $prefersListLayout, + selection: $selection, + viewModel: viewModel, + showFeatureCards: showFeatureCards + ) } else { HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled) } @@ -329,6 +331,7 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var prefersListLayout: Bool @State private var showHideFeatureAlert = false + @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel let showFeatureCards: Bool @@ -540,7 +543,7 @@ struct AnimatingCellHeight: AnimatableModifier { Spacer(minLength: 2) } - List { + List(selection: $selection) { filtersHeader .listRowSeparator(.hidden, edges: .all) .listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset)) @@ -562,7 +565,7 @@ struct AnimatingCellHeight: AnimatableModifier { } } - ForEach(viewModel.items) { item in + ForEach(viewModel.items, id: \.self.unwrappedID) { item in FeedCardNavigationLink( item: item, isInMultiSelectMode: viewModel.isInMultiSelectMode, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 1f34b82c9..59715701e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -368,6 +368,21 @@ import Views dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true) } + func bulkAction(dataService: DataService, action: BulkAction, items: [String]) { + if items.count < 1 { + snackbar("No items selected") + return + } + Task { + do { + try await dataService.bulkAction(action: action, items: items) + snackbar("Operation completed") + } catch { + snackbar("Error performing operation") + } + } + } + private var queryContainsFilter: Bool { if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") { return true diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift new file mode 100644 index 000000000..fcc12fcd4 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/BulkActionMutation.swift @@ -0,0 +1,101 @@ +// +// BulkActionMutation.swift +// +// +// Created by Jackson Harper on 11/17/23. +// + +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public enum BulkAction { + case delete + case archive + + var GQLType: Enums.BulkActionType { + switch self { + case BulkAction.archive: + return Enums.BulkActionType.archive + case BulkAction.delete: + return Enums.BulkActionType.delete + } + } +} + +public extension DataService { + func bulkAction(action: BulkAction, items: [String]) async throws { + // If the item is still available locally, update its state + backgroundContext.performAndWait { + items.forEach { itemID in + if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) { + if action == .delete { + linkedItem.state = "DELETED" + linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue) + } else { + linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: true) + linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue) + } + } + } + do { + try backgroundContext.save() + logger.debug("LinkedItem updated succesfully") + } catch { + backgroundContext.rollback() + logger.debug("Failed to update LinkedItem: \(error.localizedDescription)") + } + } + + // If we recovered locally, but failed to sync the undelete, that is OK, because + // the item shouldn't be deleted server side. + try await syncBulkAction(action: action, items: items) + } + + func syncBulkAction(action: BulkAction, items: [String]) async throws { + enum MutationResult { + case result(success: Bool) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + bulkActionError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") }, + bulkActionSuccess: .init { .result(success: try $0.success()) } + ) + } + + let query = "includes:\"\(items.joined(separator: ","))\"" + let mutation = Selection.Mutation { + try $0.bulkAction( + action: action.GQLType, + query: query, + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .result(success: success): + if success { + continuation.resume() + } else { + continuation.resume(throwing: BasicError.message(messageText: "Operation failed")) + } + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +}