// // AttachmentsListController.swift // ComposeUI // // Created by Shadowfacts on 3/8/23. // import SwiftUI import PhotosUI import PencilKit class AttachmentsListController: ViewController { unowned let parent: ComposeController var draft: Draft { parent.draft } var isValid: Bool { !requiresAttachmentDescriptions && validAttachmentCombination } private var requiresAttachmentDescriptions: Bool { if parent.config.requireAttachmentDescriptions { return draft.draftAttachments.allSatisfy { !$0.attachmentDescription.isEmpty } } else { return false } } private var validAttachmentCombination: Bool { if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return true } else if draft.attachments.count > 1, draft.draftAttachments.contains(where: { $0.type == .video }) { return false } else if draft.attachments.count > 4 { return false } return true } init(parent: ComposeController) { self.parent = parent } var canAddAttachment: Bool { if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil } else { return true } } private var canAddPoll: Bool { if parent.mastodonController.instanceFeatures.pollsAndAttachments { return true } else { return draft.attachments.count == 0 } } var view: some View { AttachmentsList() } private func moveAttachments(from source: IndexSet, to destination: Int) { // just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet // results in the order switching back to the previous order and then to the correct one // on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem var array = draft.draftAttachments array.move(fromOffsets: source, toOffset: destination) draft.attachments = NSMutableOrderedSet(array: array) } private func deleteAttachments(at indices: IndexSet) { draft.attachments.removeObjects(at: indices) } private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) { provider.loadObject(ofClass: DraftAttachment.self) { object, error in guard let attachment = object as? DraftAttachment else { return } DispatchQueue.main.async { guard self.canAddAttachment else { return } DraftsPersistentContainer.shared.viewContext.insert(attachment) attachment.draft = self.draft self.draft.attachments.add(attachment) } } } } private func addImage() { parent.config.presentAssetPicker?({ results in self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider)) }) } private func addDrawing() { parent.config.presentDrawing?(PKDrawing()) { drawing in let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext) attachment.drawing = drawing attachment.draft = self.draft self.draft.attachments.add(attachment) } } private func togglePoll() { UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) withAnimation { draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil } } struct AttachmentsList: View { private let cellHeight: CGFloat = 80 private let cellPadding: CGFloat = 12 @EnvironmentObject private var controller: AttachmentsListController @EnvironmentObject private var draft: Draft @Environment(\.colorScheme) private var colorScheme @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { Group { attachmentsList if controller.parent.config.presentAssetPicker != nil { addImageButton .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) } if controller.parent.config.presentDrawing != nil { addDrawingButton .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) } togglePollButton .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) } } private var attachmentsList: some View { ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) } .onMove(perform: controller.moveAttachments) .onDelete(perform: controller.deleteAttachments) .conditionally(controller.canAddAttachment) { $0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in controller.insertAttachments(at: offset, itemProviders: providers) }) } // only sort of works, see #240 .onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in controller.insertAttachments(at: 0, itemProviders: providers) return true } } private var addImageButton: some View { Button(action: controller.addImage) { Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo") } .disabled(!controller.canAddAttachment) .foregroundColor(.accentColor) .frame(height: cellHeight / 2) } private var addDrawingButton: some View { Button(action: controller.addDrawing) { Label("Draw something", systemImage: "hand.draw") } .disabled(!controller.canAddAttachment) .foregroundColor(.accentColor) .frame(height: cellHeight / 2) } private var togglePollButton: some View { Button(action: controller.togglePoll) { Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal") } .disabled(!controller.canAddPoll) .foregroundColor(.accentColor) .frame(height: cellHeight / 2) } } } fileprivate extension View { @ViewBuilder func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View { if condition { body(self) } else { self } } @available(iOS, obsoleted: 16.0) @ViewBuilder func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { if #available(iOS 16.0, *) { self.modifier(SheetOrPopover(isPresented: isPresented, view: content)) } else { self.popover(isPresented: isPresented, content: content) } } @available(iOS, obsoleted: 16.0) @ViewBuilder func withSheetDetentsIfAvailable() -> some View { if #available(iOS 16.0, *) { self .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } else { self } } } @available(iOS 16.0, *) fileprivate struct SheetOrPopover: ViewModifier { @Binding var isPresented: Bool @ViewBuilder let view: () -> V @Environment(\.horizontalSizeClass) var sizeClass func body(content: Content) -> some View { if sizeClass == .compact { content.sheet(isPresented: $isPresented, content: view) } else { content.popover(isPresented: $isPresented, content: view) } } }