// // 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.attachments.allSatisfy { !$0.attachmentDescription.isEmpty } } else { return false } } private var validAttachmentCombination: Bool { if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return true } else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 { return false } else if draft.attachments.count > 4 { return false } return true } init(parent: ComposeController) { self.parent = parent } private var canAddAttachment: Bool { if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions { return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image } && draft.poll == nil } else { return true } } private var canAddPoll: Bool { if parent.mastodonController.instanceFeatures.pollsAndAttachments { return true } else { return draft.attachments.isEmpty } } var view: some View { AttachmentsList() } private func moveAttachments(from source: IndexSet, to destination: Int) { draft.attachments.move(fromOffsets: source, toOffset: destination) } private func deleteAttachments(at indices: IndexSet) { draft.attachments.remove(atOffsets: indices) } @MainActor private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) async { 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 } self.draft.attachments.append(attachment) } } } } private func addImage() { parent.config.presentAssetPicker?({ results in Task { await self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider)) } }) } private func addDrawing() { parent.config.presentDrawing?(PKDrawing()) { drawing in self.draft.attachments.append(DraftAttachment(data: .drawing(drawing))) } } private func togglePoll() { UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil) withAnimation { draft.poll = draft.poll == nil ? Draft.Poll() : 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) { attachment in ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) }) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .onDrag { NSItemProvider(object: attachment) } } .onMove(perform: controller.moveAttachments) .onDelete(perform: controller.deleteAttachments) .conditionally(controller.canAddAttachment) { $0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in Task { await controller.insertAttachments(at: offset, itemProviders: providers) } }) } } 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) } } }