iOS multi select

This commit is contained in:
Jackson Harper
2023-11-17 18:03:35 +08:00
parent d5d3c72196
commit db54ecff3a
3 changed files with 154 additions and 35 deletions

View File

@ -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,

View File

@ -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

View File

@ -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))
}
}
}
}
}