From a8173fb475877cc097847d64073301c2b57afba8 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:00:31 +0100 Subject: [PATCH 1/4] `Communication`: Support adding mentions for FAQs in messages (#251) * Add support for mentioning FAQs * Increment version number --- Artemis/Supporting/Info.plist | 2 +- .../Faq/Services/FaqService/FaqService.swift | 6 +-- .../Resources/en.lproj/Localizable.strings | 2 + .../SendMessageFAQPickerViewModel.swift | 46 +++++++++++++++++++ .../SendMessageFAQPicker.swift | 43 +++++++++++++++++ .../SendMessageMentionContentView.swift | 3 ++ .../SendMessageViews/SendMessageView.swift | 7 +++ 7 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index 69490c06..97e4eb76 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.5.1 + 1.6.0 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift index bf7abc0b..dac4c5fa 100644 --- a/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift +++ b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift @@ -8,11 +8,11 @@ import Common import SharedModels -protocol FaqService { +public protocol FaqService { func getFaqs(for courseId: Int) async -> DataState<[FaqDTO]> func getFaq(with faqId: Int64, for courseId: Int) async -> DataState } -enum FaqServiceFactory { - static let shared: FaqService = FaqServiceImpl() +public enum FaqServiceFactory { + public static let shared: FaqService = FaqServiceImpl() } diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index bfb47e67..714c27ba 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -11,6 +11,8 @@ "lecture" = "Lecture"; "channelsUnavailable" = "No Channels"; "lecturesUnavailable" = "No Lectures"; +"faqs" = "FAQs"; +"faqsUnavailable" = "No FAQs"; "membersUnavailable" = "No Members"; "messageAction" = "Message %@"; "mentionSlideNumber" = "Slide %i"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift new file mode 100644 index 00000000..65db68e1 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageFAQPickerViewModel.swift @@ -0,0 +1,46 @@ +// +// SendMessageFAQPickerViewModel.swift +// ArtemisKit +// +// Created by Anian Schleyer on 04.12.24. +// + +import Faq +import SharedModels +import SwiftUI + +@Observable +final class SendMessageFAQPickerViewModel { + + let course: Course + var faqs: [FaqDTO] + + private let delegate: SendMessageMentionContentDelegate + private let faqService: FaqService + + init( + course: Course, + faqs: [FaqDTO] = [], + delegate: SendMessageMentionContentDelegate = SendMessageMentionContentDelegate { _ in }, + faqService: FaqService = FaqServiceFactory.shared + ) { + self.course = course + self.faqs = faqs + self.delegate = delegate + self.faqService = faqService + } + + func loadFAQs() async { + let faqs = await faqService.getFaqs(for: course.id) + + if case let .done(faqs) = faqs { + self.faqs = faqs.filter { + $0.faqState == .accepted + } + } + } + + func select(faq: FaqDTO) { + delegate.pickerDidSelect("[faq]\(faq.questionTitle)(/courses/\(course.id)/faq?faqId=\(faq.id))[/faq]") + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift new file mode 100644 index 00000000..447422ea --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageFAQPicker.swift @@ -0,0 +1,43 @@ +// +// SendMessageFAQPicker.swift +// ArtemisKit +// +// Created by Anian Schleyer on 07.12.24. +// + +import SharedModels +import SwiftUI + +struct SendMessageFAQPicker: View { + + @State var viewModel: SendMessageFAQPickerViewModel + + var body: some View { + Group { + if !viewModel.faqs.isEmpty { + List(viewModel.faqs) { faq in + Button { + viewModel.select(faq: faq) + } label: { + Text(faq.questionTitle) + .lineLimit(2) + } + } + .listStyle(.plain) + } else { + ContentUnavailableView(R.string.localizable.faqsUnavailable(), systemImage: "magnifyingglass") + } + } + .task { + await viewModel.loadFAQs() + } + .navigationTitle(R.string.localizable.faqs()) + .navigationBarTitleDisplayMode(.inline) + } +} + +extension SendMessageFAQPicker { + init(course: Course, delegate: SendMessageMentionContentDelegate) { + self.init(viewModel: SendMessageFAQPickerViewModel(course: course, delegate: delegate)) + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift index 6a4766a8..2a96be2c 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -33,6 +33,8 @@ struct SendMessageMentionContentView: View { SendMessageExercisePicker(delegate: delegate, course: viewModel.course) case .lecture: SendMessageLecturePicker(course: viewModel.course, delegate: delegate) + case .faq: + SendMessageFAQPicker(course: viewModel.course, delegate: delegate) } } .toolbar { @@ -53,4 +55,5 @@ enum MessageMentionContentType: Identifiable { case exercise case lecture + case faq } diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 83ae4e96..95eebe87 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -141,6 +141,13 @@ private extension SendMessageView { } label: { Label(R.string.localizable.lectures(), systemImage: "character.book.closed") } + if viewModel.course.faqEnabled == true { + Button { + viewModel.wantsToAddMessageMentionContentType = .faq + } label: { + Label(R.string.localizable.faqs(), systemImage: "questionmark.circle") + } + } } label: { Label(R.string.localizable.mention(), systemImage: "plus.circle.fill") } From dbd81d2f03e5329cd9b28a8d6ac6dde998352aa7 Mon Sep 17 00:00:00 2001 From: Eylul Naz Can <65118253+eylulnc@users.noreply.github.com> Date: Sat, 14 Dec 2024 01:35:01 +0100 Subject: [PATCH 2/4] `Communication`: Support File Sharing (#260) --- .../Resources/en.lproj/Localizable.strings | 1 + .../MessagesService/MessagesService.swift | 6 +- .../MessagesService/MessagesServiceImpl.swift | 44 ++++--- .../MessagesService/MessagesServiceStub.swift | 4 +- .../SendMessageUploadFileViewModel.swift | 120 ++++++++++++++++++ .../SendMessageUploadImageViewModel.swift | 68 +--------- .../FileUpload/UploadViewModel.swift | 75 +++++++++++ .../SendMessageViewModel.swift | 4 + .../MessagesAvailableView.swift | 3 +- .../FileUpload/FilePickerViewModifier.swift | 91 +++++++++++++ .../SendMessageFilePickerView.swift | 32 +++++ .../SendMessageImagePickerView.swift | 41 ++++++ .../UploadFileView.swift} | 45 +------ .../SendMessageViews/SendMessageView.swift | 9 ++ 14 files changed, 416 insertions(+), 127 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift rename ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/{ => FileUpload}/SendMessageUploadImageViewModel.swift (57%) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift rename ArtemisKit/Sources/Messages/Views/SendMessageViews/{SendMessageImagePickerView.swift => FileUpload/UploadFileView.swift} (60%) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 714c27ba..d79222a4 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -26,6 +26,7 @@ "code" = "Code"; "link" = "Link"; "uploadImage" = "Upload Image"; +"uploadFile" = "Upload File"; // MARK: SendMessageMentionContentView "members" = "Members"; diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index c2ef0032..c25a896d 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -49,9 +49,9 @@ protocol MessagesService { func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse /** - * Perform a post request for uploading a jpeg image in a specific conversation to the server. - */ - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState + * Perform a post request for uploading a file in a specific conversation to the server. + */ + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState /** * Perform a delete request for a message in a specific course to the server. diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index b953189d..cac82b7e 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -233,30 +233,32 @@ struct MessagesServiceImpl: MessagesService { } } - struct UploadImageResult: Codable { + struct UploadFileResult: Codable { let path: String } - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { - if image.count > 5 * 1024 * 1024 { - return .failure(error: .init(title: "File too big to upload")) - } - - let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)") - request.addDataField(named: "file", - filename: "\(UUID().uuidString).jpg", - data: image, - mimeType: "image/jpeg") - - let result: Swift.Result<(UploadImageResult, Int), APIClientError> = await client.sendRequest(request) - - switch result { - case .success(let response): - return .done(response: response.0.path) - case .failure(let failure): - return .failure(error: .init(error: failure)) - } - } + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState { + // Check file size limit + let maxFileSize = 5 * 1024 * 1024 + if file.count > maxFileSize { + return .failure(error: .init(title: "File too big to upload")) + } + + let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)") + request.addDataField(named: "file", + filename: filename, + data: file, + mimeType: mimeType) + + let result: Swift.Result<(UploadFileResult, Int), APIClientError> = await client.sendRequest(request) + + switch result { + case .success(let response): + return .done(response: response.0.path) + case .failure(let failure): + return .failure(error: .init(error: failure)) + } + } struct DeleteMessageRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index f1fb4d34..3676ae5a 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -204,8 +204,8 @@ extension MessagesServiceStub: MessagesService { func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { .loading } - - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { + + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState { .loading } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift new file mode 100644 index 00000000..863319e2 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift @@ -0,0 +1,120 @@ +// +// SendMessageUploadFileViewModel.swift +// ArtemisKit +// +// Created by Eylul Naz Can on 9.12.2024. +// + +import Foundation +import UniformTypeIdentifiers +import Common +import SwiftUI + +@Observable +final class SendMessageUploadFileViewModel: UploadViewModel { + + var fileData: Data? + var fileName: String? + var isFilePickerPresented = false + + private let messagesService: MessagesService + + static let allowedFileTypes: [UTType] = [ + .png, + .jpeg, + .gif, + .svg, + .pdf, + .rtf, + .plainText, + .json, + .spreadsheet, + .presentation, + UTType(filenameExtension: "doc") ?? .data, + UTType(filenameExtension: "docx") ?? .data, + UTType(filenameExtension: "xls") ?? .spreadsheet, + UTType(filenameExtension: "xlsx") ?? .spreadsheet, + UTType(filenameExtension: "ppt") ?? .presentation, + UTType(filenameExtension: "pptx") ?? .presentation + ] + + init( + courseId: Int, + conversationId: Int64, + messagesService: MessagesService = MessagesServiceFactory.shared + ) { + self.messagesService = messagesService + super.init(courseId: courseId, conversationId: conversationId) + } + + /// Handles changes to the selected file URL + func filePicked(at url: URL) { + isFilePickerPresented = false + loadFileData(from: url) + } + + /// Reads the file data from the provided URL + private func loadFileData(from url: URL) { + uploadState = .compressing + filePath = nil + + do { + guard url.startAccessingSecurityScopedResource() else { + throw NSFileProviderError(.notAuthenticated) + } + let fileData = try Data(contentsOf: url) + url.stopAccessingSecurityScopedResource() + let fileName = url.lastPathComponent + handleFileSelection(fileData: fileData, fileName: fileName) + } catch { + uploadState = .failed(error: .init(title: "Failed to read the selected file. Please try again.")) + } + } + + /// Validates and handles the selected file data + private func handleFileSelection(fileData: Data, fileName: String) { + self.fileData = fileData + self.fileName = fileName + + if fileData.count > 5 * 1024 * 1024 { + uploadState = .failed( + error: .init(title: "The file size exceeds the 5MB limit. Please choose a smaller file.") + ) + return + } + + let fileExtension = (fileName as NSString).pathExtension.lowercased() + guard Self.allowedFileTypes.contains(where: { $0.preferredFilenameExtension == fileExtension }) else { + uploadState = .failed( + error: .init(title: "The file type '\(fileExtension)' is not supported.") + ) + return + } + + upload(data: fileData, fileName: fileName, mimeType: fileExtension) + } + + private func upload(data: Data, fileName: String, mimeType: String) { + uploadState = .uploading + + uploadTask = Task { + let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: data, filename: fileName, mimeType: mimeType) + if Task.isCancelled { return } + + switch result { + case .loading: + break + case .failure(let error): + uploadState = .failed(error: error) + case .done(let response): + filePath = response + uploadState = .done + } + } + } + + func resetFileSelection() { + fileData = nil + fileName = nil + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift similarity index 57% rename from ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift index a9d9e33f..bd535752 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift @@ -1,5 +1,5 @@ // -// File.swift +// SendMessageUploadImageViewModel.swift // ArtemisKit // // Created by Anian Schleyer on 09.11.24. @@ -10,58 +10,11 @@ import Foundation import PhotosUI import SwiftUI -enum UploadState: Equatable { - case selectImage - case compressing - case uploading - case done - case failed(error: UserFacingError) -} - @Observable -final class SendMessageUploadImageViewModel { - - let courseId: Int - let conversationId: Int64 +final class SendMessageUploadImageViewModel: UploadViewModel { var selection: PhotosPickerItem? var image: UIImage? - var uploadState = UploadState.selectImage - var imagePath: String? - private var uploadTask: Task<(), Never>? - - var showUploadScreen: Binding { - .init { - self.uploadState != .selectImage - } set: { newValue in - if !newValue { - self.uploadState = .selectImage - } - } - } - var error: UserFacingError? { - switch uploadState { - case .failed(let error): - return error - default: - return nil - } - } - - var statusLabel: String { - switch uploadState { - case .selectImage: - "" - case .compressing: - R.string.localizable.loading() - case .uploading: - R.string.localizable.uploading() - case .done: - R.string.localizable.done() - case .failed(let error): - error.localizedDescription - } - } private let messagesService: MessagesService @@ -70,9 +23,8 @@ final class SendMessageUploadImageViewModel { conversationId: Int64, messagesService: MessagesService = MessagesServiceFactory.shared ) { - self.courseId = courseId - self.conversationId = conversationId self.messagesService = messagesService + super.init(courseId: courseId, conversationId: conversationId) } /// Register as change handler for selection on View @@ -86,7 +38,7 @@ final class SendMessageUploadImageViewModel { } uploadState = .compressing - imagePath = nil + filePath = nil Task { if let transferable = try? await item.loadTransferable(type: Data.self) { @@ -107,7 +59,7 @@ final class SendMessageUploadImageViewModel { uploadState = .uploading uploadTask = Task { - let result = await messagesService.uploadImage(for: courseId, and: conversationId, image: imageData) + let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: imageData, filename: "\(UUID().uuidString).jpg", mimeType: "image/jpeg") if Task.isCancelled { return } @@ -118,7 +70,7 @@ final class SendMessageUploadImageViewModel { case .failure(let error): uploadState = .failed(error: error) case .done(let response): - imagePath = response + filePath = response uploadState = .done } selection = nil @@ -141,12 +93,4 @@ final class SendMessageUploadImageViewModel { return imageData } } - - func cancel() { - uploadTask?.cancel() - uploadTask = nil - selection = nil - image = nil - uploadState = .selectImage - } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift new file mode 100644 index 00000000..0d32bd4e --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift @@ -0,0 +1,75 @@ +// +// UploadState.swift +// ArtemisKit +// +// Created by Eylul Naz Can on 9.12.2024. +// + +import Common +import Foundation +import SwiftUI + +enum UploadState: Equatable { + case idle + case compressing + case uploading + case done + case failed(error: UserFacingError) +} + +@Observable +class UploadViewModel { + var uploadState: UploadState = .idle + var filePath: String? + + private(set) var courseId: Int + private(set) var conversationId: Int64 + + internal var uploadTask: Task<(), Never>? + + init(courseId: Int, conversationId: Int64) { + self.courseId = courseId + self.conversationId = conversationId + } + + var showUploadScreen: Binding { + .init { + self.uploadState != .idle + } set: { newValue in + if !newValue { + self.uploadState = .idle + } + } + } + + var error: UserFacingError? { + switch uploadState { + case .failed(let error): + return error + default: + return nil + } + } + + var statusLabel: String { + switch uploadState { + case .idle: + return "" + case .compressing: + return R.string.localizable.loading() + case .uploading: + return R.string.localizable.uploading() + case .done: + return R.string.localizable.done() + case .failed(let error): + return error.localizedDescription + } + } + + func cancel() { + uploadTask?.cancel() + uploadTask = nil + uploadState = .idle + filePath = nil + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index f9361022..ac40a79e 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -221,6 +221,10 @@ extension SendMessageViewModel { func insertImageMention(path: String) { appendToSelection(before: "![", after: "](\(path))", placeholder: "image") } + + func insertFileMention(path: String, fileName: String) { + appendToSelection(before: "[", after: "](\(path))", placeholder: fileName) + } /// Prepends/Appends the given snippets to text the user has selected. private func appendToSelection(before: String, after: String, placeholder: String) { diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 1f4ec102..5b293c58 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -193,7 +193,8 @@ public struct MessagesAvailableView: View { } } } - .toolbar(.hidden, for: .navigationBar) + // Add a file picker here, inside the navigation it doesn't work sometimes + .supportsFilePicker() } @ViewBuilder var filterBar: some View { diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift new file mode 100644 index 00000000..81817c6b --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift @@ -0,0 +1,91 @@ +// +// FilePickerViewModifier.swift +// ArtemisKit +// +// Created by Anian Schleyer on 13.12.24. +// + +import SwiftUI + +extension View { + /// Add this further up in the view hierarchy to use a file picker somewhere below + func supportsFilePicker() -> some View { + modifier(AddFilePickerViewModifier()) + } + + /// Use this to utitlize the previously added file picker + func filePicker(isPresented: Binding, + onFilePicked: @escaping (URL) -> Void, + onError: @escaping (Error) -> Void) -> some View { + modifier(UseFilePickerViewModifier(presentFilePicker: isPresented, + onFilePick: onFilePicked, + onError: onError)) + } +} + +/// Adds a fileImporter in the view hierarchy for use in lower levels. +/// Use this before you can use `.filePicker` on a View. +private struct AddFilePickerViewModifier: ViewModifier { + @State var manager = FilePickerManager() + func body(content: Content) -> some View { + @Bindable var manager = manager + + content + .environment(\.filePickerManager, manager) + .fileImporter(isPresented: $manager.isPresented, + allowedContentTypes: SendMessageUploadFileViewModel.allowedFileTypes, + allowsMultipleSelection: false) { result in + switch result { + case .success(let success): + if let url = success.first { + manager.onPicked(url) + } + case .failure(let failure): + manager.onError(failure) + } + } + } +} + +/// This uses the previously injected fileImporter. +/// *Prerequesite*: Use `.supportsFilePicker()` on a view further up +private struct UseFilePickerViewModifier: ViewModifier { + @Environment(\.filePickerManager) var manager + + @Binding var presentFilePicker: Bool + let onFilePick: (URL) -> Void + let onError: (Error) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: presentFilePicker, initial: true) { _, newValue in + manager.isPresented = newValue + } + .onAppear { + manager.onPicked = onFilePick + manager.onError = onError + } + } +} + +@Observable +class FilePickerManager { + var isPresented = false + var onPicked: (URL) -> Void = { _ in } + var onError: (Error) -> Void = { _ in } +} + +private enum FilePickerManagerKey: EnvironmentKey { + static let defaultValue = FilePickerManager() +} + +private extension EnvironmentValues { + var filePickerManager: FilePickerManager { + get { + self[FilePickerManagerKey.self] + } + set { + self[FilePickerManagerKey.self] = newValue + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift new file mode 100644 index 00000000..508e03b4 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct SendMessageFilePickerView: View { + var sendViewModel: SendMessageViewModel + @Bindable private var viewModel: SendMessageUploadFileViewModel + + init(sendViewModel: SendMessageViewModel, viewModel: SendMessageUploadFileViewModel) { + self.viewModel = viewModel + self.sendViewModel = sendViewModel + } + + var body: some View { + Button { + viewModel.isFilePickerPresented = true + } label: { + Label("Upload File", systemImage: "doc.fill") + } + .filePicker(isPresented: $viewModel.isFilePickerPresented, + onFilePicked: viewModel.filePicked(at:)) { error in + viewModel.uploadState = .failed(error: .init(title: error.localizedDescription)) + } + .sheet(isPresented: viewModel.showUploadScreen) { + if let path = viewModel.filePath { + sendViewModel.insertFileMention(path: path, fileName: viewModel.fileName ?? "file") + } + viewModel.resetFileSelection() + } content: { + UploadFileProgressView(viewModel: viewModel) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift new file mode 100644 index 00000000..a888a0af --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift @@ -0,0 +1,41 @@ +// +// SendMessageImagePickerView.swift +// ArtemisKit +// +// Created by Anian Schleyer on 09.11.24. +// + +import PhotosUI +import SwiftUI + +struct SendMessageImagePickerView: View { + + var sendViewModel: SendMessageViewModel + @State private var viewModel: SendMessageUploadImageViewModel + + init(sendMessageViewModel: SendMessageViewModel) { + self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id, + conversationId: sendMessageViewModel.conversation.id)) + self.sendViewModel = sendMessageViewModel + } + + var body: some View { + PhotosPicker(selection: $viewModel.selection, + matching: .images, + preferredItemEncoding: .compatible) { + Label(R.string.localizable.uploadImage(), systemImage: "photo.fill") + } + .onChange(of: viewModel.selection) { + viewModel.onChange() + } + .sheet(isPresented: viewModel.showUploadScreen) { + if let path = viewModel.filePath { + sendViewModel.insertImageMention(path: path) + } + viewModel.selection = nil + viewModel.image = nil + } content: { + UploadFileProgressView(viewModel: viewModel) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift similarity index 60% rename from ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift rename to ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift index e6124dad..f65c4ad3 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift @@ -1,47 +1,14 @@ // -// SendMessageImagePickerView.swift +// UploadFileView.swift // ArtemisKit // -// Created by Anian Schleyer on 09.11.24. +// Created by Anian Schleyer on 13.12.24. // -import PhotosUI import SwiftUI -struct SendMessageImagePickerView: View { - - var sendViewModel: SendMessageViewModel - @State private var viewModel: SendMessageUploadImageViewModel - - init(sendMessageViewModel: SendMessageViewModel) { - self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id, - conversationId: sendMessageViewModel.conversation.id)) - self.sendViewModel = sendMessageViewModel - } - - var body: some View { - PhotosPicker(selection: $viewModel.selection, - matching: .images, - preferredItemEncoding: .compatible) { - Label(R.string.localizable.uploadImage(), systemImage: "photo.fill") - } - .onChange(of: viewModel.selection) { - viewModel.onChange() - } - .sheet(isPresented: viewModel.showUploadScreen) { - if let path = viewModel.imagePath { - sendViewModel.insertImageMention(path: path) - } - viewModel.selection = nil - viewModel.image = nil - } content: { - UploadImageView(viewModel: viewModel) - } - } -} - -private struct UploadImageView: View { - var viewModel: SendMessageUploadImageViewModel +struct UploadFileProgressView: View { + var viewModel: UploadViewModel @Environment(\.dismiss) var dismiss var body: some View { @@ -99,7 +66,8 @@ private struct UploadImageView: View { } @ViewBuilder var backgroundImage: some View { - if let image = viewModel.image { + if let vm = viewModel as? SendMessageUploadImageViewModel, + let image = vm.image { Image(uiImage: image) .resizable() .scaledToFill() @@ -110,3 +78,4 @@ private struct UploadImageView: View { } } } + diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 95eebe87..2cd36a2f 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -13,9 +13,17 @@ import SwiftUI struct SendMessageView: View { @State var viewModel: SendMessageViewModel + /// This has to be in here, otherwise it gets deinitialized while file picker is open, + /// due to the textfield losing focus and the toolbar disappearing + @State private var uploadFileViewModel: SendMessageUploadFileViewModel @FocusState private var isFocused: Bool + init(viewModel: SendMessageViewModel) { + self._viewModel = State(initialValue: viewModel) + self._uploadFileViewModel = State(initialValue: .init(courseId: viewModel.course.id, conversationId: viewModel.conversation.id)) + } + var body: some View { VStack(spacing: 0) { if viewModel.isEditing { @@ -195,6 +203,7 @@ private extension SendMessageView { Label(R.string.localizable.link(), systemImage: "link") } SendMessageImagePickerView(sendMessageViewModel: viewModel) + SendMessageFilePickerView(sendViewModel: viewModel, viewModel: uploadFileViewModel) } .labelStyle(.iconOnly) .font(.title3) From c6c3aaea962539ef213ea86669ba3cb1a8bbf91f Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 14 Dec 2024 03:09:27 +0100 Subject: [PATCH 3/4] `Communication`: Fix certain reactions not showing (#261) --- .../xcshareddata/swiftpm/Package.resolved | 17 ++++------------- ArtemisKit/Package.swift | 1 + 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b5075ef9..824ead16 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5c8626acffff005e2642c0e07e1f755598c39323b3bb020fb01711f64303781e", + "originHash" : "d98f8b8207dd2f669664cadc42e7d9f386e6aa80a1ad0c3e37c5b13e2081ecc9", "pins" : [ { "identity" : "apollon-ios-module", @@ -10,15 +10,6 @@ "version" : "1.0.9" } }, - { - "identity" : "artemis-ios-core-modules", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ls1intum/artemis-ios-core-modules", - "state" : { - "revision" : "83a8eafae4d7b098e303aa0c06b392c505852b7f", - "version" : "15.1.2" - } - }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", @@ -85,10 +76,10 @@ { "identity" : "smile", "kind" : "remoteSourceControl", - "location" : "https://github.com/onmyway133/Smile", + "location" : "https://github.com/onmyway133/Smile.git", "state" : { - "revision" : "40604722a7a56f735124e069fcbb58307637744b", - "version" : "2.1.0" + "branch" : "6bacbf7", + "revision" : "6bacbf74638eb725fb0dd3e728341bc17acf8958" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 80a0c678..a7eea141 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -18,6 +18,7 @@ let package = Package( ]) ], dependencies: [ + .package(url: "https://github.com/onmyway133/Smile.git", revision: "6bacbf7"), .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"), .package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")), .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "15.1.2")), From 3de04a57f5092b7f74a5f51063cafde445adc7a0 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:21:32 +0100 Subject: [PATCH 4/4] `Communication`: Fix scroll gesture conflict (#262) * Update packages * Set version number to 1.5.2 * Increase threshold for swipe to reply * Resolve warning --- .../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++- Artemis/Supporting/Info.plist | 2 +- ArtemisKit/Package.swift | 3 ++- .../MessageDetailViewModels/MessageCellModel.swift | 8 ++++---- .../Views/MessageDetailView/SwipeToReply.swift | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 824ead16..233e1569 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d98f8b8207dd2f669664cadc42e7d9f386e6aa80a1ad0c3e37c5b13e2081ecc9", + "originHash" : "60201ec1c26be7adad9fdf6611cc7c649da23b2d45f1aa466264a869dc6de3a1", "pins" : [ { "identity" : "apollon-ios-module", @@ -10,6 +10,15 @@ "version" : "1.0.9" } }, + { + "identity" : "artemis-ios-core-modules", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ls1intum/artemis-ios-core-modules", + "state" : { + "revision" : "f28438e6960df1c91bbfa59e53f46670430f509e", + "version" : "15.1.4" + } + }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index 97e4eb76..4d03c5a2 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.6.0 + 1.5.2 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index a7eea141..886b931c 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -120,7 +120,8 @@ let package = Package( .product(name: "SharedModels", package: "artemis-ios-core-modules"), .product(name: "SharedServices", package: "artemis-ios-core-modules"), .product(name: "UserStore", package: "artemis-ios-core-modules"), - .product(name: "RswiftLibrary", package: "R.swift") + .product(name: "RswiftLibrary", package: "R.swift"), + .product(name: "Smile", package: "Smile") ], plugins: [ .plugin(name: "RswiftGeneratePublicResources", package: "R.swift") diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift index 680b8f81..cae8a932 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -100,10 +100,10 @@ class SwipeToReplyState { /// Update all view properies associated with swiping to reply func update(with distance: CGFloat) { - overlayOffset = 200 * exp((distance - 10) / 30) - messageBlur = max((-distance - 25) * 0.2 * blurIntensity, 0) - overlayOpacity = max(0, min(-(distance + 40) * 0.05, 1)) - overlayScale = max(0, min(-(distance + 40) * 0.03, 1)) + overlayOffset = 200 * exp((distance - 15) / 30) + messageBlur = max((-distance - 30) * 0.2 * blurIntensity, 0) + overlayOpacity = max(0, min(-(distance + 35) * 0.05, 1)) + overlayScale = max(0, min(-(distance + 35) * 0.03, 1)) // If user dragged far enough to activate reply, let them know if !swiped && distance < -70 { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift index a1bed1a5..ca549ed1 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift @@ -38,7 +38,7 @@ struct SwipeToReply: ViewModifier { } var swipeToReplyGesture: some Gesture { - DragGesture(minimumDistance: 20) + DragGesture(minimumDistance: 25) .onChanged { value in // No swiping in Thread View guard enabled else { return }