iOS multi select
This commit is contained in:
@ -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<String>()
|
||||
|
||||
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<String>
|
||||
@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<String>
|
||||
@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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<MutationResult, Unions.BulkActionResult> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user