From 535407c3ff038ab81476505f43527fc1e5dbb1ee Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Fri, 31 May 2024 12:59:11 +0200 Subject: [PATCH 01/48] Display number of lectures (#110) --- .../Sources/CourseView/Resources/en.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 633a409e..a6a80343 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -94,5 +94,5 @@ "date" = "Date"; "noDateAssociated" = "No date associated"; "lectureUnits" = "Lecture Units"; -"lecturesGroupTitle" = "%s (Exercises: %i)"; +"lecturesGroupTitle" = "%s (Lectures: %i)"; "attachments" = "Attachments"; From e48afc03426ed2c9819b0537b5caaf727cb5c1f3 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 3 Jun 2024 13:21:12 +0200 Subject: [PATCH 02/48] Add conversation tab mock --- .../Sources/Dashboard/CourseServiceStub.swift | 6 +---- .../CodeOfConductServiceStub.swift | 27 +++++++++++++++++++ .../MessagesService/MessagesService.swift | 3 ++- 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift diff --git a/ArtemisKit/Sources/Dashboard/CourseServiceStub.swift b/ArtemisKit/Sources/Dashboard/CourseServiceStub.swift index 86a1a17d..62609e80 100644 --- a/ArtemisKit/Sources/Dashboard/CourseServiceStub.swift +++ b/ArtemisKit/Sources/Dashboard/CourseServiceStub.swift @@ -21,11 +21,7 @@ struct CourseServiceStub: CourseService { }() static let courses: CoursesForDashboardDTO = { - var courses = CoursesForDashboardDTO() - courses.courses = [ - course - ] - return courses + return .mock }() func getCourses() async -> DataState { diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift new file mode 100644 index 00000000..c2f6f043 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift @@ -0,0 +1,27 @@ +// +// File.swift +// +// +// Created by Anian Schleyer on 03.06.24. +// + +import Foundation +import Common + +public class CodeOfConductServiceStub: CodeOfConductService { + public func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse { + return .success + } + + public func getAgreement(for courseId: Int) async -> DataState { + return .done(response: true) + } + + func getResponsibleUsers(for courseId: Int) async -> DataState<[ResponsibleUserDTO]> { + return .done(response: []) + } + + func getTemplate() async -> DataState { + return .done(response: "") + } +} diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 3e74a94e..c11f9ef3 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -179,5 +179,6 @@ extension MessagesService { } enum MessagesServiceFactory { - static let shared: MessagesService = MessagesServiceImpl() + // TODO: Invert Order + static let shared: MessagesService = !CommandLine.arguments.contains("-Screenshots") ? MessagesServiceStub() : MessagesServiceImpl() } From fdc69453eedf9b32afd67f586186993d628f1e21 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Mon, 3 Jun 2024 13:25:37 +0200 Subject: [PATCH 03/48] `Communication`: Mention content (#107) * Margins for content * Parse mention * Add navigation titles * Create SendMessageMentionContentView * Display all results if query is empty * Create LectureService * View lecture, lecture unit, and slide * Select lecture unit * Select lecture * Select slide --- .../Resources/en.lproj/Localizable.strings | 5 + .../LectureService/LectureService.swift | 17 ++++ .../LectureService/LectureServiceImpl.swift | 40 ++++++++ .../MessageCellModel+MentionScheme.swift | 21 +++++ .../SendMessageLecturePickerViewModel.swift | 75 +++++++++++++++ .../SendMessageMentionChannelViewModel.swift | 2 +- .../SendMessageMentionContentDelegate.swift | 10 ++ .../SendMessageViewModel.swift | 11 +-- .../Views/MessageDetailView/MessageCell.swift | 8 ++ .../MessagesAvailableView.swift | 2 +- .../MessagesTabView/MessagesTabView.swift | 2 +- .../SendMessageExercisePicker.swift | 28 +++--- .../SendMessageLecturePicker.swift | 94 ++++++++++++++++--- .../SendMessageMentionContentView.swift | 50 ++++++++++ .../SendMessageViews/SendMessageView.swift | 39 ++------ 15 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Services/LectureService/LectureService.swift create mode 100644 ArtemisKit/Sources/Messages/Services/LectureService/LectureServiceImpl.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionContentDelegate.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 5f43ce34..12ca96e6 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -13,6 +13,11 @@ "lecturesUnavailable" = "No Lectures"; "membersUnavailable" = "No Members"; "messageAction" = "Message %@"; +"mentionSlideNumber" = "Slide %i"; + +// MARK: SendMessageMentionContentView +"members" = "Members"; +"mention" = "Mention"; // MARK: ReactionsView "emojis" = "Emojis"; diff --git a/ArtemisKit/Sources/Messages/Services/LectureService/LectureService.swift b/ArtemisKit/Sources/Messages/Services/LectureService/LectureService.swift new file mode 100644 index 00000000..08ae2e78 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/LectureService/LectureService.swift @@ -0,0 +1,17 @@ +// +// LectureService.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import Common +import SharedModels + +protocol LectureService { + func getLecturesWithSlides(courseId: Int) async -> DataState<[Lecture]> +} + +enum LectureServiceFactory { + static let shared: LectureService = LectureServiceImpl() +} diff --git a/ArtemisKit/Sources/Messages/Services/LectureService/LectureServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/LectureService/LectureServiceImpl.swift new file mode 100644 index 00000000..c4e8270e --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/LectureService/LectureServiceImpl.swift @@ -0,0 +1,40 @@ +// +// LectureServiceImpl.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import APIClient +import Common +import SharedModels + +class LectureServiceImpl: LectureService { + + let client = APIClient() + + struct GetLecturesWithSlidesRequest: APIRequest { + typealias Response = [Lecture] + + let courseId: Int + + var method: HTTPMethod { + .get + } + + var resourceName: String { + "api/courses/\(courseId)/lectures-with-slides" + } + } + + func getLecturesWithSlides(courseId: Int) async -> DataState<[Lecture]> { + let result = await client.sendRequest(GetLecturesWithSlidesRequest(courseId: courseId)) + + switch result { + case let .success((response, _)): + return .done(response: response) + case let .failure(error): + return .failure(error: UserFacingError(error: error)) + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift index 548c70e7..112ef764 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift @@ -8,16 +8,26 @@ import Foundation enum MentionScheme { + case attachment(Int) case channel(Int64) case exercise(Int) case lecture(Int) + case lectureUnit case member(String) + case message(Int) + case slide init?(_ url: URL) { guard url.scheme == "mention" else { return nil } switch url.host() { + case "attachment": + // E.g., mention://attachment/lecture/3/LectureAttachment_2024-05-24T21-05-08-351_d37182b7.png + if url.pathComponents.count >= 3, let lectureId = Int(url.pathComponents[3]) { + self = .attachment(lectureId) + return + } case "channel": if let id = Int64(url.lastPathComponent) { self = .channel(id) @@ -33,9 +43,20 @@ enum MentionScheme { self = .lecture(id) return } + case "lecture-unit": + // E.g., mention://lecture-unit/attachment-unit/7/AttachmentUnit_2024-05-24T21-12-25-915_Inheritance__part_1_.pdf + self = .lectureUnit case "member": self = .member(url.lastPathComponent) return + case "message": + // E.g., mention://message/1 + if let id = Int(url.lastPathComponent) { + self = .message(id) + } + case "slide": + // E.g., mention://slide/attachment-unit/10/slide/1 + self = .slide default: return nil } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift new file mode 100644 index 00000000..5b87a048 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift @@ -0,0 +1,75 @@ +// +// SendMessageLecturePickerViewModel.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import SharedModels +import SwiftUI + +@Observable +@MainActor +final class SendMessageLecturePickerViewModel { + + let course: Course + var lectureUnits: [LectureUnit] + + private let delegate: SendMessageMentionContentDelegate + private let lectureService: LectureService + + init( + course: Course, + lectureUnits: [LectureUnit] = [], + delegate: SendMessageMentionContentDelegate, + lectureService: LectureService = LectureServiceFactory.shared + ) { + self.course = course + self.lectureUnits = lectureUnits + self.delegate = delegate + self.lectureService = lectureService + } + + func task() async { + let lectures = await lectureService.getLecturesWithSlides(courseId: course.id) + + if case let .done(lectures) = lectures, + let lecture = lectures.first, + let lectureUnits = lecture.lectureUnits { + self.lectureUnits = lectureUnits + } + } + + func select(lecture: Lecture) { + if let title = lecture.title { + delegate.pickerDidSelect("[lecture]\(title)(/courses/\(course.id)/lectures/\(lecture.id))[/lecture]") + } + } + + func select(lectureUnit: LectureUnit) { + if let name = lectureUnit.baseUnit.name, + case let .attachment(attachment) = lectureUnit, + case let .file(file) = attachment.attachment, + let link = file.link, + let url = URL(string: link), + url.pathComponents.count >= 7 { + let path = url.pathComponents[4...] + let id = path.joined(separator: "/") + + delegate.pickerDidSelect("[lecture-unit]\(name)(\(id))[/lecture-unit]") + } + } + + func select(lectureUnit: LectureUnit, slide: Slide) { + if let name = lectureUnit.baseUnit.name, + let slideNumber = slide.slideNumber, + let slideImagePath = slide.slideImagePath, + let url = URL(string: slideImagePath), + url.pathComponents.count >= 9 { + let path = url.pathComponents[4...7] + let id = path.joined(separator: "/") + + delegate.pickerDidSelect("[slide]\(name) Slide \(slideNumber)(\(id))[/slide]") + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift index 4aa67e09..ef97a350 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift @@ -30,7 +30,7 @@ final class SendMessageMentionChannelViewModel { func search(idOrName: String) async { let channels = await messagesService.getChannelsPublicOverview(for: course.id) - if case let .done(channels) = channels { + if case let .done(channels) = channels, !idOrName.isEmpty { let filtered = channels.filter { channel in let range = channel.name.range(of: idOrName, options: [.caseInsensitive, .diacriticInsensitive]) return range != nil diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionContentDelegate.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionContentDelegate.swift new file mode 100644 index 00000000..810f3067 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionContentDelegate.swift @@ -0,0 +1,10 @@ +// +// SendMessageMentionContentDelegate.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +struct SendMessageMentionContentDelegate { + var pickerDidSelect: (_ mention: String) -> Void +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index f1f0a7ee..9defb09d 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -23,15 +23,6 @@ extension SendMessageViewModel { case memberPicker case channelPicker } - - enum ModalPresentation: Identifiable { - case exercisePicker - case lecturePicker - - var id: Self { - self - } - } } @MainActor @@ -78,7 +69,7 @@ final class SendMessageViewModel { var isMemberPickerSuppressed = false var isChannelPickerSuppressed = false - var modalPresentation: ModalPresentation? + var isMentionContentViewPresented = false // MARK: Life cycle diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 30d7551b..2e9b8ac2 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -199,18 +199,26 @@ private extension MessageCell { if let mention = MentionScheme(url) { let coursePath = CoursePath(course: conversationViewModel.course) switch mention { + case let .attachment(id): + navigationController.path.append(LecturePath(id: id, coursePath: coursePath)) case let .channel(id): navigationController.path.append(ConversationPath(id: id, coursePath: coursePath)) case let .exercise(id): navigationController.path.append(ExercisePath(id: id, coursePath: coursePath)) case let .lecture(id): navigationController.path.append(LecturePath(id: id, coursePath: coursePath)) + case let .lectureUnit: + break case let .member(login): Task { if let conversation = await viewModel.getOneToOneChatOrCreate(login: login) { navigationController.path.append(ConversationPath(conversation: conversation, coursePath: coursePath)) } } + case let .message(id): + break + case let .slide: + break } return .handled } diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 2383d7e0..0f86ef65 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -132,7 +132,7 @@ public struct MessagesAvailableView: View { ScrollView { CodeOfConductView(course: viewModel.course) } - .padding() + .contentMargins(.l, for: .scrollContent) .navigationTitle(R.string.localizable.codeOfConduct()) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift index d7e317a9..bbd74e7b 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift @@ -46,7 +46,7 @@ public struct MessagesTabView: View { Spacer() } } - .padding() + .contentMargins(.l, for: .scrollContent) } } .task { diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift index 4cccd8c1..e52bfb23 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift @@ -10,30 +10,32 @@ import SwiftUI struct SendMessageExercisePicker: View { - @Environment(\.dismiss) var dismiss - - @Binding var text: String + let delegate: SendMessageMentionContentDelegate let course: Course var body: some View { - if let exercises = course.exercises, !exercises.isEmpty { - List(exercises) { exercise in - if let title = exercise.baseExercise.title { - Button(title) { - appendMarkdown(for: exercise) - dismiss() + Group { + if let exercises = course.exercises, !exercises.isEmpty { + List(exercises) { exercise in + if let title = exercise.baseExercise.title { + Button(title) { + selectMention(for: exercise) + } } } + .listStyle(.plain) + } else { + ContentUnavailableView(R.string.localizable.exercisesUnavailable(), systemImage: "magnifyingglass") } - } else { - ContentUnavailableView(R.string.localizable.exercisesUnavailable(), systemImage: "magnifyingglass") } + .navigationTitle("Exercises") + .navigationBarTitleDisplayMode(.inline) } } private extension SendMessageExercisePicker { - func appendMarkdown(for exercise: Exercise) { + func selectMention(for exercise: Exercise) { let type: String? switch exercise { case .fileUpload: @@ -54,6 +56,6 @@ private extension SendMessageExercisePicker { return } - text.append("[\(type)]\(title)(/courses/\(course.id)/exercises/\(exercise.id))[/\(type)]") + delegate.pickerDidSelect("[\(type)]\(title)(/courses/\(course.id)/exercises/\(exercise.id))[/\(type)]") } } diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift index 237b3861..0a840dc1 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift @@ -10,24 +10,94 @@ import SwiftUI struct SendMessageLecturePicker: View { - @Environment(\.dismiss) var dismiss + @State var viewModel: SendMessageLecturePickerViewModel - @Binding var text: String + var body: some View { + Group { + if let lectures = viewModel.course.lectures, !lectures.isEmpty { + List(lectures) { lecture in + rowContent(lecture: lecture) + } + .listStyle(.plain) + } else { + ContentUnavailableView(R.string.localizable.lecturesUnavailable(), systemImage: "magnifyingglass") + } + } + .task { + await viewModel.task() + } + .navigationTitle(R.string.localizable.lectures()) + .navigationBarTitleDisplayMode(.inline) + } +} - let course: Course +@MainActor +extension SendMessageLecturePicker { + init(course: Course, delegate: SendMessageMentionContentDelegate) { + self.init(viewModel: SendMessageLecturePickerViewModel(course: course, delegate: delegate)) + } +} - var body: some View { - if let lectures = course.lectures, !lectures.isEmpty { - List(lectures) { lecture in - if let title = lecture.title { - Button(title) { - text.append("[lecture]\(title)(/courses/\(course.id)/lectures/\(lecture.id))[/lecture]") - dismiss() +@MainActor +private extension SendMessageLecturePicker { + @ViewBuilder + func rowContent(lecture: Lecture) -> some View { + if let title = lecture.title { + NavigationLink { + Group { + List { + Button(title) { + viewModel.select(lecture: lecture) + } + ForEach(viewModel.lectureUnits, id: \.id) { lectureUnit in + rowContent(lectureUnit: lectureUnit) + } } + .listStyle(.plain) } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } label: { + Text(title) + } + } + } + + @ViewBuilder + func rowContent(lectureUnit: LectureUnit) -> some View { + if let name = lectureUnit.baseUnit.name { + NavigationLink { + Group { + List { + Button { + viewModel.select(lectureUnit: lectureUnit) + } label: { + Text(name) + } + if case let .attachment(attachment) = lectureUnit, let slides = attachment.slides { + ForEach(slides, id: \.id) { slide in + rowContent(lectureUnit: lectureUnit, slide: slide) + } + } + } + .listStyle(.plain) + } + .navigationTitle(name) + .navigationBarTitleDisplayMode(.inline) + } label: { + Text(name) + } + } + } + + @ViewBuilder + func rowContent(lectureUnit: LectureUnit, slide: Slide) -> some View { + if let slideImagePath = slide.slideImagePath, let slideNumber = slide.slideNumber { + Button { + viewModel.select(lectureUnit: lectureUnit, slide: slide) + } label: { + Text(R.string.localizable.mentionSlideNumber(slideNumber)) } - } else { - ContentUnavailableView(R.string.localizable.lecturesUnavailable(), systemImage: "magnifyingglass") } } } diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift new file mode 100644 index 00000000..55f3d666 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -0,0 +1,50 @@ +// +// SendMessageMentionContentView.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import SwiftUI + +struct SendMessageMentionContentView: View { + + @Bindable var viewModel: SendMessageViewModel + + var body: some View { + NavigationStack { + List { + Button { + viewModel.didTapAtButton() + viewModel.isMentionContentViewPresented.toggle() + } label: { + Label(R.string.localizable.members(), systemImage: "at") + } + Button { + viewModel.didTapNumberButton() + viewModel.isMentionContentViewPresented.toggle() + } label: { + Label(R.string.localizable.channels(), systemImage: "number") + } + + let delegate = SendMessageMentionContentDelegate { [weak viewModel] mention in + viewModel?.text.append(mention) + viewModel?.isMentionContentViewPresented.toggle() + } + NavigationLink { + SendMessageExercisePicker(delegate: delegate, course: viewModel.course) + } label: { + Label(R.string.localizable.exercises(), systemImage: "list.bullet.clipboard") + } + NavigationLink { + SendMessageLecturePicker(course: viewModel.course, delegate: delegate) + } label: { + Label(R.string.localizable.lectures(), systemImage: "character.book.closed") + } + } + .listStyle(.plain) + .navigationTitle(R.string.localizable.mention()) + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index cf1ea0ba..37ccbde3 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -51,15 +51,9 @@ struct SendMessageView: View { } } ) - .sheet(item: $viewModel.modalPresentation) { - isFocused = true - } content: { presentation in - switch presentation { - case .exercisePicker: - SendMessageExercisePicker(text: $viewModel.text, course: viewModel.course) - case .lecturePicker: - SendMessageLecturePicker(text: $viewModel.text, course: viewModel.course) - } + .sheet(isPresented: $viewModel.isMentionContentViewPresented) { + SendMessageMentionContentView(viewModel: viewModel) + .presentationDetents([.fraction(0.5), .medium]) } } } @@ -113,6 +107,11 @@ private extension SendMessageView { HStack { ScrollView(.horizontal, showsIndicators: false) { HStack { + Button { + viewModel.isMentionContentViewPresented.toggle() + } label: { + Image(systemName: "plus.circle.fill") + } Button { viewModel.didTapBoldButton() } label: { @@ -148,28 +147,6 @@ private extension SendMessageView { } label: { Image(systemName: "link") } - Button { - viewModel.didTapAtButton() - } label: { - Image(systemName: "at") - } - Button { - viewModel.didTapNumberButton() - } label: { - Image(systemName: "number") - } - Button { - isFocused = false - viewModel.modalPresentation = .exercisePicker - } label: { - Text(R.string.localizable.exercise()) - } - Button { - isFocused = false - viewModel.modalPresentation = .lecturePicker - } label: { - Text(R.string.localizable.lecture()) - } } } Spacer() From 807ca0c312286b6a5073e7349c5ddbca2a3222c9 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 3 Jun 2024 13:26:44 +0200 Subject: [PATCH 04/48] Add mock channel to conversations list --- .../Services/CodeOfConductService/CodeOfConductService.swift | 3 ++- .../Services/MessagesService/MessagesServiceStub.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift index 912c138b..e7d4a0b7 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -31,5 +31,6 @@ protocol CodeOfConductService { } enum CodeOfConductServiceFactory { - static let shared: CodeOfConductService = CodeOfConductServiceImpl() + // TODO: Invert order + static let shared: CodeOfConductService = !CommandLine.arguments.contains("-Screenshots") ? CodeOfConductServiceStub() : CodeOfConductServiceImpl() } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index 4bb436b0..c31a16a4 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -94,7 +94,7 @@ struct MessagesServiceStub { extension MessagesServiceStub: MessagesService { func getConversations(for courseId: Int) async -> DataState<[Conversation]> { - .loading + .done(response: [.channel(conversation: .mock)]) } func updateIsConversationFavorite(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse { From b3c51de838e6b701ade8847065b0fda9fb24e502 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Mon, 3 Jun 2024 13:34:12 +0200 Subject: [PATCH 05/48] `Development`: Fix core modules --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- ArtemisKit/Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e9942512..968e4d87 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "9c70eae3336c21f9de1e84ae7d25134d019b4dac", - "version" : "11.0.0" + "revision" : "5ceb0023189edb62d2411826fe43a8504df1adb7", + "version" : "11.1.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index ec1d6995..242eaf10 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "11.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "11.1.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ From 598ec75188060c2fb8ba68f198a92bf633223083 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 3 Jun 2024 13:41:35 +0200 Subject: [PATCH 06/48] Activate mock models only for `-Screenshots` option --- .../Services/CodeOfConductService/CodeOfConductService.swift | 3 +-- .../Messages/Services/MessagesService/MessagesService.swift | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift index e7d4a0b7..d10a2e7d 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -31,6 +31,5 @@ protocol CodeOfConductService { } enum CodeOfConductServiceFactory { - // TODO: Invert order - static let shared: CodeOfConductService = !CommandLine.arguments.contains("-Screenshots") ? CodeOfConductServiceStub() : CodeOfConductServiceImpl() + static let shared: CodeOfConductService = CommandLine.arguments.contains("-Screenshots") ? CodeOfConductServiceStub() : CodeOfConductServiceImpl() } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index c11f9ef3..9b62961f 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -179,6 +179,5 @@ extension MessagesService { } enum MessagesServiceFactory { - // TODO: Invert Order - static let shared: MessagesService = !CommandLine.arguments.contains("-Screenshots") ? MessagesServiceStub() : MessagesServiceImpl() + static let shared: MessagesService = CommandLine.arguments.contains("-Screenshots") ? MessagesServiceStub() : MessagesServiceImpl() } From d4c48d73ff8f8f57de4149a1c004b49738ce77c2 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 3 Jun 2024 15:37:28 +0200 Subject: [PATCH 07/48] Add UI Test target --- Artemis.xcodeproj/project.pbxproj | 137 +++++++- .../xcshareddata/xcschemes/Artemis.xcscheme | 11 + .../xcschemes/ArtemisUITests.xcscheme | 54 +++ ArtemisUITests/ArtemisUITests.swift | 44 +++ ArtemisUITests/SnapshotHelper.swift | 313 ++++++++++++++++++ 5 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 Artemis.xcodeproj/xcshareddata/xcschemes/ArtemisUITests.xcscheme create mode 100644 ArtemisUITests/ArtemisUITests.swift create mode 100644 ArtemisUITests/SnapshotHelper.swift diff --git a/Artemis.xcodeproj/project.pbxproj b/Artemis.xcodeproj/project.pbxproj index 0916b53e..e65d02bb 100644 --- a/Artemis.xcodeproj/project.pbxproj +++ b/Artemis.xcodeproj/project.pbxproj @@ -10,13 +10,28 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* ArtemisApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* ArtemisApp.swift */; }; + 51F1B2252C0CC26800F14D01 /* ArtemisUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F1B2242C0CC26800F14D01 /* ArtemisUITests.swift */; }; + 51F1B22C2C0CC2D700F14D01 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F1B22B2C0CC2D700F14D01 /* SnapshotHelper.swift */; }; A166A2592B0381F000AB6119 /* ArtemisKit in Frameworks */ = {isa = PBXBuildFile; productRef = A166A2582B0381F000AB6119 /* ArtemisKit */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 51F1B2262C0CC26800F14D01 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7555FF73242A565900829871 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7555FF7A242A565900829871; + remoteInfo = Artemis; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* ArtemisApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtemisApp.swift; sourceTree = ""; }; + 51F1B2202C0CC26800F14D01 /* ArtemisUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ArtemisUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 51F1B2242C0CC26800F14D01 /* ArtemisUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtemisUITests.swift; sourceTree = ""; }; + 51F1B22B2C0CC2D700F14D01 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* Artemis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Artemis.app; sourceTree = BUILT_PRODUCTS_DIR; }; A166A2622B03893900AB6119 /* Gemfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Gemfile; sourceTree = SOURCE_ROOT; }; A166A2632B03893900AB6119 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; @@ -39,6 +54,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 51F1B21D2C0CC26800F14D01 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -57,12 +79,22 @@ name = Frameworks; sourceTree = ""; }; + 51F1B2212C0CC26800F14D01 /* ArtemisUITests */ = { + isa = PBXGroup; + children = ( + 51F1B2242C0CC26800F14D01 /* ArtemisUITests.swift */, + 51F1B22B2C0CC2D700F14D01 /* SnapshotHelper.swift */, + ); + path = ArtemisUITests; + sourceTree = ""; + }; 7555FF72242A565900829871 = { isa = PBXGroup; children = ( D51AD00C299E390700FA5B94 /* Artemis.entitlements */, A1C7E0A92B03754200804542 /* ArtemisKit */, 7555FF7D242A565900829871 /* Artemis */, + 51F1B2212C0CC26800F14D01 /* ArtemisUITests */, 7555FF7C242A565900829871 /* Products */, 22B6A91C292D785600F08C7E /* Frameworks */, ); @@ -72,6 +104,7 @@ isa = PBXGroup; children = ( 7555FF7B242A565900829871 /* Artemis.app */, + 51F1B2202C0CC26800F14D01 /* ArtemisUITests.xctest */, ); name = Products; sourceTree = ""; @@ -105,6 +138,26 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 51F1B21F2C0CC26800F14D01 /* ArtemisUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 51F1B22A2C0CC26800F14D01 /* Build configuration list for PBXNativeTarget "ArtemisUITests" */; + buildPhases = ( + 51F1B21C2C0CC26800F14D01 /* Sources */, + 51F1B21D2C0CC26800F14D01 /* Frameworks */, + 51F1B21E2C0CC26800F14D01 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 51F1B2272C0CC26800F14D01 /* PBXTargetDependency */, + ); + name = ArtemisUITests; + packageProductDependencies = ( + ); + productName = ArtemisUITests; + productReference = 51F1B2202C0CC26800F14D01 /* ArtemisUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 7555FF7A242A565900829871 /* Artemis */ = { isa = PBXNativeTarget; buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "Artemis" */; @@ -133,10 +186,14 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1130; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1500; ORGANIZATIONNAME = orgName; TargetAttributes = { + 51F1B21F2C0CC26800F14D01 = { + CreatedOnToolsVersion = 15.2; + TestTargetID = 7555FF7A242A565900829871; + }; 7555FF7A242A565900829871 = { CreatedOnToolsVersion = 11.3.1; }; @@ -158,11 +215,19 @@ projectRoot = ""; targets = ( 7555FF7A242A565900829871 /* Artemis */, + 51F1B21F2C0CC26800F14D01 /* ArtemisUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 51F1B21E2C0CC26800F14D01 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7555FF79242A565900829871 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -195,6 +260,15 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 51F1B21C2C0CC26800F14D01 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 51F1B2252C0CC26800F14D01 /* ArtemisUITests.swift in Sources */, + 51F1B22C2C0CC2D700F14D01 /* SnapshotHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7555FF77242A565900829871 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -205,7 +279,59 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 51F1B2272C0CC26800F14D01 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7555FF7A242A565900829871 /* Artemis */; + targetProxy = 51F1B2262C0CC26800F14D01 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 51F1B2282C0CC26800F14D01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.tum.cit.artemis.ArtemisUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Artemis; + }; + name = Debug; + }; + 51F1B2292C0CC26800F14D01 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.tum.cit.artemis.ArtemisUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Artemis; + }; + name = Release; + }; 7555FFA3242A565B00829871 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -385,6 +511,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 51F1B22A2C0CC26800F14D01 /* Build configuration list for PBXNativeTarget "ArtemisUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 51F1B2282C0CC26800F14D01 /* Debug */, + 51F1B2292C0CC26800F14D01 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 7555FF76242A565900829871 /* Build configuration list for PBXProject "Artemis" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme b/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme index 8c99ab8b..0e523cc9 100644 --- a/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme +++ b/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme @@ -28,6 +28,17 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ArtemisUITests/ArtemisUITests.swift b/ArtemisUITests/ArtemisUITests.swift new file mode 100644 index 00000000..092d7e81 --- /dev/null +++ b/ArtemisUITests/ArtemisUITests.swift @@ -0,0 +1,44 @@ +// +// ArtemisUITests.swift +// ArtemisUITests +// +// Created by Anian Schleyer on 02.06.24. +// Copyright © 2024 orgName. All rights reserved. +// + +import XCTest + +final class ArtemisUITests: XCTestCase { + var app: XCUIApplication! + + @MainActor + override func setUp() { + super.setUp() + app = XCUIApplication() + setupSnapshot(app) + app.launchArguments += ["-Screenshots"] + } + + @MainActor + func testTakeScreenshots() { + app.launch() + + snapshot("01Dashboard") + + // Navigate to course details + app.staticTexts["Interactive Learning"].tap() + + snapshot("02CourseView") + + // Navigate to messages tab + app.tabBars.firstMatch.buttons["Messages"].tap() + + // Accept code of conduct + let accept = app.buttons["Accept"] + if accept.exists { + accept.tap() + } + + snapshot("03MessagesView") + } +} diff --git a/ArtemisUITests/SnapshotHelper.swift b/ArtemisUITests/SnapshotHelper.swift new file mode 100644 index 00000000..35f30664 --- /dev/null +++ b/ArtemisUITests/SnapshotHelper.swift @@ -0,0 +1,313 @@ +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch let error { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png", isDirectory: false) + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch let error { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.30] From 1c8ae1d79ef825c0ad5b4e571d2e5df70fb59335 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 3 Jun 2024 15:43:29 +0200 Subject: [PATCH 08/48] Add fastlane config for screenshots --- fastlane/Fastfile | 11 +++++++++++ fastlane/README.md | 8 ++++++++ fastlane/Snapfile | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 fastlane/Snapfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ad4c8d4a..60d5277c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -17,6 +17,17 @@ default_platform(:ios) platform :ios do + desc "Generate new screenshots" + lane :screenshots do + capture_screenshots + upload_to_app_store( + api_key: api_key, + force: true, + overwrite_screenshots: true, + precheck_include_in_app_purchases: false + ) + end + desc "[CI] Check static code quality" lane :swift_lint do swiftlint( diff --git a/fastlane/README.md b/fastlane/README.md index 051f2f3b..1bbfc1cf 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,6 +15,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do ## iOS +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Generate new screenshots + ### ios swift_lint ```sh diff --git a/fastlane/Snapfile b/fastlane/Snapfile new file mode 100644 index 00000000..56844db2 --- /dev/null +++ b/fastlane/Snapfile @@ -0,0 +1,26 @@ +# For more information about all available options run fastlane action snapshot + +devices([ + "iPhone 15 Pro Max", + "iPhone 14 Plus", + "iPad Pro (12.9-inch) (6th generation)", + "iPad Pro (12.9-inch) (2nd generation)" +]) + +languages([ + "en-US" +]) + +scheme("ArtemisUITests") + +output_directory("./screenshots") + +ios_version '17.2' + +clear_previous_screenshots(true) + +override_status_bar(true) + +number_of_retries(2) + +skip_open_summary(true) \ No newline at end of file From f8f99cb62135b80a55d72db6ec26815153adce81 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Wed, 5 Jun 2024 20:18:24 +0200 Subject: [PATCH 09/48] Rename CodeOfConductServiceStub file in header comment --- .../CodeOfConductService/CodeOfConductServiceStub.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift index c2f6f043..8a70adf2 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// CodeOfConductServiceStub.swift +// // // Created by Anian Schleyer on 03.06.24. // From ab57d03fb4cae811545a6916ad34717405dba494 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Wed, 5 Jun 2024 20:26:53 +0200 Subject: [PATCH 10/48] Use new Stub/Impl property wrapper for Factories --- .../Services/CodeOfConductService/CodeOfConductService.swift | 3 ++- .../Messages/Services/MessagesService/MessagesService.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift index d10a2e7d..e49f5cd0 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -31,5 +31,6 @@ protocol CodeOfConductService { } enum CodeOfConductServiceFactory { - static let shared: CodeOfConductService = CommandLine.arguments.contains("-Screenshots") ? CodeOfConductServiceStub() : CodeOfConductServiceImpl() + @StubOrImpl(stub: CodeOfConductServiceStub(), impl: CodeOfConductServiceImpl()) + static var shared: CodeOfConductService } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 9b62961f..773c5072 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -179,5 +179,6 @@ extension MessagesService { } enum MessagesServiceFactory { - static let shared: MessagesService = CommandLine.arguments.contains("-Screenshots") ? MessagesServiceStub() : MessagesServiceImpl() + @StubOrImpl(stub: MessagesServiceStub(), impl: MessagesServiceImpl()) + static var shared: MessagesService } From ae1a2171b75c30f00019b2870d58127f4c4dbff7 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Thu, 6 Jun 2024 13:21:24 +0200 Subject: [PATCH 11/48] Update with DependencyFactory --- ArtemisKit/Sources/ArtemisKit/AppDelegate.swift | 2 +- .../Sources/ArtemisKit/RootViewModel.swift | 2 +- .../ExerciseTab/ExerciseDetailView.swift | 16 ++++++++++++---- .../LectureService/LectureServiceImpl.swift | 4 +++- .../CodeOfConductService.swift | 7 ++++--- .../CodeOfConductStorageServiceImpl.swift | 4 ++-- .../MessagesService/MessagesService.swift | 13 +++++++------ .../MessagesService/MessagesServiceImpl.swift | 2 +- .../ConversationViewModel.swift | 2 +- .../MessageCellModel.swift | 2 +- .../MessagesAvailableViewModel.swift | 2 +- .../SendMessageViewModel.swift | 2 +- .../ConversationInfoSheetView.swift | 4 ++-- .../Navigation/Deeplinks/DeeplinkHandler.swift | 2 +- .../NotificationWebsocketServiceImpl.swift | 8 ++++---- .../ViewModels/NotificationViewModel.swift | 2 +- 16 files changed, 43 insertions(+), 31 deletions(-) diff --git a/ArtemisKit/Sources/ArtemisKit/AppDelegate.swift b/ArtemisKit/Sources/ArtemisKit/AppDelegate.swift index c733caa8..92f64269 100644 --- a/ArtemisKit/Sources/ArtemisKit/AppDelegate.swift +++ b/ArtemisKit/Sources/ArtemisKit/AppDelegate.swift @@ -46,7 +46,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - UserSession.shared.saveNotificationDeviceConfiguration(token: nil, encryptionKey: nil, skippedNotifications: true) + UserSessionFactory.shared.saveNotificationDeviceConfiguration(token: nil, encryptionKey: nil, skippedNotifications: true) log.error("Did Fail To Register For Remote Notifications With Error: \(error)") } diff --git a/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift index 2d645a15..2623c208 100644 --- a/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift @@ -27,7 +27,7 @@ class RootViewModel: ObservableObject { private var cancellable: Set = Set() init( - userSession: UserSession = .shared, + userSession: UserSession = UserSessionFactory.shared, accountService: AccountService = AccountServiceFactory.shared ) { self.userSession = userSession diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index a6307dd2..126c945b 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -34,7 +34,9 @@ public struct ExerciseDetailView: View { public init(course: Course, exercise: Exercise) { self._exercise = State(wrappedValue: .done(response: exercise)) - self._urlRequest = State(wrappedValue: URLRequest(url: URL(string: "/courses/\(course.id)/exercises/\(exercise.id)/problem-statement", relativeTo: UserSession.shared.institution?.baseURL)!)) + self._urlRequest = State(wrappedValue: URLRequest(url: URL( + string: "/courses/\(course.id)/exercises/\(exercise.id)/problem-statement", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!)) self.exerciseId = exercise.id self.courseId = course.id @@ -42,7 +44,9 @@ public struct ExerciseDetailView: View { public init(courseId: Int, exerciseId: Int) { self._exercise = State(wrappedValue: .loading) - self._urlRequest = State(wrappedValue: URLRequest(url: URL(string: "/courses/\(courseId)/exercises/\(exerciseId)", relativeTo: UserSession.shared.institution?.baseURL)!)) + self._urlRequest = State(wrappedValue: URLRequest(url: URL( + string: "/courses/\(courseId)/exercises/\(exerciseId)", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!)) self.exerciseId = exerciseId self.courseId = courseId @@ -278,7 +282,9 @@ public struct ExerciseDetailView: View { self.latestResultId = latestResultId } - urlRequest = URLRequest(url: URL(string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", relativeTo: UserSession.shared.institution?.baseURL)!) + urlRequest = URLRequest(url: URL( + string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!) } /// JavaScript to reduce visible content in WebView to just problem statement @@ -390,7 +396,9 @@ private struct FeedbackView: View { @State private var isWebViewLoading = true init(courseId: Int, exerciseId: Int, participationId: Int, resultId: Int) { - self._urlRequest = State(wrappedValue: URLRequest(url: URL(string: "/courses/\(courseId)/exercises/\(exerciseId)/participations/\(participationId)/results/\(resultId)/feedback/", relativeTo: UserSession.shared.institution?.baseURL)!)) + self._urlRequest = State(wrappedValue: URLRequest(url: URL( + string: "/courses/\(courseId)/exercises/\(exerciseId)/participations/\(participationId)/results/\(resultId)/feedback/", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!)) } var body: some View { diff --git a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift index c892fd44..f15d3626 100644 --- a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift +++ b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift @@ -70,7 +70,9 @@ class LectureServiceImpl: LectureService { } func getAttachmentFile(link: String) async -> DataState { - guard let url = URL(string: link, relativeTo: UserSession.shared.institution?.baseURL) else { return .failure(error: UserFacingError(title: "Wrong URL")) } + guard let url = URL(string: link, relativeTo: UserSessionFactory.shared.institution?.baseURL) else { + return .failure(error: UserFacingError(title: "Wrong URL")) + } do { let (data, _) = try await URLSession.shared.data(from: url) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift index e49f5cd0..cfe0d499 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -30,7 +30,8 @@ protocol CodeOfConductService { func getTemplate() async -> DataState } -enum CodeOfConductServiceFactory { - @StubOrImpl(stub: CodeOfConductServiceStub(), impl: CodeOfConductServiceImpl()) - static var shared: CodeOfConductService +enum CodeOfConductServiceFactory: DependencyFactory { + static let liveValue: CodeOfConductService = CodeOfConductServiceImpl() + + static let testValue: CodeOfConductService = CodeOfConductServiceStub() } diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift index 8990951f..79008d79 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift @@ -12,7 +12,7 @@ import UserStore struct CodeOfConductStorageServiceImpl: CodeOfConductStorageService { func acceptCodeOfConduct(for courseId: Int, codeOfConduct: String) { - guard let serverHost = UserSession.shared.institution?.baseURL?.absoluteString, + guard let serverHost = UserSessionFactory.shared.institution?.baseURL?.absoluteString, let data = codeOfConduct.data(using: .utf8) else { return } @@ -21,7 +21,7 @@ struct CodeOfConductStorageServiceImpl: CodeOfConductStorageService { } func getAgreement(for courseId: Int, codeOfConduct: String) -> Bool { - guard let serverHost = UserSession.shared.institution?.baseURL?.absoluteString, + guard let serverHost = UserSessionFactory.shared.institution?.baseURL?.absoluteString, let data = codeOfConduct.data(using: .utf8) else { return false } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 773c5072..ba381e58 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -156,19 +156,19 @@ protocol MessagesService { extension MessagesService { func joinChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { - guard let username = UserSession.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } + guard let username = UserSessionFactory.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } return await addMembersToChannel(for: courseId, channelId: channelId, usernames: [username]) } func leaveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { - guard let username = UserSession.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } + guard let username = UserSessionFactory.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } return await removeMembersFromChannel(for: courseId, channelId: channelId, usernames: [username]) } func leaveConversation(for courseId: Int, groupChatId: Int64) async -> NetworkResponse { - guard let username = UserSession.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } + guard let username = UserSessionFactory.shared.user?.login else { return .failure(error: UserFacingError(error: APIClientError.wrongParameters)) } return await removeMembersFromGroupChat(for: courseId, groupChatId: groupChatId, usernames: [username]) } @@ -178,7 +178,8 @@ extension MessagesService { } } -enum MessagesServiceFactory { - @StubOrImpl(stub: MessagesServiceStub(), impl: MessagesServiceImpl()) - static var shared: MessagesService +enum MessagesServiceFactory: DependencyFactory { + static let liveValue: MessagesService = MessagesServiceImpl() + + static let testValue: MessagesService = MessagesServiceStub() } diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index 4484de44..728c890b 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -12,7 +12,7 @@ import SharedModels import UserStore // swiftlint:disable file_length type_body_length -class MessagesServiceImpl: MessagesService { +struct MessagesServiceImpl: MessagesService { private let client = APIClient() diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 70e0bd4c..de16f845 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -58,7 +58,7 @@ class ConversationViewModel: BaseViewModel { messagesRepository: MessagesRepository = .shared, messagesService: MessagesService = MessagesServiceFactory.shared, stompClient: ArtemisStompClient = .shared, - userSession: UserSession = .shared + userSession: UserSession = UserSessionFactory.shared ) { self.course = course self.conversation = conversation diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift index a8178379..54f8d761 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -31,7 +31,7 @@ final class MessageCellModel { isHeaderVisible: Bool, retryButtonAction: (() -> Void)?, messagesService: MessagesService = MessagesServiceFactory.shared, - userSession: UserSession = .shared + userSession: UserSession = UserSessionFactory.shared ) { self.course = course self.conversationPath = conversationPath diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift index 9a58a483..81741df6 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift @@ -42,7 +42,7 @@ class MessagesAvailableViewModel: BaseViewModel { course: Course, messagesService: MessagesService = MessagesServiceFactory.shared, stompClient: ArtemisStompClient = ArtemisStompClient.shared, - userSession: UserSession = UserSession.shared + userSession: UserSession = UserSessionFactory.shared ) { self.course = course self.courseId = course.id diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index 9defb09d..2f6f9549 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -80,7 +80,7 @@ final class SendMessageViewModel { delegate: SendMessageViewModelDelegate, messagesRepository: MessagesRepository = .shared, messagesService: MessagesService = MessagesServiceFactory.shared, - userSession: UserSession = .shared + userSession: UserSession = UserSessionFactory.shared ) { self.course = course self.conversation = conversation diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift index 7f5b60e5..87185d61 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift @@ -121,12 +121,12 @@ private extension ConversationInfoSheetView { HStack { Text(name) Spacer() - if UserSession.shared.user?.login == member.login { + if UserSessionFactory.shared.user?.login == member.login { Chip(text: R.string.localizable.youLabel(), backgroundColor: .Artemis.artemisBlue) } } .contextMenu { - if UserSession.shared.user?.login != member.login, + if UserSessionFactory.shared.user?.login != member.login, viewModel.canRemoveUsers { Button(R.string.localizable.removeUserButtonLabel()) { viewModel.isLoading = true diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift index fb24e254..5f308cbe 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift @@ -23,7 +23,7 @@ public class DeeplinkHandler { private let userSession: UserSession private init( - userSession: UserSession = .shared + userSession: UserSession = UserSessionFactory.shared ) { self.userSession = userSession } diff --git a/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift index 07996b19..96515ad3 100644 --- a/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift +++ b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift @@ -73,7 +73,7 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { } private func subscribeToSingleUserNotificationUpdates() async { - guard let userId = UserSession.shared.user?.id else { + guard let userId = UserSessionFactory.shared.user?.id else { log.debug("User could not be found. Subscribe to UserNotifications not possible") return } @@ -199,7 +199,7 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { } private func subscribeToTutorialGroupNotificationUpdates() async { - guard let userId = UserSession.shared.user?.id else { + guard let userId = UserSessionFactory.shared.user?.id else { log.debug("User could not be found. Subscription to UserNotifications is not possible") return } @@ -217,7 +217,7 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { } private func subscribeToConversationNotificationUpdates() async { - guard let userId = UserSession.shared.user?.id else { + guard let userId = UserSessionFactory.shared.user?.id else { log.debug("User could not be found. Subscription to UserNotifications is not possible") return } @@ -228,7 +228,7 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { let task = Task { for await message in stream { guard let notification = JSONDecoder.getTypeFromSocketMessage(type: Notification.self, message: message), - let userId = UserSession.shared.user?.id else { continue } + let userId = UserSessionFactory.shared.user?.id else { continue } // Only add notification if it is not from the current user if notification.author?.id != userId { diff --git a/ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift b/ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift index f1f0896a..b41f70b2 100644 --- a/ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift +++ b/ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift @@ -42,7 +42,7 @@ class NotificationViewModel: ObservableObject { } private func updateLastNotificationSeenDate() { - let userLastNotificationSeen = UserSession.shared.user?.lastNotificationRead + let userLastNotificationSeen = UserSessionFactory.shared.user?.lastNotificationRead let storedLastNotificationSeenDate = UserDefaults.standard.object(forKey: "lastNotificationSeenDate") as? Date if let userLastNotificationSeen, From 080bcddc9541c23aa6d56278ed37f7719523091d Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Thu, 6 Jun 2024 13:21:58 +0200 Subject: [PATCH 12/48] Fix test https://github.com/ls1intum/artemis-ios-core-modules/pull/56/commits/94c02f6a69d91d5599b0042e483f5fa5fdd68ad1 --- ArtemisUITests/ArtemisUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArtemisUITests/ArtemisUITests.swift b/ArtemisUITests/ArtemisUITests.swift index 092d7e81..30d9365e 100644 --- a/ArtemisUITests/ArtemisUITests.swift +++ b/ArtemisUITests/ArtemisUITests.swift @@ -16,7 +16,7 @@ final class ArtemisUITests: XCTestCase { super.setUp() app = XCUIApplication() setupSnapshot(app) - app.launchArguments += ["-Screenshots"] + app.launchArguments += ["-dependency-factory-test-value"] } @MainActor From 1e7e2d581ee2fb400ad9929829ec8767cb2e860b Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Thu, 6 Jun 2024 13:31:16 +0200 Subject: [PATCH 13/48] On review --- .../CodeOfConductService/CodeOfConductServiceImpl.swift | 2 +- .../CodeOfConductService/CodeOfConductServiceStub.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift index 308935df..f08537ef 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift @@ -8,7 +8,7 @@ import APIClient import Common -class CodeOfConductServiceImpl: CodeOfConductService { +struct CodeOfConductServiceImpl: CodeOfConductService { private let client = APIClient() diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift index 8a70adf2..725c0f6a 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift @@ -8,12 +8,12 @@ import Foundation import Common -public class CodeOfConductServiceStub: CodeOfConductService { - public func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse { +struct CodeOfConductServiceStub: CodeOfConductService { + func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse { return .success } - public func getAgreement(for courseId: Int) async -> DataState { + func getAgreement(for courseId: Int) async -> DataState { return .done(response: true) } From 1b9d59643b644e4ed40a645a384fe775e92c7321 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Fri, 7 Jun 2024 01:11:43 +0200 Subject: [PATCH 14/48] `Development`: Semantic Versioning (#111) * Set CFBundleShortVersionString directly --- Artemis/Supporting/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index 6f222b2f..cd3b097b 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + 1.1.0 CFBundleVersion 1 LSRequiresIPhoneOS From 99d5f44da8657d60fa339b1bd1ac15dc2e95c8b7 Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Fri, 7 Jun 2024 20:36:23 +0200 Subject: [PATCH 15/48] Remove unused command line argument from UITest class --- ArtemisUITests/ArtemisUITests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/ArtemisUITests/ArtemisUITests.swift b/ArtemisUITests/ArtemisUITests.swift index 30d9365e..02827fc0 100644 --- a/ArtemisUITests/ArtemisUITests.swift +++ b/ArtemisUITests/ArtemisUITests.swift @@ -16,7 +16,6 @@ final class ArtemisUITests: XCTestCase { super.setUp() app = XCUIApplication() setupSnapshot(app) - app.launchArguments += ["-dependency-factory-test-value"] } @MainActor From c361641febdb5e61a72d7c8a9cddfb93ca8698fc Mon Sep 17 00:00:00 2001 From: Anian Schleyer Date: Mon, 10 Jun 2024 11:16:41 +0200 Subject: [PATCH 16/48] Update core modules to version 12 --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- ArtemisKit/Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 968e4d87..a7d954a9 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "5ceb0023189edb62d2411826fe43a8504df1adb7", - "version" : "11.1.0" + "revision" : "91b63e8e82a3a4ac100fac0a2a58ddd27a8f6f30", + "version" : "12.0.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 242eaf10..18d2dce9 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "11.1.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "12.0.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ From f6e13cac502fe997026733d0e80c436934a58181 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 12 Jun 2024 22:04:42 +0200 Subject: [PATCH 17/48] Adapt size of problem statement view to new component (#113) Adapt size of problem statement view to new component --- .../ExerciseTab/ExerciseDetailView.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index 126c945b..63bd882f 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -224,7 +224,7 @@ public struct ExerciseDetailView: View { ArtemisWebView(urlRequest: $urlRequest, contentHeight: $webViewHeight, isLoading: $isWebViewLoading, - customJSHeightQuery: webViewContentJS) + customJSHeightQuery: webViewHeightJS) .frame(height: webViewHeight) .allowsHitTesting(false) .loadingIndicator(isLoading: $isWebViewLoading) @@ -287,15 +287,15 @@ public struct ExerciseDetailView: View { relativeTo: UserSessionFactory.shared.institution?.baseURL)!) } - /// JavaScript to reduce visible content in WebView to just problem statement - private let webViewContentJS = """ - if (document.querySelector("jhi-course-overview") != null - && document.querySelector("jhi-programming-exercise-instructions") != null - && document.querySelector("jhi-problem-statement").innerText.length > 10) { - document.querySelector("jhi-course-overview").innerHTML = document.querySelector("jhi-programming-exercise-instructions").innerHTML; - document.querySelector("#programming-exercise-instructions-content").setAttribute("style", "overflow: unset"); + /// We need a custom height calculation, otherwise the web view is often too small + private let webViewHeightJS = """ + if (document.querySelector("#problem-statement") != null) { + document.querySelector("#problem-statement").scrollHeight; + } else if (document.querySelector(".instructions__content") != null) { + document.querySelector(".instructions__content").scrollHeight; + } else { + document.body.scrollHeight; } - document.querySelector(".instructions__content").scrollHeight """ } From 9fda93f8541e1fa019a1b3813c96723d5c667934 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 17 Jun 2024 11:09:33 +0200 Subject: [PATCH 18/48] `Dashboard`: Enable Caching for Course icons (#117) --- .../Sources/Dashboard/CourseGridCell.swift | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift index 7f87a54f..128269d2 100644 --- a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift +++ b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift @@ -46,17 +46,13 @@ struct CourseGridCell: View { private extension CourseGridCell { var header: some View { HStack(alignment: .center) { - AsyncImage(url: courseForDashboard.course.courseIconURL) { phase in - switch phase { - case let .success(image): - image - .resizable() - .clipShape(.circle) - .frame(width: .extraLargeImage) - case .failure, .empty: - EmptyView() - @unknown default: - EmptyView() + VStack { + if let imageURL = courseForDashboard.course.courseIconURL { + ArtemisAsyncImage(imageURL: imageURL) { + EmptyView() + } + .clipShape(.circle) + .frame(width: .extraLargeImage) } } .frame(height: .extraLargeImage) From d10276e4a517a2ea54791a8126fd091801dda71e Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 17 Jun 2024 14:49:07 +0200 Subject: [PATCH 19/48] `Dashboard`: Fix font of Course Title (#118) --- ArtemisKit/Sources/Dashboard/CourseGridCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift index 128269d2..4dd95618 100644 --- a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift +++ b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift @@ -59,7 +59,7 @@ private extension CourseGridCell { .padding([.leading, .vertical], .m) VStack(alignment: .leading, spacing: 0) { Text(courseForDashboard.course.title ?? "") - .font(.custom("SF Pro", size: 21, relativeTo: .title)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 21))) .multilineTextAlignment(.leading) .lineLimit(2) Text(R.string.localizable.dashboardExercisesLabel(courseForDashboard.course.exercises?.count ?? 0)) From a53d396571cbea64699023025b8b85900fc8fa14 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Wed, 19 Jun 2024 18:34:57 +0200 Subject: [PATCH 20/48] `Text exercises`: Add participation in text exercises (#59) * Start exercise * Create ExerciseParticipationView * Create ExerciseParticipationViewModel * Localize button * Create ExerciseDetailViewModel * Move courseId & exerciseId * Prepare exercise * Move exercise * Move score, showFeedbackButton, and isExerciseParticipationAvailable * Move ExerciseDetailViewModel * Move showFeedback * Move urlRequest * Move init * Move isWebViewLoading, webViewId, webViewHeight, and webViewHeightJS * Move latestResultId & participationId * Move loadExercise & refreshExercise * Split body * Inject dependencies * Open EditTextExerciseView * Move EditTextExerciseViewTab * Remove segmented control * Present sheet * Problem statement * Generalize SubmitButton: ExerciseParticipationSubmitButton * Generalize ProblemStatementButton: ExerciseParticipationProblemButton * Reuse ExerciseParticipationProblemButton * Simplify ExerciseParticipationSubmitButton * Add participationId, result, fetchSubmission, and submit * Add TextExerciseSubmissionServiceImpl * Add isSubmissionAlertPresented & isSubmissionSuccessful * Load submission * Submit * Add alert * Add ViewTextExerciseView * Style ViewTextExerciseView * Stub ViewTextExerciseResultView * Generalize ExerciseParticipationAssessmentButton * Show feedback --- .../xcshareddata/swiftpm/Package.resolved | 12 +- ArtemisKit/Package.swift | 2 +- ...xerciseParticipationAssessmentButton.swift | 30 ++ .../ExerciseParticipationProblemButton.swift | 30 ++ .../ExerciseParticipationSubmitButton.swift | 62 +++ .../Views/EditModelingExerciseView.swift | 148 ++---- .../ViewModelingExerciseResultView.swift | 37 +- .../TextExercise/EditTextExerciseView.swift | 110 ++++ .../EditTextExerciseViewModel.swift | 90 ++++ .../TextExercise/ViewTextExerciseView.swift | 40 ++ .../ExerciseTab/ExerciseDetailView.swift | 486 ++++++++---------- .../ExerciseTab/ExerciseDetailViewModel.swift | 132 +++++ .../Resources/en.lproj/Localizable.strings | 1 + .../ExerciseSubmissionService.swift | 2 + .../TextExerciseSubmissionServiceImpl.swift | 92 ++++ 15 files changed, 881 insertions(+), 393 deletions(-) create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationAssessmentButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationProblemButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationSubmitButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseViewModel.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/ViewTextExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/TextExerciseSubmissionServiceImpl.swift diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a7d954a9..19018110 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "91b63e8e82a3a4ac100fac0a2a58ddd27a8f6f30", - "version" : "12.0.0" + "revision" : "28a9c7a2c169171c11753b06f0b71d008f592d74", + "version" : "13.0.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "5b92f029fab2cce44386d28588098b5be0824ef5", - "version" : "7.11.0" + "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", + "version" : "7.12.0" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "ae799d015a5374708f7b4c85f3294c05f2a564e2", - "version" : "2.3.0" + "revision" : "9a8119b37e09a770367eeb26e05267c75d854053", + "version" : "2.3.1" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 18d2dce9..72c15313 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "12.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "13.0.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationAssessmentButton.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationAssessmentButton.swift new file mode 100644 index 00000000..4b802300 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationAssessmentButton.swift @@ -0,0 +1,30 @@ +// +// ExerciseParticipationAssessmentButton.swift +// +// +// Created by Nityananda Zbil on 17.06.24. +// + +import SwiftUI + +struct ExerciseParticipationAssessmentButton: View { + @Binding var isAssessmentPresented: Bool + + var body: some View { + Button { + self.isAssessmentPresented = true + } label: { + Image(systemName: "ellipsis.message") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .font(.headline) + .padding(.vertical, .m) + .padding(.horizontal, .l) + .background { + RoundedRectangle(cornerRadius: 4) + .foregroundColor(Color.Artemis.primaryButtonColor) + } + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationProblemButton.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationProblemButton.swift new file mode 100644 index 00000000..d74aecd5 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationProblemButton.swift @@ -0,0 +1,30 @@ +// +// ExerciseParticipationProblemButton.swift +// +// +// Created by Nityananda Zbil on 15.06.24. +// + +import SwiftUI + +struct ExerciseParticipationProblemButton: View { + @Binding var isProblemPresented: Bool + + var body: some View { + Button { + isProblemPresented = true + } label: { + Image(systemName: "newspaper") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.white) + .font(.headline) + .padding(.vertical, .m) + .padding(.horizontal, .l) + .background { + RoundedRectangle(cornerRadius: 4) + .foregroundColor(Color.Artemis.primaryButtonColor) + } + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationSubmitButton.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationSubmitButton.swift new file mode 100644 index 00000000..d126378f --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationSubmitButton.swift @@ -0,0 +1,62 @@ +// +// ExerciseParticipationSubmitButton.swift +// +// +// Created by Nityananda Zbil on 15.06.24. +// + +import DesignLibrary +import SwiftUI + +struct ExerciseParticipationSubmitButton: View { + let submit: () async throws -> Void + + @Binding var isSubmissionAlertPresented: Bool + @Binding var isSubmissionSuccessful: Bool + + @State private var isSubmitting = false + + var body: some View { + Button { + action() + } label: { + ZStack { + Text(R.string.localizable.submitSubmission()) + .opacity(isSubmitting ? 0 : 1) + // Show a Progress View, whilst the submision is being submitted + if isSubmitting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.Artemis.primaryButtonTextColor)) + } + } + } + .buttonStyle(ArtemisButton(buttonColor: buttonColor, buttonTextColor: Color.Artemis.primaryButtonTextColor)) + .disabled(isSubmitting) + } +} + +private extension ExerciseParticipationSubmitButton { + func action() { + isSubmitting = true + Task { + do { + try await submit() + isSubmissionSuccessful = true + } catch { + isSubmissionSuccessful = false + } + withAnimation { + isSubmitting = false + isSubmissionAlertPresented.toggle() + } + } + } + + var buttonColor: Color { + if isSubmissionAlertPresented { + (isSubmissionSuccessful ? Color.Artemis.resultSuccess : Color.Artemis.resultFailedColor) + } else { + Color.Artemis.primaryButtonColor + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift index 094be0c9..c7ac63c9 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift @@ -13,14 +13,12 @@ import DesignLibrary struct EditModelingExerciseView: View { @StateObject var modelingViewModel: ModelingExerciseViewModel - @State private var showSubmissionAlert = false + + @State private var isSubmissionAlertPresented = false @State private var isSubmissionSuccessful = false - init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest) { - self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, - participationId: participationId, - problemStatementURL: problemStatementURL)) - } + @State private var isProblemPresented = false + @State private var isWebViewLoading = true var body: some View { ZStack { @@ -48,117 +46,73 @@ struct EditModelingExerciseView: View { ToolbarItemGroup(placement: .topBarTrailing) { if !modelingViewModel.diagramTypeUnsupported { HStack { - ProblemStatementButton(modelingViewModel: modelingViewModel) - SubmitButton(modelingViewModel: modelingViewModel, showSubmissionAlert: $showSubmissionAlert, isSubmissionSuccessful: $isSubmissionSuccessful) + ExerciseParticipationProblemButton(isProblemPresented: $isProblemPresented) + ExerciseParticipationSubmitButton( + submit: { + try await modelingViewModel.submitSubmission() + }, + isSubmissionAlertPresented: $isSubmissionAlertPresented, + isSubmissionSuccessful: $isSubmissionSuccessful) } } } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) - .alert(isPresented: $showSubmissionAlert) { - if isSubmissionSuccessful { - return Alert( - title: Text(R.string.localizable.successfulSubmissionAlertTitle()), - message: Text(R.string.localizable.successfulSubmissionAlertMessage()) - ) - } else { - return Alert( - title: Text(R.string.localizable.failedSubmissionAlertTitle()), - message: Text(R.string.localizable.failedSubmissionAlertMessage()) - ) - } + .alert(isPresented: $isSubmissionAlertPresented) { + alert + } + .sheet(isPresented: $isProblemPresented) { + sheet } } } -struct SubmitButton: View { - @ObservedObject var modelingViewModel: ModelingExerciseViewModel - @Binding var showSubmissionAlert: Bool - @Binding var isSubmissionSuccessful: Bool - @State private var isSubmitting = false - - var body: some View { - Button { - submit() - } label: { - ZStack { - Text(R.string.localizable.submitSubmission()) - .opacity(isSubmitting ? 0 : 1) - // Show a Progress View, whilst the submision is being submitted - if isSubmitting { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Color.Artemis.primaryButtonTextColor)) - } - } - } - .buttonStyle(ArtemisButton(buttonColor: showSubmissionAlert ? - (isSubmissionSuccessful ? Color.Artemis.resultSuccess : Color.Artemis.resultFailedColor) : - Color.Artemis.primaryButtonColor, - buttonTextColor: Color.Artemis.primaryButtonTextColor)) - .disabled(isSubmitting) +extension EditModelingExerciseView { + init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest) { + self.init(modelingViewModel: ModelingExerciseViewModel( + exercise: exercise, + participationId: participationId, + problemStatementURL: problemStatementURL)) } +} - private func submit() { - isSubmitting = true - Task { - do { - try await modelingViewModel.submitSubmission() - isSubmissionSuccessful = true - } catch { - isSubmissionSuccessful = false - } - withAnimation { - isSubmitting = false - showSubmissionAlert.toggle() - } +private extension EditModelingExerciseView { + var alert: Alert { + if isSubmissionSuccessful { + return Alert( + title: Text(R.string.localizable.successfulSubmissionAlertTitle()), + message: Text(R.string.localizable.successfulSubmissionAlertMessage()) + ) + } else { + return Alert( + title: Text(R.string.localizable.failedSubmissionAlertTitle()), + message: Text(R.string.localizable.failedSubmissionAlertMessage()) + ) } } -} -struct ProblemStatementButton: View { - @ObservedObject var modelingViewModel: ModelingExerciseViewModel - @State private var isShowingProblemStatement = false - @State private var isWebViewLoading = true - - var body: some View { - Button { - isShowingProblemStatement = true - } label: { - Image(systemName: "newspaper") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.white) - .font(.headline) - .padding(.vertical, .m) - .padding(.horizontal, .l) - .background { - RoundedRectangle(cornerRadius: 4) - .foregroundColor(Color.Artemis.primaryButtonColor) - } - } - .sheet(isPresented: $isShowingProblemStatement) { - NavigationView { - VStack(alignment: .leading) { - if modelingViewModel.problemStatementURL != nil { - ArtemisWebView(urlRequest: Binding( - get: { modelingViewModel.problemStatementURL ?? URLRequest(url: URL(string: "")!) }, - set: { modelingViewModel.problemStatementURL = $0 }), - isLoading: $isWebViewLoading) - .loadingIndicator(isLoading: $isWebViewLoading) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button { - isShowingProblemStatement = false - } label: { - Text(R.string.localizable.close()) - } + var sheet: some View { + NavigationView { + VStack(alignment: .leading) { + if modelingViewModel.problemStatementURL != nil { + ArtemisWebView(urlRequest: Binding( + get: { modelingViewModel.problemStatementURL ?? URLRequest(url: URL(string: "")!) }, + set: { modelingViewModel.problemStatementURL = $0 }), + isLoading: $isWebViewLoading) + .loadingIndicator(isLoading: $isWebViewLoading) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + isProblemPresented = false + } label: { + Text(R.string.localizable.close()) } } } } - .padding(.m) } + .padding(.m) } } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift index 197093f3..c5b440d0 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift @@ -13,7 +13,7 @@ import DesignLibrary struct ViewModelingExerciseResultView: View { @StateObject var modelingViewModel: ModelingExerciseViewModel - @State var isStatusViewClicked = false + @State var isAssessmentPresented = false init(exercise: Exercise, participationId: Int) { self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, @@ -55,11 +55,14 @@ struct ViewModelingExerciseResultView: View { SubmissionResultStatusView(exercise: modelingViewModel.exercise) } ToolbarItemGroup(placement: .navigationBarTrailing) { - AssessmentViewButton(modelingViewModel: modelingViewModel, isStatusViewClicked: $isStatusViewClicked) + ExerciseParticipationAssessmentButton(isAssessmentPresented: $isAssessmentPresented) } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) + .sheet(isPresented: $isAssessmentPresented) { + AssessmentView(modelingViewModel: modelingViewModel, isAssessmentPresented: $isAssessmentPresented) + } } } @@ -118,41 +121,15 @@ private struct FeedbackViewPopOver: View { } } -private struct AssessmentViewButton: View { - @ObservedObject var modelingViewModel: ModelingExerciseViewModel - @Binding var isStatusViewClicked: Bool - - var body: some View { - Button { - self.isStatusViewClicked = true - } label: { - Image(systemName: "ellipsis.message") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.white) - .font(.headline) - .padding(.vertical, .m) - .padding(.horizontal, .l) - .background { - RoundedRectangle(cornerRadius: 4) - .foregroundColor(Color.Artemis.primaryButtonColor) - } - } - .sheet(isPresented: $isStatusViewClicked) { - AssessmentView(modelingViewModel: modelingViewModel, isStatusViewClicked: $isStatusViewClicked) - } - } -} - private struct AssessmentView: View { @ObservedObject var modelingViewModel: ModelingExerciseViewModel - @Binding var isStatusViewClicked: Bool + @Binding var isAssessmentPresented: Bool var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: .s) { Button { - isStatusViewClicked = false + isAssessmentPresented = false } label: { Text(R.string.localizable.close()) } diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseView.swift new file mode 100644 index 00000000..616297ea --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseView.swift @@ -0,0 +1,110 @@ +// +// EditTextExerciseView.swift +// +// +// Created by Nityananda Zbil on 10.12.23. +// + +import DesignLibrary +import SharedModels +import SwiftUI + +struct EditTextExerciseView: View { + + @State var viewModel: EditTextExerciseViewModel + + var body: some View { + VStack(alignment: .leading) { + TextEditor(text: $viewModel.text) + .overlay { + RoundedRectangle(cornerRadius: .m) + .stroke(Color.Artemis.artemisBlue) + } + } + .padding() + .navigationTitle(viewModel.exercise.baseExercise.title ?? "") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.fetchSubmission() + } + .toolbar { + ToolbarItem { + HStack { + ExerciseParticipationProblemButton(isProblemPresented: $viewModel.isProblemPresented) + ExerciseParticipationSubmitButton( + submit: { + try await viewModel.submit() + }, + isSubmissionAlertPresented: $viewModel.isSubmissionAlertPresented, + isSubmissionSuccessful: $viewModel.isSubmissionSuccessful) + } + } + } + .sheet(isPresented: $viewModel.isProblemPresented) { + sheet + } + .alert(isPresented: $viewModel.isSubmissionAlertPresented) { + alert + } + } +} + +extension EditTextExerciseView { + init(exercise: Exercise, participationId: Int, problem: URLRequest) { + self.init(viewModel: EditTextExerciseViewModel( + exercise: exercise, + participationId: participationId, + problem: problem)) + } +} + +private extension EditTextExerciseView { + var alert: Alert { + if viewModel.isSubmissionSuccessful { + return Alert( + title: Text(R.string.localizable.successfulSubmissionAlertTitle()), + message: Text(R.string.localizable.successfulSubmissionAlertMessage()) + ) + } else { + return Alert( + title: Text(R.string.localizable.failedSubmissionAlertTitle()), + message: Text(R.string.localizable.failedSubmissionAlertMessage()) + ) + } + } + + var sheet: some View { + NavigationView { + VStack(alignment: .leading) { + if let problem = Binding($viewModel.problem) { + ArtemisWebView( + urlRequest: problem, + isLoading: $viewModel.isWebViewLoading + ) + } + } + .loadingIndicator(isLoading: $viewModel.isWebViewLoading) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + viewModel.isProblemPresented = false + } label: { + Text(R.string.localizable.close()) + } + } + } + .padding(.m) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + EditTextExerciseView( + exercise: .text(exercise: TextExercise(id: 1)), + participationId: 1, + problem: URLRequest(url: URL(string: "example.org")!)) + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseViewModel.swift new file mode 100644 index 00000000..26cd07cd --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseViewModel.swift @@ -0,0 +1,90 @@ +// +// EditTextExerciseViewModel.swift +// +// +// Created by Nityananda Zbil on 14.06.24. +// + +import Common +import Foundation +import SharedModels +import SharedServices + +@Observable +final class EditTextExerciseViewModel { + let exercise: Exercise + let participationId: Int + + var problem: URLRequest? + + var submission: TextSubmission? + var result: Result? + var text: String = "" + + var isProblemPresented = false + var isSubmissionAlertPresented = false + var isSubmissionSuccessful = false + + // MARK: Web view + + var isWebViewLoading = true + + private let exerciseService: ExerciseService + private let exerciseSubmissionService: ExerciseSubmissionService + + init( + exercise: Exercise, + participationId: Int, + problem: URLRequest?, + exerciseService: ExerciseService = ExerciseServiceFactory.shared + ) { + self.exercise = exercise + self.participationId = participationId + self.problem = problem + + self.exerciseService = exerciseService + self.exerciseSubmissionService = ExerciseSubmissionServiceFactory.service(for: exercise) + } + + func fetchSubmission() async { + guard submission == nil else { + return + } + + let data = await exerciseService.getExercise(exerciseId: exercise.id) + guard let exercise = data.value, + case let .text(textExercise) = exercise, + let studentParticipations = textExercise.studentParticipations, + let studentParticipation = studentParticipations.first, + case let .student(student) = studentParticipation, + let submissions = student.submissions, + let submission = submissions.first, + case let .text(textSubmission) = submission + else { + log.error(String(describing: "Submission unavailable")) + return + } + + self.submission = textSubmission + if let result = textSubmission.results?.first, let result { + self.result = result + } + if let text = textSubmission.text { + self.text = text + } + } + + func submit() async throws { + guard var submission else { + return + } + + do { + submission.text = text + try await exerciseSubmissionService.updateSubmission(exerciseId: exercise.id, submission: submission) + } catch { + log.error(String(describing: error)) + throw error + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/ViewTextExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/ViewTextExerciseView.swift new file mode 100644 index 00000000..6a4911a2 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/ViewTextExerciseView.swift @@ -0,0 +1,40 @@ +// +// ViewTextExerciseView.swift +// +// +// Created by Nityananda Zbil on 16.06.24. +// + +import SharedModels +import SwiftUI + +struct ViewTextExerciseView: View { + @State var viewModel: EditTextExerciseViewModel + + var body: some View { + VStack(alignment: .leading) { + TextEditor(text: $viewModel.text) + .disabled(true) + .overlay { + RoundedRectangle(cornerRadius: .m) + .stroke(Color.Artemis.artemisBlue) + } + } + .padding() + .task { + await viewModel.fetchSubmission() + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .navigationTitle(R.string.localizable.viewSubmissionTitle()) + } +} + +extension ViewTextExerciseView { + init(exercise: Exercise, participationId: Int) { + self.init(viewModel: EditTextExerciseViewModel( + exercise: exercise, + participationId: participationId, + problem: nil)) + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index 63bd882f..ac23ecee 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -5,230 +5,29 @@ // Created by Sven Andabaka on 23.03.23. // -import SwiftUI -import SharedModels -import UserStore -import DesignLibrary import Common -import SharedServices +import DesignLibrary import Navigation +import SharedModels +import SwiftUI +import UserStore public struct ExerciseDetailView: View { @EnvironmentObject var navigationController: NavigationController - @State private var webViewHeight = CGFloat.s - @State private var urlRequest: URLRequest - @State private var isWebViewLoading = true - - @State private var exercise: DataState - - @State private var showFeedback = false - - @State private var latestResultId: Int? - @State private var participationId: Int? - - private let exerciseId: Int - private let courseId: Int - - @State private var webViewId = UUID() - - public init(course: Course, exercise: Exercise) { - self._exercise = State(wrappedValue: .done(response: exercise)) - self._urlRequest = State(wrappedValue: URLRequest(url: URL( - string: "/courses/\(course.id)/exercises/\(exercise.id)/problem-statement", - relativeTo: UserSessionFactory.shared.institution?.baseURL)!)) - - self.exerciseId = exercise.id - self.courseId = course.id - } - - public init(courseId: Int, exerciseId: Int) { - self._exercise = State(wrappedValue: .loading) - self._urlRequest = State(wrappedValue: URLRequest(url: URL( - string: "/courses/\(courseId)/exercises/\(exerciseId)", - relativeTo: UserSessionFactory.shared.institution?.baseURL)!)) - - self.exerciseId = exerciseId - self.courseId = courseId - } - - private var score: String { - let score = exercise.value?.baseExercise.studentParticipations? - .first? - .baseParticipation - .results? - .filter { $0.rated ?? false } - .max(by: { ($0.id ?? Int.min) > ($1.id ?? Int.min) })? - .score ?? 0 - - let maxPoints = exercise.value?.baseExercise.maxPoints ?? 0 - - return (score * maxPoints / 100).rounded().clean - } - - private var showFeedbackButton: Bool { - switch exercise.value { - case .fileUpload, .programming, .text: - return true - default: - return false - } - } - - private var isExerciseParticipationAvailable: Bool { - switch exercise.value { - case .modeling: - return true - default: - return false - } - } + @State private var viewModel: ExerciseDetailViewModel public var body: some View { - DataStateView(data: $exercise, retryHandler: { await loadExercise() }) { exercise in + DataStateView(data: $viewModel.exercise) { + await viewModel.loadExercise() + } content: { exercise in ScrollView { VStack(alignment: .leading, spacing: .l) { - // All buttons regarding viewing feedback and for the future, starting an exercise - HStack(spacing: .m) { - if isExerciseParticipationAvailable { - if let dueDate = exercise.baseExercise.dueDate { - if dueDate > Date() { - if let participationId { - OpenExerciseButton(exercise: exercise, participationId: participationId, problemStatementURL: urlRequest) - } else { - StartExerciseButton(exercise: exercise, participationId: $participationId) - } - } else { - if let participationId { - if latestResultId == nil { - ViewExerciseSubmissionButton(exercise: exercise, participationId: participationId) - } else { - ViewExerciseResultButton(exercise: exercise, participationId: participationId) - } - } - } - } else { - if let participationId { - OpenExerciseButton(exercise: exercise, participationId: participationId, problemStatementURL: urlRequest) - } else { - StartExerciseButton(exercise: exercise, participationId: $participationId) - } - } - } - if let latestResultId, let participationId, showFeedbackButton { - Button { - showFeedback = true - } label: { - Text(R.string.localizable.showFeedback()) - } - .buttonStyle(ArtemisButton()) - .sheet(isPresented: $showFeedback) { - FeedbackView(courseId: courseId, - exerciseId: exerciseId, - participationId: participationId, - resultId: latestResultId) - } - } - } - .padding(.horizontal, .m) - - if !isExerciseParticipationAvailable { - ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) - .padding(.horizontal, .m) - } - - // All score related information - VStack(alignment: .leading, spacing: .xs) { - Text(R.string.localizable.points( - score, - exercise.baseExercise.maxPoints?.clean ?? "0")) - .bold() - - SubmissionResultStatusView(exercise: exercise) - } - .padding(.horizontal, .m) - - // Exercise Details - VStack(alignment: .leading, spacing: 0) { - // Exercise Details title text - Text(R.string.localizable.exerciseDetails) - .bold() - .frame(height: 25, alignment: .center) - .padding(.s) - - Divider() - .frame(height: 1.0) - .overlay(Color.Artemis.artemisBlue) - - // Release Date - if let releaseDate = exercise.baseExercise.releaseDate { - ExerciseDetailCell(descriptionText: R.string.localizable.releaseDate()) { - Text(releaseDate.mediumDateShortTime) - } - } - - // Due Date - if let submissionDate = exercise.baseExercise.dueDate { - ExerciseDetailCell(descriptionText: R.string.localizable.submissionDate()) { - Text(submissionDate.mediumDateShortTime) - } - } else { - ExerciseDetailCell(descriptionText: R.string.localizable.submissionDate()) { - Text(R.string.localizable.noDueDate()) - } - } - - // Assessment Due Date - if let assessmentDate = exercise.baseExercise.assessmentDueDate { - ExerciseDetailCell(descriptionText: R.string.localizable.assessmentDate()) { - Text(assessmentDate.mediumDateShortTime) - } - } - - // Complaints Possible - if let complaintPossible = exercise.baseExercise.allowComplaintsForAutomaticAssessments { - ExerciseDetailCell(descriptionText: R.string.localizable.complaintPossible()) { - Text(complaintPossible ? "Yes" : "No") - } - } - - // Exercise Type - if exercise.baseExercise.includedInOverallScore != .includedCompletely { - ExerciseDetailCell(descriptionText: R.string.localizable.exerciseType()) { - Chip(text: exercise.baseExercise.includedInOverallScore.description, backgroundColor: exercise.baseExercise.includedInOverallScore.color, padding: .s) - } - } - - // Difficulty - if let difficulty = exercise.baseExercise.difficulty { - ExerciseDetailCell(descriptionText: R.string.localizable.difficulty()) { - Chip(text: difficulty.description, backgroundColor: difficulty.color, padding: .s) - } - } - - // Categories - if let categories = exercise.baseExercise.categories { - ExerciseDetailCell(descriptionText: R.string.localizable.categories()) { - ForEach(categories, id: \.category) { category in - Chip(text: category.category, backgroundColor: UIColor(hexString: category.colorCode).suColor, padding: .s) - } - } - } - } - .background { - RoundedRectangle(cornerRadius: 3.0) - .stroke(Color.Artemis.artemisBlue, lineWidth: 1.0) - } - .padding(.horizontal, .m) - - ArtemisWebView(urlRequest: $urlRequest, - contentHeight: $webViewHeight, - isLoading: $isWebViewLoading, - customJSHeightQuery: webViewHeightJS) - .frame(height: webViewHeight) - .allowsHitTesting(false) - .loadingIndicator(isLoading: $isWebViewLoading) - .id(webViewId) + feedback(exercise: exercise) + hint + score(exercise: exercise) + detail(exercise: exercise) + problem } } .toolbar { @@ -247,56 +46,195 @@ public struct ExerciseDetailView: View { } } .task { - await loadExercise() + await viewModel.loadExercise() } .refreshable { - await refreshExercise() + await viewModel.refreshExercise() } } +} + +public extension ExerciseDetailView { + init(course: Course, exercise: Exercise) { + self.init(viewModel: ExerciseDetailViewModel( + courseId: course.id, + exerciseId: exercise.id, + exercise: .done(response: exercise), + urlRequest: URLRequest(url: URL( + string: "/courses/\(course.id)/exercises/\(exercise.id)/problem-statement", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!))) + } + + init(courseId: Int, exerciseId: Int) { + self.init(viewModel: ExerciseDetailViewModel( + courseId: courseId, + exerciseId: exerciseId, + exercise: .loading, + urlRequest: URLRequest(url: URL( + string: "/courses/\(courseId)/exercises/\(exerciseId)", + relativeTo: UserSessionFactory.shared.institution?.baseURL)!))) + } +} - private func loadExercise() async { - if let exercise = exercise.value { - setParticipationAndResultId(from: exercise) - } else { - await refreshExercise() +private extension ExerciseDetailView { + // All buttons regarding viewing feedback and for the future, starting an exercise + func feedback(exercise: Exercise) -> some View { + HStack(spacing: .m) { + if viewModel.isExerciseParticipationAvailable { + if let dueDate = exercise.baseExercise.dueDate { + if dueDate > Date() { + if let participationId = viewModel.participationId { + OpenExerciseButton( + exercise: exercise, + participationId: participationId, + problemStatementURL: viewModel.urlRequest) + } else { + StartExerciseButton(exercise: exercise, participationId: $viewModel.participationId) + } + } else { + if let participationId = viewModel.participationId { + if viewModel.latestResultId == nil { + ViewExerciseSubmissionButton(exercise: exercise, participationId: participationId) + } else { + ViewExerciseResultButton(exercise: exercise, participationId: participationId) + } + } + } + } else { + if let participationId = viewModel.participationId { + OpenExerciseButton( + exercise: exercise, + participationId: participationId, + problemStatementURL: viewModel.urlRequest) + } else { + StartExerciseButton(exercise: exercise, participationId: $viewModel.participationId) + } + } + } + if let latestResultId = viewModel.latestResultId, + let participationId = viewModel.participationId, + viewModel.isFeedbackButtonVisible { + Button { + viewModel.isFeedbackPresented = true + } label: { + Text(R.string.localizable.showFeedback()) + } + .buttonStyle(ArtemisButton()) + .sheet(isPresented: $viewModel.isFeedbackPresented) { + FeedbackView(courseId: viewModel.courseId, + exerciseId: viewModel.exerciseId, + participationId: participationId, + resultId: latestResultId) + } + } } + .padding(.horizontal, .m) } - private func refreshExercise() async { - self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) - if let exercise = self.exercise.value { - setParticipationAndResultId(from: exercise) + @ViewBuilder var hint: some View { + if !viewModel.isExerciseParticipationAvailable { + ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) + .padding(.horizontal, .m) } - // Force WebView to reload - webViewId = UUID() } - private func setParticipationAndResultId(from exercise: Exercise) { - isWebViewLoading = true + // All score related information + func score(exercise: Exercise) -> some View { + VStack(alignment: .leading, spacing: .xs) { + Text(R.string.localizable.points( + viewModel.score, + exercise.baseExercise.maxPoints?.clean ?? "0")) + .bold() - let participation = exercise.getSpecificStudentParticipation(testRun: false) - participationId = participation?.id - // Sort participation results by completionDate desc. - // The latest result is the first rated result in the sorted array (=newest) - if let latestResultId = participation?.results?.max(by: { $0.completionDate ?? .distantPast > $1.completionDate ?? .distantPast })?.id { - self.latestResultId = latestResultId + SubmissionResultStatusView(exercise: exercise) } - - urlRequest = URLRequest(url: URL( - string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", - relativeTo: UserSessionFactory.shared.institution?.baseURL)!) + .padding(.horizontal, .m) } - /// We need a custom height calculation, otherwise the web view is often too small - private let webViewHeightJS = """ - if (document.querySelector("#problem-statement") != null) { - document.querySelector("#problem-statement").scrollHeight; - } else if (document.querySelector(".instructions__content") != null) { - document.querySelector(".instructions__content").scrollHeight; - } else { - document.body.scrollHeight; + // Exercise Details + func detail(exercise: Exercise) -> some View { + VStack(alignment: .leading, spacing: 0) { + // Exercise Details title text + Text(R.string.localizable.exerciseDetails) + .bold() + .frame(height: 25, alignment: .center) + .padding(.s) + + Divider() + .frame(height: 1.0) + .overlay(Color.Artemis.artemisBlue) + + // Release Date + if let releaseDate = exercise.baseExercise.releaseDate { + ExerciseDetailCell(descriptionText: R.string.localizable.releaseDate()) { + Text(releaseDate.mediumDateShortTime) + } + } + + // Due Date + if let submissionDate = exercise.baseExercise.dueDate { + ExerciseDetailCell(descriptionText: R.string.localizable.submissionDate()) { + Text(submissionDate.mediumDateShortTime) + } + } else { + ExerciseDetailCell(descriptionText: R.string.localizable.submissionDate()) { + Text(R.string.localizable.noDueDate()) + } + } + + // Assessment Due Date + if let assessmentDate = exercise.baseExercise.assessmentDueDate { + ExerciseDetailCell(descriptionText: R.string.localizable.assessmentDate()) { + Text(assessmentDate.mediumDateShortTime) + } + } + + // Complaints Possible + if let complaintPossible = exercise.baseExercise.allowComplaintsForAutomaticAssessments { + ExerciseDetailCell(descriptionText: R.string.localizable.complaintPossible()) { + Text(complaintPossible ? "Yes" : "No") + } + } + + // Exercise Type + if exercise.baseExercise.includedInOverallScore != .includedCompletely { + ExerciseDetailCell(descriptionText: R.string.localizable.exerciseType()) { + Chip(text: exercise.baseExercise.includedInOverallScore.description, backgroundColor: exercise.baseExercise.includedInOverallScore.color, padding: .s) + } + } + + // Difficulty + if let difficulty = exercise.baseExercise.difficulty { + ExerciseDetailCell(descriptionText: R.string.localizable.difficulty()) { + Chip(text: difficulty.description, backgroundColor: difficulty.color, padding: .s) + } + } + + // Categories + if let categories = exercise.baseExercise.categories { + ExerciseDetailCell(descriptionText: R.string.localizable.categories()) { + ForEach(categories, id: \.category) { category in + Chip(text: category.category, backgroundColor: UIColor(hexString: category.colorCode).suColor, padding: .s) + } + } + } + } + .background { + RoundedRectangle(cornerRadius: 3.0) + .stroke(Color.Artemis.artemisBlue, lineWidth: 1.0) } - """ + .padding(.horizontal, .m) + } + + var problem: some View { + ArtemisWebView(urlRequest: $viewModel.urlRequest, + contentHeight: $viewModel.webViewHeight, + isLoading: $viewModel.isWebViewLoading, + customJSHeightQuery: viewModel.webViewHeightJS) + .frame(height: viewModel.webViewHeight) + .allowsHitTesting(false) + .loadingIndicator(isLoading: $viewModel.isWebViewLoading) + .id(viewModel.webViewId)} } private struct ExerciseDetailCell: View { @@ -344,11 +282,21 @@ private struct OpenExerciseButton: View { var body: some View { switch exercise { case .modeling: - NavigationLink(destination: EditModelingExerciseView(exercise: exercise, - participationId: participationId, - problemStatementURL: problemStatementURL)) { - Text(R.string.localizable.openModelingEditor()) - }.buttonStyle(ArtemisButton()) + NavigationLink(R.string.localizable.openModelingEditor()) { + EditModelingExerciseView( + exercise: exercise, + participationId: participationId, + problemStatementURL: problemStatementURL) + } + .buttonStyle(ArtemisButton()) + case .text: + NavigationLink(R.string.localizable.openExercise()) { + EditTextExerciseView( + exercise: exercise, + participationId: participationId, + problem: problemStatementURL) + } + .buttonStyle(ArtemisButton()) default: ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) } @@ -362,10 +310,19 @@ private struct ViewExerciseSubmissionButton: View { var body: some View { switch exercise { case .modeling: - NavigationLink(destination: ViewModelingExerciseView(exercise: exercise, - participationId: participationId)) { + NavigationLink { + ViewModelingExerciseView(exercise: exercise, participationId: participationId) + } label: { + Text(R.string.localizable.viewSubmission()) + } + .buttonStyle(ArtemisButton()) + case .text: + NavigationLink { + ViewTextExerciseView(exercise: exercise, participationId: participationId) + } label: { Text(R.string.localizable.viewSubmission()) - }.buttonStyle(ArtemisButton()) + } + .buttonStyle(ArtemisButton()) default: ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) } @@ -379,10 +336,21 @@ private struct ViewExerciseResultButton: View { var body: some View { switch exercise { case .modeling: - NavigationLink(destination: ViewModelingExerciseResultView(exercise: exercise, - participationId: participationId)) { + NavigationLink { + ViewModelingExerciseResultView( + exercise: exercise, + participationId: participationId) + } label: { Text(R.string.localizable.viewResult()) - }.buttonStyle(ArtemisButton()) + } + .buttonStyle(ArtemisButton()) + case .text: + NavigationLink { + ViewTextExerciseView(exercise: exercise, participationId: participationId) + } label: { + Text(R.string.localizable.viewSubmission()) + } + .buttonStyle(ArtemisButton()) default: ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift new file mode 100644 index 00000000..09a2bdac --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift @@ -0,0 +1,132 @@ +// +// ExerciseDetailViewModel.swift +// +// +// Created by Nityananda Zbil on 14.06.24. +// + +import Common +import Foundation +import SharedModels +import SharedServices +import UserStore + +@Observable +final class ExerciseDetailViewModel { + let courseId: Int + let exerciseId: Int + + var exercise: DataState + + var isFeedbackPresented = false + var latestResultId: Int? + var participationId: Int? + + // MARK: Web view + + var isWebViewLoading = true + var urlRequest: URLRequest + var webViewId = UUID() + var webViewHeight = CGFloat.s + /// We need a custom height calculation, otherwise the web view is often too small + let webViewHeightJS = """ + if (document.querySelector("#problem-statement") != null) { + document.querySelector("#problem-statement").scrollHeight; + } else if (document.querySelector(".instructions__content") != null) { + document.querySelector(".instructions__content").scrollHeight; + } else { + document.body.scrollHeight; + } + """ + + private let exerciseService: ExerciseService + private let userSession: UserSession + + init( + courseId: Int, + exerciseId: Int, + exercise: DataState, + urlRequest: URLRequest, + exerciseService: ExerciseService = ExerciseServiceFactory.shared, + userSession: UserSession = UserSessionFactory.shared + ) { + self.courseId = courseId + self.exerciseId = exerciseId + + self.exercise = exercise + self.urlRequest = urlRequest + + self.exerciseService = exerciseService + self.userSession = userSession + } + + func loadExercise() async { + if let exercise = exercise.value { + setParticipationAndResultId(from: exercise) + } else { + await refreshExercise() + } + } + + func refreshExercise() async { + exercise = await exerciseService.getExercise(exerciseId: exerciseId) + if let exercise = exercise.value { + setParticipationAndResultId(from: exercise) + } + // Force WebView to reload + webViewId = UUID() + } + + private func setParticipationAndResultId(from exercise: Exercise) { + isWebViewLoading = true + + let participation = exercise.getSpecificStudentParticipation(testRun: false) + participationId = participation?.id + // Sort participation results by completionDate desc. + let areInIncreasingOrder = { (lhs: Result, rhs: Result) -> Bool in + lhs.completionDate ?? .distantPast > rhs.completionDate ?? .distantPast + } + // The latest result is the first rated result in the sorted array (=newest) + if let latestResultId = participation?.results?.max(by: areInIncreasingOrder)?.id { + self.latestResultId = latestResultId + } + + urlRequest = URLRequest(url: URL( + string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", + relativeTo: userSession.institution?.baseURL)!) + } +} + +extension ExerciseDetailViewModel { + var score: String { + let score = exercise.value?.baseExercise.studentParticipations? + .first? + .baseParticipation + .results? + .filter { $0.rated ?? false } + .max(by: { ($0.id ?? Int.min) > ($1.id ?? Int.min) })? + .score ?? 0 + + let maxPoints = exercise.value?.baseExercise.maxPoints ?? 0 + + return (score * maxPoints / 100).rounded().clean + } + + var isFeedbackButtonVisible: Bool { + switch exercise.value { + case .fileUpload, .programming, .text: + return true + default: + return false + } + } + + var isExerciseParticipationAvailable: Bool { + switch exercise.value { + case .modeling, .text: + return true + default: + return false + } + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index a6a80343..6833d1cc 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -52,6 +52,7 @@ "assessment" = "Assessment: %s"; "showFeedback" = "Show feedback"; "startExercise" = "Start exercise"; +"openExercise" = "Open exercise"; "openModelingEditor" = "Open modeling editor"; "submitSubmission" = "Submit"; "viewSubmission" = "View submission"; diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseSubmissionService.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseSubmissionService.swift index b4638c22..98f462f1 100644 --- a/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseSubmissionService.swift +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseSubmissionService.swift @@ -24,6 +24,8 @@ enum ExerciseSubmissionServiceFactory { switch exercise { case .modeling: return ModelingExerciseSubmissionServiceImpl() + case .text: + return TextExerciseSubmissionServiceImpl() default: return UnknownExerciseSubmissionServiceImpl() } diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/TextExerciseSubmissionServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/TextExerciseSubmissionServiceImpl.swift new file mode 100644 index 00000000..426a1076 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/TextExerciseSubmissionServiceImpl.swift @@ -0,0 +1,92 @@ +// +// TextExerciseSubmissionServiceImpl.swift +// +// +// Created by Nityananda Zbil on 16.12.23. +// + +import APIClient +import SharedModels + +struct TextExerciseSubmissionServiceImpl: ExerciseSubmissionService { + let client = APIClient() + + struct StartParticipationRequest: APIRequest { + typealias Response = Participation + + let exerciseId: Int + + var method: HTTPMethod { + .post + } + + var resourceName: String { + "api/exercises/\(exerciseId)/participations" + } + } + + func startParticipation(exerciseId: Int) async throws -> Participation { + try await client.sendRequest(StartParticipationRequest(exerciseId: exerciseId)).get().0 + } + + enum GetLatestSubmissionError: Error { + // Use ExerciseService.getExercise instead. + case unavailable + } + + func getLatestSubmission(participationId: Int) async throws -> Submission { + throw GetLatestSubmissionError.unavailable + } + + struct CreateSubmissionRequest: APIRequest { + typealias Response = Submission + + let exerciseId: Int + let submission: TextSubmission + + var method: HTTPMethod { + .post + } + + var body: Encodable? { + submission + } + + var resourceName: String { + "api/exercises/\(exerciseId)/text-submissions" + } + } + + func createSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + guard let submission = submission as? TextSubmission else { + return + } + _ = try await client.sendRequest(CreateSubmissionRequest(exerciseId: exerciseId, submission: submission)).get() + } + + struct UpdateSubmissionRequest: APIRequest { + typealias Response = Submission + + let exerciseId: Int + let submission: TextSubmission + + var method: HTTPMethod { + .put + } + + var body: Encodable? { + submission + } + + var resourceName: String { + "api/exercises/\(exerciseId)/text-submissions" + } + } + + func updateSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + guard let submission = submission as? TextSubmission else { + return + } + _ = try await client.sendRequest(UpdateSubmissionRequest(exerciseId: exerciseId, submission: submission)).get() + } +} From e7d492e0316840c1c55a04392b2bcacf8845057b Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Thu, 20 Jun 2024 19:12:09 +0200 Subject: [PATCH 21/48] `Exercises`: Add new design for exercise list cells (#119) --- .../ExerciseTab/ExerciseListView.swift | 99 +++++++++++-------- .../Resources/en.lproj/Localizable.strings | 1 - 2 files changed, 57 insertions(+), 43 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index a4c69f56..1a4283d7 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -137,58 +137,73 @@ struct ExerciseListCell: View { let course: Course let exercise: Exercise - let rows = [ - GridItem() - ] + var showAdditionalBadges: Bool { + if let releaseDate = exercise.baseExercise.releaseDate, + releaseDate > .now { + return true + } + if let categories = exercise.baseExercise.categories, !categories.isEmpty { + return true + } + return exercise.baseExercise.includedInOverallScore != .includedCompletely + } var body: some View { Button { navigationController.path.append(ExercisePath(exercise: exercise, coursePath: CoursePath(course: course))) } label: { - VStack(alignment: .leading, spacing: .m) { - HStack(spacing: .l) { - exercise.image - .renderingMode(.template) - .resizable() - .scaledToFit() - .foregroundColor(Color.Artemis.primaryLabel) - .frame(width: .smallImage) - Text(exercise.baseExercise.title ?? "") - .font(.title3) - Spacer() + HStack(alignment: .top) { + if let difficulty = exercise.baseExercise.difficulty { + Rectangle() + .frame(width: .m) + .foregroundStyle(difficulty.color) + .accessibilityLabel(difficulty.description) } - if let dueDate = exercise.baseExercise.dueDate { - Text(R.string.localizable.dueDate(dueDate.relative ?? "?")) - } else { - Text(R.string.localizable.noDueDate()) - } - SubmissionResultStatusView(exercise: exercise) - ScrollView(.horizontal) { - LazyHGrid(rows: rows, spacing: .s) { - if let releaseDate = exercise.baseExercise.releaseDate, - releaseDate > .now { - Chip( - text: R.string.localizable.notReleased(), - backgroundColor: Color.Artemis.badgeWarningColor) - } - ForEach(exercise.baseExercise.categories ?? [], id: \.category) { category in - Chip(text: category.category, backgroundColor: UIColor(hexString: category.colorCode).suColor) - } - // TODO: maybe add isActiveQuiz in presentationMode badge - if let difficulty = exercise.baseExercise.difficulty { - Chip(text: difficulty.description, backgroundColor: difficulty.color) - } - if exercise.baseExercise.includedInOverallScore != .includedCompletely { - Chip( - text: exercise.baseExercise.includedInOverallScore.description, - backgroundColor: exercise.baseExercise.includedInOverallScore.color) + VStack(alignment: .leading, spacing: .m) { + HStack(spacing: .m) { + exercise.image + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundColor(Color.Artemis.primaryLabel) + .frame(width: .smallImage) + Text(exercise.baseExercise.title ?? "") + .font(.title3) + .lineLimit(1) + } + if let dueDate = exercise.baseExercise.dueDate { + Text(dueDate, style: .date) + } else { + Text(R.string.localizable.noDueDate()) + } + SubmissionResultStatusView(exercise: exercise) + if showAdditionalBadges { + ScrollView(.horizontal) { + HStack(spacing: .s) { + if let releaseDate = exercise.baseExercise.releaseDate, + releaseDate > .now { + Chip( + text: R.string.localizable.notReleased(), + backgroundColor: Color.Artemis.badgeWarningColor) + } + ForEach(exercise.baseExercise.categories ?? [], id: \.category) { category in + Chip(text: category.category, backgroundColor: UIColor(hexString: category.colorCode).suColor) + } + // TODO: maybe add isActiveQuiz in presentationMode badge + if exercise.baseExercise.includedInOverallScore != .includedCompletely { + Chip( + text: exercise.baseExercise.includedInOverallScore.description, + backgroundColor: exercise.baseExercise.includedInOverallScore.color) + } + } } } } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, .m) + .padding(.vertical, .l) } - .frame(maxWidth: .infinity) - .padding(.l) - .artemisStyleCard() + .cardModifier(backgroundColor: .Artemis.exerciseCardBackgroundColor, cornerRadius: .m) } // Make button style explicit, otherwise, multiple cells may activate a navigation link. .buttonStyle(.plain) diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 6833d1cc..dcf47dce 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -46,7 +46,6 @@ // MARK: - ExerciseDetailView "exerciseDetails" = "Exercise details"; -"dueDate" = "Due Date: %s"; "noDueDate" = "No due date"; "points" = "Points: %s of %s"; "assessment" = "Assessment: %s"; From a4683c780ddc3c05dae4e3a2f5cbce9932af7a4c Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sun, 23 Jun 2024 18:57:01 +0200 Subject: [PATCH 22/48] `Notifications`: Improve notification sheet presentation (#120) * Use popover to improve sheet presentation on iPad * Add close button to Notification Sheet --- .../Resources/en.lproj/Localizable.strings | 1 + .../Views/NotificationView.swift | 7 +++++++ .../Views/View+NotificationToolbar.swift | 19 ++++++++++++++++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings index 733ff9f9..43a98a2d 100644 --- a/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings @@ -1,5 +1,6 @@ "artemisLabel" = "Artemis"; "ok" = "OK"; +"close" = "Close"; "notificationsTitle" = "Notifications"; "notificationAuthorLabel" = "%@ by %@"; diff --git a/ArtemisKit/Sources/Notifications/Views/NotificationView.swift b/ArtemisKit/Sources/Notifications/Views/NotificationView.swift index cd81dc11..ba8de959 100644 --- a/ArtemisKit/Sources/Notifications/Views/NotificationView.swift +++ b/ArtemisKit/Sources/Notifications/Views/NotificationView.swift @@ -57,6 +57,13 @@ struct NotificationView: View { .alert(R.string.localizable.notificationTargetNotFound(), isPresented: $isTargetNotFoundAlertPresented) { Button(R.string.localizable.ok(), role: .cancel) { } } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(R.string.localizable.close()) { + dismiss() + } + } + } } } } diff --git a/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift b/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift index c74f4687..5d8e71e1 100644 --- a/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift +++ b/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift @@ -18,9 +18,12 @@ private struct NotificationBell: ViewModifier { @StateObject private var viewModel = NotificationViewModel() @State private var isNotificationSheetPresented = false + @Environment(\.horizontalSizeClass) var horizontalSize func body(content: Content) -> some View { content + // Prevent user from accidentally tapping buttons outside the popover while open + .disabled(isNotificationSheetPresented) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { @@ -29,11 +32,21 @@ private struct NotificationBell: ViewModifier { Image(systemName: "bell.fill") .overlay(Badge(count: viewModel.newNotificationCount)) } + .popover(isPresented: $isNotificationSheetPresented) { + let minSize: CGFloat? = + if UIDevice.current.userInterfaceIdiom == .pad && horizontalSize != .compact { + // If not shown as a sheet, we need to set a size. + // Otherwise, it will be too small for its content. + min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.8 + } else { + // If shown as a sheet, the default size works for us + nil + } + NotificationView(viewModel: viewModel) + .frame(minWidth: minSize, minHeight: minSize) + } } } - .sheet(isPresented: $isNotificationSheetPresented) { - NotificationView(viewModel: viewModel) - } .task { await viewModel.subscribeToNotificationUpdates() } From ea4b9dfd5e893f1b82f48db5f237a60e72086cd4 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sun, 23 Jun 2024 21:02:05 +0200 Subject: [PATCH 23/48] `Exercises`: Adjust spacing of exercise list cell content (#122) --- .../Sources/CourseView/ExerciseTab/ExerciseListView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index 1a4283d7..d930e026 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -152,7 +152,7 @@ struct ExerciseListCell: View { Button { navigationController.path.append(ExercisePath(exercise: exercise, coursePath: CoursePath(course: course))) } label: { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 0) { if let difficulty = exercise.baseExercise.difficulty { Rectangle() .frame(width: .m) @@ -200,8 +200,7 @@ struct ExerciseListCell: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, .m) - .padding(.vertical, .l) + .padding(.l) } .cardModifier(backgroundColor: .Artemis.exerciseCardBackgroundColor, cornerRadius: .m) } From 283a77b5356991a2da87117205714a31f8fbae96 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Mon, 24 Jun 2024 10:34:17 +0200 Subject: [PATCH 24/48] Save messages data to disk (#123) --- .../Sources/Messages/Repositories/MessagesRepository.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift index 8d521a5a..419ab040 100644 --- a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift +++ b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift @@ -25,7 +25,7 @@ final class MessagesRepository { init(timeoutInSeconds: Int = 24 * 60 * 60) throws { let schema = Schema(versionedSchema: SchemaV1.self) - let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let configuration = ModelConfiguration(schema: schema) let container = try ModelContainer(for: schema, configurations: configuration) self.context = container.mainContext self.seconds = timeoutInSeconds From 9d91e1d377a4629c21001047d05b7d265854da9f Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Thu, 27 Jun 2024 10:59:19 +0200 Subject: [PATCH 25/48] `Communication`: Navigate to mentions (#126) * Prototype message * Fix attachment * Fix lectureUnits * Prototype lectureUnit * Rename task: loadLecturesWithSlides * Prototype slide * Update core --- .../xcshareddata/swiftpm/Package.resolved | 4 +- ArtemisKit/Package.swift | 2 +- .../MessageCellModel+MentionScheme.swift | 43 +++++++++++-------- .../SendMessageLecturePickerViewModel.swift | 25 +++++++---- .../Views/MessageDetailView/MessageCell.swift | 40 ++++++++++++++--- .../SendMessageLecturePicker.swift | 12 +++--- 6 files changed, 84 insertions(+), 42 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 19018110..63c4e7f1 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "28a9c7a2c169171c11753b06f0b71d008f592d74", - "version" : "13.0.0" + "revision" : "3de4642a5b399003cb218cfdb8ba69b01409bcb5", + "version" : "13.1.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 72c15313..b72a9341 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "13.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "13.1.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift index 112ef764..6a1ba6ca 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift @@ -8,14 +8,14 @@ import Foundation enum MentionScheme { - case attachment(Int) - case channel(Int64) - case exercise(Int) - case lecture(Int) - case lectureUnit - case member(String) - case message(Int) - case slide + case attachment(filename: String, lectureId: Int) + case channel(id: Int64) + case exercise(id: Int) + case lecture(id: Int) + case lectureUnit(filename: String, attachmentUnit: Int) + case member(login: String) + case message(id: Int64) + case slide(number: Int, attachmentUnit: Int) init?(_ url: URL) { guard url.scheme == "mention" else { @@ -24,39 +24,46 @@ enum MentionScheme { switch url.host() { case "attachment": // E.g., mention://attachment/lecture/3/LectureAttachment_2024-05-24T21-05-08-351_d37182b7.png - if url.pathComponents.count >= 3, let lectureId = Int(url.pathComponents[3]) { - self = .attachment(lectureId) + if url.pathComponents.count >= 3, let lectureId = Int(url.pathComponents[2]) { + self = .attachment(filename: url.lastPathComponent, lectureId: lectureId) return } case "channel": if let id = Int64(url.lastPathComponent) { - self = .channel(id) + self = .channel(id: id) return } case "exercise": if let id = Int(url.lastPathComponent) { - self = .exercise(id) + self = .exercise(id: id) return } case "lecture": if let id = Int(url.lastPathComponent) { - self = .lecture(id) + self = .lecture(id: id) return } case "lecture-unit": // E.g., mention://lecture-unit/attachment-unit/7/AttachmentUnit_2024-05-24T21-12-25-915_Inheritance__part_1_.pdf - self = .lectureUnit + if url.pathComponents.count >= 4, let attachmentUnit = Int(url.pathComponents[2]) { + self = .lectureUnit(filename: url.lastPathComponent, attachmentUnit: attachmentUnit) + return + } case "member": - self = .member(url.lastPathComponent) + self = .member(login: url.lastPathComponent) return case "message": // E.g., mention://message/1 - if let id = Int(url.lastPathComponent) { - self = .message(id) + if let id = Int64(url.lastPathComponent) { + self = .message(id: id) + return } case "slide": // E.g., mention://slide/attachment-unit/10/slide/1 - self = .slide + if url.pathComponents.count >= 4, let attachmentUnit = Int(url.pathComponents[2]), let id = Int(url.lastPathComponent) { + self = .slide(number: id, attachmentUnit: attachmentUnit) + return + } default: return nil } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift index 5b87a048..607b65a2 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift @@ -13,30 +13,28 @@ import SwiftUI final class SendMessageLecturePickerViewModel { let course: Course - var lectureUnits: [LectureUnit] + var lectures: [Lecture] private let delegate: SendMessageMentionContentDelegate private let lectureService: LectureService init( course: Course, - lectureUnits: [LectureUnit] = [], - delegate: SendMessageMentionContentDelegate, + lectures: [Lecture] = [], + delegate: SendMessageMentionContentDelegate = SendMessageMentionContentDelegate { _ in }, lectureService: LectureService = LectureServiceFactory.shared ) { self.course = course - self.lectureUnits = lectureUnits + self.lectures = lectures self.delegate = delegate self.lectureService = lectureService } - func task() async { + func loadLecturesWithSlides() async { let lectures = await lectureService.getLecturesWithSlides(courseId: course.id) - if case let .done(lectures) = lectures, - let lecture = lectures.first, - let lectureUnits = lecture.lectureUnits { - self.lectureUnits = lectureUnits + if case let .done(lectures) = lectures { + self.lectures = lectures } } @@ -72,4 +70,13 @@ final class SendMessageLecturePickerViewModel { delegate.pickerDidSelect("[slide]\(name) Slide \(slideNumber)(\(id))[/slide]") } } + + func firstLectureContains(attachmentUnit id: Int) -> Lecture? { + for lecture in lectures { + for lectureUnit in lecture.lectureUnits ?? [] where lectureUnit.baseUnit.id == id { + return lecture + } + } + return nil + } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 2e9b8ac2..ad1e51da 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -199,16 +199,25 @@ private extension MessageCell { if let mention = MentionScheme(url) { let coursePath = CoursePath(course: conversationViewModel.course) switch mention { - case let .attachment(id): - navigationController.path.append(LecturePath(id: id, coursePath: coursePath)) + case let .attachment(id, lectureId): + navigationController.path.append(LecturePath(id: lectureId, coursePath: coursePath)) case let .channel(id): navigationController.path.append(ConversationPath(id: id, coursePath: coursePath)) case let .exercise(id): navigationController.path.append(ExercisePath(id: id, coursePath: coursePath)) case let .lecture(id): navigationController.path.append(LecturePath(id: id, coursePath: coursePath)) - case let .lectureUnit: - break + case let .lectureUnit(id, attachmentUnit): + Task { + let delegate = SendMessageLecturePickerViewModel(course: conversationViewModel.course) + + await delegate.loadLecturesWithSlides() + + if let lecture = delegate.firstLectureContains(attachmentUnit: attachmentUnit) { + navigationController.path.append(LecturePath(id: lecture.id, coursePath: coursePath)) + return + } + } case let .member(login): Task { if let conversation = await viewModel.getOneToOneChatOrCreate(login: login) { @@ -216,9 +225,26 @@ private extension MessageCell { } } case let .message(id): - break - case let .slide: - break + guard let index = conversationViewModel.messages.firstIndex(of: .of(id: id)), + let messagePath = MessagePath( + message: Binding.constant(.done(response: conversationViewModel.messages[index].rawValue)), + conversationPath: ConversationPath(conversation: conversationViewModel.conversation, coursePath: coursePath), + conversationViewModel: conversationViewModel) else { + break + } + + navigationController.path.append(messagePath) + case let .slide(number, attachmentUnit): + Task { + let delegate = SendMessageLecturePickerViewModel(course: conversationViewModel.course) + + await delegate.loadLecturesWithSlides() + + if let lecture = delegate.firstLectureContains(attachmentUnit: attachmentUnit) { + navigationController.path.append(LecturePath(id: lecture.id, coursePath: coursePath)) + return + } + } } return .handled } diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift index 0a840dc1..3f54ebb3 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift @@ -14,8 +14,8 @@ struct SendMessageLecturePicker: View { var body: some View { Group { - if let lectures = viewModel.course.lectures, !lectures.isEmpty { - List(lectures) { lecture in + if !viewModel.lectures.isEmpty { + List(viewModel.lectures) { lecture in rowContent(lecture: lecture) } .listStyle(.plain) @@ -24,7 +24,7 @@ struct SendMessageLecturePicker: View { } } .task { - await viewModel.task() + await viewModel.loadLecturesWithSlides() } .navigationTitle(R.string.localizable.lectures()) .navigationBarTitleDisplayMode(.inline) @@ -49,8 +49,10 @@ private extension SendMessageLecturePicker { Button(title) { viewModel.select(lecture: lecture) } - ForEach(viewModel.lectureUnits, id: \.id) { lectureUnit in - rowContent(lectureUnit: lectureUnit) + if let lectureUnits = lecture.lectureUnits { + ForEach(lectureUnits, id: \.id) { lectureUnit in + rowContent(lectureUnit: lectureUnit) + } } } .listStyle(.plain) From 7e3a430a338948cc89bd4abbccc7e0e5a9daf03d Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 28 Jun 2024 01:28:26 +0200 Subject: [PATCH 26/48] `Communication`: Add visual context menu to conversation row (#125) * Add ellipsis context menu to conversation row * Add icons to conversation context menu --- .../ConversationRow/ConversationRow.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index 3d92ea08..5858878b 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -36,6 +36,12 @@ struct ConversationRow: View { if let unreadCount = conversation.unreadMessagesCount { Badge(count: unreadCount) } + Menu { + contextMenuItems + } label: { + Image(systemName: "ellipsis") + .padding(.m) + } } .opacity((conversation.unreadMessagesCount ?? 0) > 0 ? 1 : 0.7) .contextMenu { @@ -49,17 +55,25 @@ struct ConversationRow: View { private extension ConversationRow { @ViewBuilder var contextMenuItems: some View { - Button((conversation.isFavorite ?? false) ? R.string.localizable.unfavorite() : R.string.localizable.favorite()) { + let isFavorite = conversation.isFavorite ?? false + Button(isFavorite ? R.string.localizable.unfavorite() : R.string.localizable.favorite(), + systemImage: isFavorite ? "heart.slash.fill" : "heart.fill") { Task(priority: .userInitiated) { await viewModel.setIsConversationFavorite(conversationId: conversation.id, isFavorite: !(conversation.isFavorite ?? false)) } } - Button((conversation.isMuted ?? false) ? R.string.localizable.unmute() : R.string.localizable.mute()) { + + let isMuted = conversation.isMuted ?? false + Button(isMuted ? R.string.localizable.unmute() : R.string.localizable.mute(), + systemImage: isMuted ? "bell.fill" : "bell.slash.fill") { Task(priority: .userInitiated) { await viewModel.setIsConversationMuted(conversationId: conversation.id, isMuted: !(conversation.isMuted ?? false)) } } - Button((conversation.isHidden ?? false) ? R.string.localizable.show() : R.string.localizable.hide()) { + + let isHidden = conversation.isHidden ?? false + Button(isHidden ? R.string.localizable.show() : R.string.localizable.hide(), + systemImage: isHidden ? "eye.fill" : "eye.slash.fill") { Task(priority: .userInitiated) { await viewModel.setConversationIsHidden(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) } From 000862d1f11c52a148e00e2315d53126e6c328ec Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 29 Jun 2024 21:09:48 +0200 Subject: [PATCH 27/48] `Lectures`: Adopt new design for lecture list (#128) * Add new style for Lecture List View cells * Use new card style for lecture units --- .../CourseView/LectureTab/LectureDetailView.swift | 6 +++--- .../Sources/CourseView/LectureTab/LectureListView.swift | 9 ++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index e13057e9..a7318da3 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -174,7 +174,7 @@ struct BaseLectureUnitCell: View { } var body: some View { - HStack { + HStack(spacing: .l) { lectureUnit.baseUnit.image .renderingMode(.template) .resizable() @@ -185,7 +185,7 @@ struct BaseLectureUnitCell: View { Text(lectureUnit.baseUnit.name ?? "") .font(.title3) - Spacer() + Spacer(minLength: 0) if !(lectureUnit.baseUnit.visibleToStudents ?? false) { Chip(text: R.string.localizable.notReleased(), backgroundColor: .Artemis.badgeWarningColor) @@ -199,7 +199,7 @@ struct BaseLectureUnitCell: View { } .frame(maxWidth: .infinity) .padding(.l) - .artemisStyleCard() + .cardModifier(backgroundColor: .Artemis.exerciseCardBackgroundColor, cornerRadius: .m) .onTapGesture { showDetails = true } diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift index eb554a41..8e4c3e8b 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift @@ -158,17 +158,12 @@ private struct LectureListCellView: View { if let startDate = lecture.startDate { Text("\(startDate.dateOnly) (\(startDate.relative ?? "?"))") } else { - Text(R.string.localizable.noDueDate()) + Text(R.string.localizable.noDateAssociated()) } } .frame(maxWidth: .infinity) .padding(.l) - .cardModifier( - backgroundColor: Color.Artemis.exerciseCardBackgroundColor, - hasBorder: true, - borderColor: Color.Artemis.artemisBlue, - cornerRadius: 2 - ) + .cardModifier(backgroundColor: .Artemis.exerciseCardBackgroundColor, cornerRadius: .m) .onTapGesture { navigationController.path.append(LecturePath(lecture: lecture, coursePath: CoursePath(course: course))) } From 5f4aac6af394d3bf7d611e82159b604594f92881 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:35:30 +0200 Subject: [PATCH 28/48] `Communication`: Add icons for sections (#124) * Add icons to Conversation sections * Change "Channels" to "General Topics" --- .../Resources/en.lproj/Localizable.strings | 1 + .../MessagesAvailableView.swift | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 12ca96e6..2870639e 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "hide" = "Hide"; "show" = "Show"; "channels" = "Channels"; +"generalTopics" = "General Topics"; "exercises" = "Exercises"; "lectures" = "Lectures"; "exams" = "Exams"; diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 0f86ef65..2cd1455a 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -57,44 +57,52 @@ public struct MessagesAvailableView: View { MixedMessageSection( viewModel: viewModel, conversations: $viewModel.favoriteConversations, - sectionTitle: R.string.localizable.favoritesSection()) + sectionTitle: R.string.localizable.favoritesSection(), + sectionIconName: "heart.fill") MessageSection( viewModel: viewModel, conversations: $viewModel.channels, - sectionTitle: R.string.localizable.channels(), + sectionTitle: R.string.localizable.generalTopics(), + sectionIconName: "bubble.left.fill", conversationType: .channel) MessageSection( viewModel: viewModel, conversations: $viewModel.exercises, sectionTitle: R.string.localizable.exercises(), + sectionIconName: "list.bullet", conversationType: .channel, isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.lectures, sectionTitle: R.string.localizable.lectures(), + sectionIconName: "doc.fill", conversationType: .channel, isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.exams, sectionTitle: R.string.localizable.exams(), + sectionIconName: "graduationcap.fill", conversationType: .channel, isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.groupChats, sectionTitle: R.string.localizable.groupChats(), + sectionIconName: "bubble.left.and.bubble.right.fill", conversationType: .groupChat) MessageSection( viewModel: viewModel, conversations: $viewModel.oneToOneChats, sectionTitle: R.string.localizable.directMessages(), + sectionIconName: "bubble.left.fill", conversationType: .oneToOneChat) MixedMessageSection( viewModel: viewModel, conversations: $viewModel.hiddenConversations, sectionTitle: R.string.localizable.hiddenSection(), + sectionIconName: "nosign", isExpanded: false) } .listRowSeparator(.visible, edges: .top) @@ -158,16 +166,19 @@ private struct MixedMessageSection: View { @State private var isExpanded = true private let sectionTitle: String + private let sectionIconName: String init( viewModel: MessagesAvailableViewModel, conversations: Binding>, sectionTitle: String, + sectionIconName: String, isExpanded: Bool = true ) { self.viewModel = viewModel self._conversations = conversations self.sectionTitle = sectionTitle + self.sectionIconName = sectionIconName self._isExpanded = State(wrappedValue: isExpanded) } @@ -211,6 +222,7 @@ private struct MixedMessageSection: View { SectionDisclosureLabel( viewModel: viewModel, sectionTitle: sectionTitle, + sectionIconName: sectionIconName, sectionUnreadCount: sectionUnreadCount, isUnreadCountVisible: !isExpanded, conversationType: nil) @@ -230,6 +242,7 @@ private struct SectionDisclosureLabel: View { @State private var isCreateChannelPresented = false let sectionTitle: String + let sectionIconName: String let sectionUnreadCount: Int let isUnreadCountVisible: Bool @@ -237,8 +250,9 @@ private struct SectionDisclosureLabel: View { var body: some View { HStack { - Text(sectionTitle) + Label(sectionTitle, systemImage: sectionIconName) .font(.headline) + .foregroundStyle(.primary) Spacer() if isUnreadCountVisible { Badge(count: sectionUnreadCount) @@ -258,6 +272,7 @@ private struct SectionDisclosureLabel: View { } } } + .padding(.vertical, .m) .sheet(isPresented: $isCreateNewConversationPresented) { CreateOrAddToChatView(courseId: viewModel.courseId, configuration: .createChat) } @@ -294,7 +309,8 @@ private struct MessageSection: View { @State private var isExpanded = true - var sectionTitle: String + let sectionTitle: String + let sectionIconName: String var conversationType: ConversationType var sectionUnreadCount: Int { @@ -307,12 +323,14 @@ private struct MessageSection: View { viewModel: MessagesAvailableViewModel, conversations: Binding>, sectionTitle: String, + sectionIconName: String, conversationType: ConversationType, isExpanded: Bool = true ) { self.viewModel = viewModel self._conversations = conversations self.sectionTitle = sectionTitle + self.sectionIconName = sectionIconName self.conversationType = conversationType self._isExpanded = State(wrappedValue: isExpanded) } @@ -339,6 +357,7 @@ private struct MessageSection: View { SectionDisclosureLabel( viewModel: viewModel, sectionTitle: sectionTitle, + sectionIconName: sectionIconName, sectionUnreadCount: sectionUnreadCount, isUnreadCountVisible: !isExpanded, conversationType: conversationType) From c11feed1c4c9a11cac738feee3f9a1f45def9509 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:42:23 +0200 Subject: [PATCH 29/48] `Communication`: Simplify compose message toolbar (#127) * Move Bold/Italic/Underline, Code and Mentions into menus * Add cancel button to Mention Content picker --- .../Resources/en.lproj/Localizable.strings | 9 ++ .../SendMessageViewModel.swift | 2 +- .../SendMessageMentionContentView.swift | 53 ++++++----- .../SendMessageViews/SendMessageView.swift | 90 +++++++++++++------ 4 files changed, 96 insertions(+), 58 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 2870639e..86cc665b 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -14,6 +14,15 @@ "membersUnavailable" = "No Members"; "messageAction" = "Message %@"; "mentionSlideNumber" = "Slide %i"; +"style" = "Style"; +"bold" = "Bold"; +"italic" = "Italic"; +"underline" = "Underline"; +"quote" = "Quote"; +"inlineCode" = "Inline code"; +"codeBlock" = "Code block"; +"code" = "Code"; +"link" = "Link"; // MARK: SendMessageMentionContentView "members" = "Members"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index 2f6f9549..aa5d304b 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -69,7 +69,7 @@ final class SendMessageViewModel { var isMemberPickerSuppressed = false var isChannelPickerSuppressed = false - var isMentionContentViewPresented = false + var wantsToAddMessageMentionContentType: MessageMentionContentType? = nil // MARK: Life cycle diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift index 55f3d666..fb125743 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -10,41 +10,38 @@ import SwiftUI struct SendMessageMentionContentView: View { @Bindable var viewModel: SendMessageViewModel + let type: MessageMentionContentType var body: some View { NavigationStack { - List { - Button { - viewModel.didTapAtButton() - viewModel.isMentionContentViewPresented.toggle() - } label: { - Label(R.string.localizable.members(), systemImage: "at") - } - Button { - viewModel.didTapNumberButton() - viewModel.isMentionContentViewPresented.toggle() - } label: { - Label(R.string.localizable.channels(), systemImage: "number") - } - - let delegate = SendMessageMentionContentDelegate { [weak viewModel] mention in - viewModel?.text.append(mention) - viewModel?.isMentionContentViewPresented.toggle() - } - NavigationLink { + let delegate = SendMessageMentionContentDelegate { [weak viewModel] mention in + viewModel?.text.append(mention) + viewModel?.wantsToAddMessageMentionContentType = nil + } + Group { + switch type { + case .exercise: SendMessageExercisePicker(delegate: delegate, course: viewModel.course) - } label: { - Label(R.string.localizable.exercises(), systemImage: "list.bullet.clipboard") - } - NavigationLink { + case .lecture: SendMessageLecturePicker(course: viewModel.course, delegate: delegate) - } label: { - Label(R.string.localizable.lectures(), systemImage: "character.book.closed") } } - .listStyle(.plain) - .navigationTitle(R.string.localizable.mention()) - .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(R.string.localizable.cancel()) { + viewModel.wantsToAddMessageMentionContentType = nil + } + } + } } } } + +enum MessageMentionContentType: Identifiable { + var id: Self { + self + } + + case exercise + case lecture +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 37ccbde3..de27aca4 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -51,8 +51,8 @@ struct SendMessageView: View { } } ) - .sheet(isPresented: $viewModel.isMentionContentViewPresented) { - SendMessageMentionContentView(viewModel: viewModel) + .sheet(item: $viewModel.wantsToAddMessageMentionContentType) { type in + SendMessageMentionContentView(viewModel: viewModel, type: type) .presentationDetents([.fraction(0.5), .medium]) } } @@ -106,46 +106,78 @@ private extension SendMessageView { var keyboardToolbarContent: some View { HStack { ScrollView(.horizontal, showsIndicators: false) { - HStack { - Button { - viewModel.isMentionContentViewPresented.toggle() - } label: { - Image(systemName: "plus.circle.fill") - } - Button { - viewModel.didTapBoldButton() - } label: { - Image(systemName: "bold") - } - Button { - viewModel.didTapItalicButton() + HStack(alignment: .firstTextBaseline, spacing: .l) { + Menu { + Button { + viewModel.didTapAtButton() + } label: { + Label(R.string.localizable.members(), systemImage: "at") + } + Button { + viewModel.didTapNumberButton() + } label: { + Label(R.string.localizable.channels(), systemImage: "number") + } + Button { + viewModel.wantsToAddMessageMentionContentType = .exercise + } label: { + Label(R.string.localizable.exercises(), systemImage: "list.bullet.clipboard") + } + Button { + viewModel.wantsToAddMessageMentionContentType = .lecture + } label: { + Label(R.string.localizable.lectures(), systemImage: "character.book.closed") + } } label: { - Image(systemName: "italic") + Label(R.string.localizable.mention(), systemImage: "plus.circle.fill") + .labelStyle(.iconOnly) } - Button { - viewModel.didTapUnderlineButton() + Menu { + Button { + viewModel.didTapBoldButton() + } label: { + Label(R.string.localizable.bold(), systemImage: "bold") + } + Button { + viewModel.didTapItalicButton() + } label: { + Label(R.string.localizable.italic(), systemImage: "italic") + } + Button { + viewModel.didTapUnderlineButton() + } label: { + Label(R.string.localizable.underline(), systemImage: "underline") + } } label: { - Image(systemName: "underline") + Label(R.string.localizable.style(), systemImage: "bold.italic.underline") + .labelStyle(.iconOnly) } Button { viewModel.didTapBlockquoteButton() } label: { - Image(systemName: "quote.opening") - } - Button { - viewModel.didTapCodeButton() - } label: { - Image(systemName: "curlybraces") + Label(R.string.localizable.quote(), systemImage: "quote.opening") + .labelStyle(.iconOnly) } - Button { - viewModel.didTapCodeBlockButton() + Menu { + Button { + viewModel.didTapCodeButton() + } label: { + Label(R.string.localizable.inlineCode(), systemImage: "curlybraces") + } + Button { + viewModel.didTapCodeBlockButton() + } label: { + Label(R.string.localizable.codeBlock(), systemImage: "curlybraces.square.fill") + } } label: { - Image(systemName: "curlybraces.square.fill") + Label(R.string.localizable.code(), systemImage: "curlybraces") + .labelStyle(.iconOnly) } Button { viewModel.didTapLinkButton() } label: { - Image(systemName: "link") + Label(R.string.localizable.link(), systemImage: "link") + .labelStyle(.iconOnly) } } } From 807aaf3b586b5444ca33db9634158334dd43784c Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 3 Jul 2024 19:19:46 +0200 Subject: [PATCH 30/48] `Communication`: Update condition for showing Messaging Tab (#121) * Display messages tab whenever communication or messaging are enabled * Display direct messages only when messaging is enabled --- .../Sources/CourseView/CourseViewModel.swift | 3 +-- .../MessagesAvailableViewModel.swift | 4 +++ .../MessagesAvailableView.swift | 26 ++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/CourseViewModel.swift b/ArtemisKit/Sources/CourseView/CourseViewModel.swift index bc6a54f3..08b987be 100644 --- a/ArtemisKit/Sources/CourseView/CourseViewModel.swift +++ b/ArtemisKit/Sources/CourseView/CourseViewModel.swift @@ -10,8 +10,7 @@ class CourseViewModel: BaseViewModel { private let courseService: CourseService var isMessagesVisible: Bool { - course.courseInformationSharingConfiguration == .communicationAndMessaging - || course.courseInformationSharingConfiguration == .messagingOnly + course.courseInformationSharingConfiguration != .disabled } init(course: Course, courseService: CourseService = CourseServiceFactory.shared) { diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift index 81741df6..1c787d89 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift @@ -31,6 +31,10 @@ class MessagesAvailableViewModel: BaseViewModel { @Published var hiddenConversations: DataState<[Conversation]> = .loading + var isDirectMessagingEnabled: Bool { + course.courseInformationSharingConfiguration == .communicationAndMessaging + } + let course: Course let courseId: Int diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 2cd1455a..13ef147d 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -86,18 +86,20 @@ public struct MessagesAvailableView: View { sectionIconName: "graduationcap.fill", conversationType: .channel, isExpanded: false) - MessageSection( - viewModel: viewModel, - conversations: $viewModel.groupChats, - sectionTitle: R.string.localizable.groupChats(), - sectionIconName: "bubble.left.and.bubble.right.fill", - conversationType: .groupChat) - MessageSection( - viewModel: viewModel, - conversations: $viewModel.oneToOneChats, - sectionTitle: R.string.localizable.directMessages(), - sectionIconName: "bubble.left.fill", - conversationType: .oneToOneChat) + if viewModel.isDirectMessagingEnabled { + MessageSection( + viewModel: viewModel, + conversations: $viewModel.groupChats, + sectionTitle: R.string.localizable.groupChats(), + sectionIconName: "bubble.left.and.bubble.right.fill", + conversationType: .groupChat) + MessageSection( + viewModel: viewModel, + conversations: $viewModel.oneToOneChats, + sectionTitle: R.string.localizable.directMessages(), + sectionIconName: "bubble.left.fill", + conversationType: .oneToOneChat) + } MixedMessageSection( viewModel: viewModel, conversations: $viewModel.hiddenConversations, From 6a0c3829aa53a4f9b95a081f61aa1fb318341ee5 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 5 Jul 2024 15:47:20 +0200 Subject: [PATCH 31/48] `Communication`: Improve conversations list (#129) * Simplify Design of Conversations List * Add swipe actions for conversation row * Streamline code for sorting conversations * Merge create/browse channel buttons --- .../Resources/en.lproj/Localizable.strings | 1 + .../ConversationRow/ConversationRow.swift | 36 ++- .../MessagesAvailableView.swift | 250 ++++++++++-------- 3 files changed, 158 insertions(+), 129 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 86cc665b..7b3a4ca7 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -36,6 +36,7 @@ "createChannel" = "Create Channel"; "createGroupChat" = "Create Group Chat"; "createOneToOneChat" = "Create OneToOne Chat"; +"createChat" = "Create Chat"; "noResultForSearch" = "There is no result for your search."; "favoritesSection" = "Favorites"; "hiddenSection" = "Hidden"; diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index 5858878b..a721f207 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -49,34 +49,46 @@ struct ConversationRow: View { } } .foregroundStyle((conversation.isMuted ?? false) ? .secondary : .primary) - .listRowSeparator(.hidden) + .swipeActions(edge: .leading) { + favoriteButton + } + .swipeActions(edge: .trailing) { + hideAndMuteButtons + } } } private extension ConversationRow { - @ViewBuilder var contextMenuItems: some View { + @ViewBuilder var favoriteButton: some View { let isFavorite = conversation.isFavorite ?? false Button(isFavorite ? R.string.localizable.unfavorite() : R.string.localizable.favorite(), systemImage: isFavorite ? "heart.slash.fill" : "heart.fill") { Task(priority: .userInitiated) { await viewModel.setIsConversationFavorite(conversationId: conversation.id, isFavorite: !(conversation.isFavorite ?? false)) } - } - - let isMuted = conversation.isMuted ?? false - Button(isMuted ? R.string.localizable.unmute() : R.string.localizable.mute(), - systemImage: isMuted ? "bell.fill" : "bell.slash.fill") { - Task(priority: .userInitiated) { - await viewModel.setIsConversationMuted(conversationId: conversation.id, isMuted: !(conversation.isMuted ?? false)) - } - } + }.tint(.orange) + } + @ViewBuilder var hideAndMuteButtons: some View { let isHidden = conversation.isHidden ?? false Button(isHidden ? R.string.localizable.show() : R.string.localizable.hide(), systemImage: isHidden ? "eye.fill" : "eye.slash.fill") { Task(priority: .userInitiated) { await viewModel.setConversationIsHidden(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) } - } + }.tint(.gray) + + let isMuted = conversation.isMuted ?? false + Button(isMuted ? R.string.localizable.unmute() : R.string.localizable.mute(), + systemImage: isMuted ? "bell.fill" : "bell.slash.fill") { + Task(priority: .userInitiated) { + await viewModel.setIsConversationMuted(conversationId: conversation.id, isMuted: !(conversation.isMuted ?? false)) + } + }.tint(.indigo) + } + + @ViewBuilder var contextMenuItems: some View { + favoriteButton + hideAndMuteButtons } } diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 13ef147d..3f9b2fc7 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -51,7 +51,7 @@ public struct MessagesAvailableView: View { if let oneToOneChat = conversation.baseConversation as? OneToOneChat { ConversationRow(viewModel: viewModel, conversation: oneToOneChat) } - } + }.listRowBackground(Color.clear) } else { Group { MixedMessageSection( @@ -63,42 +63,36 @@ public struct MessagesAvailableView: View { viewModel: viewModel, conversations: $viewModel.channels, sectionTitle: R.string.localizable.generalTopics(), - sectionIconName: "bubble.left.fill", - conversationType: .channel) + sectionIconName: "bubble.left.fill") MessageSection( viewModel: viewModel, conversations: $viewModel.exercises, sectionTitle: R.string.localizable.exercises(), sectionIconName: "list.bullet", - conversationType: .channel, isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.lectures, sectionTitle: R.string.localizable.lectures(), sectionIconName: "doc.fill", - conversationType: .channel, isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.exams, sectionTitle: R.string.localizable.exams(), sectionIconName: "graduationcap.fill", - conversationType: .channel, isExpanded: false) if viewModel.isDirectMessagingEnabled { MessageSection( viewModel: viewModel, conversations: $viewModel.groupChats, sectionTitle: R.string.localizable.groupChats(), - sectionIconName: "bubble.left.and.bubble.right.fill", - conversationType: .groupChat) + sectionIconName: "bubble.left.and.bubble.right.fill") MessageSection( viewModel: viewModel, conversations: $viewModel.oneToOneChats, sectionTitle: R.string.localizable.directMessages(), - sectionIconName: "bubble.left.fill", - conversationType: .oneToOneChat) + sectionIconName: "bubble.left.fill") } MixedMessageSection( viewModel: viewModel, @@ -107,8 +101,8 @@ public struct MessagesAvailableView: View { sectionIconName: "nosign", isExpanded: false) } - .listRowSeparator(.visible, edges: .top) - .listRowInsets(EdgeInsets(top: .s, leading: .l, bottom: .s, trailing: .l)) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: .s, bottom: 0, trailing: .s)) HStack { Spacer() @@ -122,10 +116,16 @@ public struct MessagesAvailableView: View { } Spacer() } - .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + + // Empty row so that there is always space for floating button + Spacer() + .listRowBackground(Color.clear) } } - .listStyle(.plain) + .scrollContentBackground(.hidden) + .listRowSpacing(0.01) + .listSectionSpacing(.compact) .refreshable { await viewModel.loadConversations() } @@ -135,6 +135,10 @@ public struct MessagesAvailableView: View { .task { await viewModel.subscribeToConversationMembershipTopic() } + .overlay(alignment: .bottomTrailing) { + CreateOrAddChannelButton(viewModel: viewModel) + .padding() + } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) .loadingIndicator(isLoading: $viewModel.isLoading) .sheet(isPresented: $isCodeOfConductPresented) { @@ -159,6 +163,76 @@ public struct MessagesAvailableView: View { } } +private struct CreateOrAddChannelButton: View { + @ObservedObject var viewModel: MessagesAvailableViewModel + + @State private var isCreateNewConversationPresented = false + @State private var isNewConversationDialogPresented = false + @State private var isBrowseChannelsPresented = false + @State private var isCreateChannelPresented = false + + var body: some View { + Group { + if viewModel.course.courseInformationSharingConfiguration == .communicationOnly && !viewModel.course.isAtLeastTutorInCourse { + // If DMs are disabled and we are no instructor, we can only browse channels + Button { + isBrowseChannelsPresented = true + } label: { + menuIcon + } + } else { + Menu { + menuContent + } label: { + menuIcon + } + } + } + .sheet(isPresented: $isCreateNewConversationPresented) { + CreateOrAddToChatView(courseId: viewModel.courseId, configuration: .createChat) + } + .sheet(isPresented: $isCreateChannelPresented) { + Task { + await viewModel.loadConversations() + } + } content: { + CreateChannelView(courseId: viewModel.courseId) + } + .sheet(isPresented: $isBrowseChannelsPresented) { + Task { + await viewModel.loadConversations() + } + } content: { + BrowseChannelsView(courseId: viewModel.courseId) + } + } + + @ViewBuilder private var menuContent: some View { + if viewModel.course.isAtLeastTutorInCourse { + Button(R.string.localizable.createChannel(), systemImage: "plus.bubble.fill") { + isCreateChannelPresented = true + } + } + Button(R.string.localizable.browseChannels(), systemImage: "number") { + isBrowseChannelsPresented = true + } + if viewModel.course.courseInformationSharingConfiguration == .communicationAndMessaging { + Button(R.string.localizable.createChat(), systemImage: "bubble.left.fill") { + isCreateNewConversationPresented = true + } + } + } + + private var menuIcon: some View { + Image(systemName: "plus.bubble") + .foregroundStyle(.white) + .font(.title2) + .padding() + .background(Color.Artemis.artemisBlue, in: .circle) + .shadow(color: Color.gray.opacity(0.2), radius: .m) + } +} + private struct MixedMessageSection: View { @ObservedObject private var viewModel: MessagesAvailableViewModel @@ -195,39 +269,32 @@ private struct MixedMessageSection: View { await viewModel.loadConversations() } content: { conversations in if !conversations.isEmpty { - DisclosureGroup(isExpanded: $isExpanded) { - ForEach( - conversations.filter { !($0.baseConversation.isMuted ?? false) } - ) { conversation in - if let channel = conversation.baseConversation as? Channel { - ConversationRow(viewModel: viewModel, conversation: channel) - } - if let groupChat = conversation.baseConversation as? GroupChat { - ConversationRow(viewModel: viewModel, conversation: groupChat) - } - if let oneToOneChat = conversation.baseConversation as? OneToOneChat { - ConversationRow(viewModel: viewModel, conversation: oneToOneChat) - } - } - ForEach(conversations.filter({ $0.baseConversation.isMuted ?? false })) { conversation in - if let channel = conversation.baseConversation as? Channel { - ConversationRow(viewModel: viewModel, conversation: channel) - } - if let groupChat = conversation.baseConversation as? GroupChat { - ConversationRow(viewModel: viewModel, conversation: groupChat) - } - if let oneToOneChat = conversation.baseConversation as? OneToOneChat { - ConversationRow(viewModel: viewModel, conversation: oneToOneChat) + Section { + DisclosureGroup(isExpanded: $isExpanded) { + ForEach( + conversations.sorted { + // Show non-muted conversations above muted ones + ($0.baseConversation.isMuted ?? false ? 0 : 1) > ($1.baseConversation.isMuted ?? false ? 0 : 1) + } + ) { conversation in + if let channel = conversation.baseConversation as? Channel { + ConversationRow(viewModel: viewModel, conversation: channel) + } + if let groupChat = conversation.baseConversation as? GroupChat { + ConversationRow(viewModel: viewModel, conversation: groupChat) + } + if let oneToOneChat = conversation.baseConversation as? OneToOneChat { + ConversationRow(viewModel: viewModel, conversation: oneToOneChat) + } } + } label: { + SectionDisclosureLabel( + viewModel: viewModel, + sectionTitle: sectionTitle, + sectionIconName: sectionIconName, + sectionUnreadCount: sectionUnreadCount, + isUnreadCountVisible: !isExpanded) } - } label: { - SectionDisclosureLabel( - viewModel: viewModel, - sectionTitle: sectionTitle, - sectionIconName: sectionIconName, - sectionUnreadCount: sectionUnreadCount, - isUnreadCountVisible: !isExpanded, - conversationType: nil) } } } @@ -238,18 +305,11 @@ private struct SectionDisclosureLabel: View { @ObservedObject var viewModel: MessagesAvailableViewModel - @State private var isCreateNewConversationPresented = false - @State private var isNewConversationDialogPresented = false - @State private var isBrowseChannelsPresented = false - @State private var isCreateChannelPresented = false - let sectionTitle: String let sectionIconName: String let sectionUnreadCount: Int let isUnreadCountVisible: Bool - let conversationType: ConversationType? - var body: some View { HStack { Label(sectionTitle, systemImage: sectionIconName) @@ -259,47 +319,8 @@ private struct SectionDisclosureLabel: View { if isUnreadCountVisible { Badge(count: sectionUnreadCount) } - if let conversationType { - Image(systemName: "plus.bubble") - .onTapGesture { - if conversationType == .channel { - if viewModel.course.isAtLeastTutorInCourse { - isNewConversationDialogPresented = true - } else { - isBrowseChannelsPresented = true - } - } else { - isCreateNewConversationPresented = true - } - } - } } .padding(.vertical, .m) - .sheet(isPresented: $isCreateNewConversationPresented) { - CreateOrAddToChatView(courseId: viewModel.courseId, configuration: .createChat) - } - .sheet(isPresented: $isCreateChannelPresented) { - Task { - await viewModel.loadConversations() - } - } content: { - CreateChannelView(courseId: viewModel.courseId) - } - .sheet(isPresented: $isBrowseChannelsPresented) { - Task { - await viewModel.loadConversations() - } - } content: { - BrowseChannelsView(courseId: viewModel.courseId) - } - .confirmationDialog("", isPresented: $isNewConversationDialogPresented, titleVisibility: .hidden) { - Button(R.string.localizable.browseChannels()) { - isBrowseChannelsPresented = true - } - Button(R.string.localizable.createChannel()) { - isCreateChannelPresented = true - } - } } } @@ -313,7 +334,6 @@ private struct MessageSection: View { let sectionTitle: String let sectionIconName: String - var conversationType: ConversationType var sectionUnreadCount: Int { (conversations.value ?? []).reduce(0) { @@ -326,43 +346,39 @@ private struct MessageSection: View { conversations: Binding>, sectionTitle: String, sectionIconName: String, - conversationType: ConversationType, isExpanded: Bool = true ) { self.viewModel = viewModel self._conversations = conversations self.sectionTitle = sectionTitle self.sectionIconName = sectionIconName - self.conversationType = conversationType self._isExpanded = State(wrappedValue: isExpanded) } var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { - DataStateView(data: $conversations) { - await viewModel.loadConversations() - } content: { conversations in - ForEach( - conversations.filter { !($0.isMuted ?? false) }, - id: \.id - ) { conversation in - ConversationRow(viewModel: viewModel, conversation: conversation) - } - ForEach( - conversations.filter { $0.isMuted ?? false }, - id: \.id - ) { conversation in - ConversationRow(viewModel: viewModel, conversation: conversation) + Section { + DisclosureGroup(isExpanded: $isExpanded) { + DataStateView(data: $conversations) { + await viewModel.loadConversations() + } content: { conversations in + ForEach( + conversations.sorted { + // Show non-muted conversations above muted ones + ($0.isMuted ?? false ? 0 : 1) > ($1.isMuted ?? false ? 0 : 1) + }, + id: \.id + ) { conversation in + ConversationRow(viewModel: viewModel, conversation: conversation) + } } + } label: { + SectionDisclosureLabel( + viewModel: viewModel, + sectionTitle: sectionTitle, + sectionIconName: sectionIconName, + sectionUnreadCount: sectionUnreadCount, + isUnreadCountVisible: !isExpanded) } - } label: { - SectionDisclosureLabel( - viewModel: viewModel, - sectionTitle: sectionTitle, - sectionIconName: sectionIconName, - sectionUnreadCount: sectionUnreadCount, - isUnreadCountVisible: !isExpanded, - conversationType: conversationType) } } } From 1c488985294d25b61323b2aebc68c9eec0812adc Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 8 Jul 2024 21:05:26 +0200 Subject: [PATCH 32/48] `Courses`: Fix some courses not shown in course enrollment (#130) * Fix courses without description not being shown in registration * Fix alignment of too short titles/descriptions * Bump Core Modules to 13.2.0 --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- ArtemisKit/Package.swift | 2 +- .../CourseRegistrationView.swift | 23 +++++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 63c4e7f1..22267995 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "3de4642a5b399003cb218cfdb8ba69b01409bcb5", - "version" : "13.1.0" + "revision" : "ce0d4e6e74cbb9c55e9dbc8f9ec2d15a8bd1c233", + "version" : "13.2.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index b72a9341..2a2221ac 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "13.1.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "13.2.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift b/ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift index cbb49167..f906a407 100644 --- a/ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift +++ b/ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift @@ -66,19 +66,24 @@ private struct CourseRegistrationListCell: View { let course: Course var body: some View { - if let title = course.title, - let description = course.description { - VStack(spacing: .m) { - VStack(alignment: .leading) { - Text(title) - .font(.title2) + if let title = course.title { + VStack(alignment: .leading, spacing: .m) { + Text(title) + .font(.title2) + + if let description = course.description { Text(description) .font(.caption) } - Button(R.string.localizable.course_registration_register_button()) { - showSignUpAlert = true + + HStack { + Spacer() + Button(R.string.localizable.course_registration_register_button()) { + showSignUpAlert = true + } + .buttonStyle(ArtemisButton()) + Spacer() } - .buttonStyle(ArtemisButton()) } .padding(.m) .frame(maxWidth: .infinity) From 50f8b66e1701f6e9e17c23452e383f72a27dcd5a Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:20:57 +0200 Subject: [PATCH 33/48] `Communication`: Improve Conversation View (#131) * Replace user image by role badge * Adjust spacing * Add background to messages, merge continuing messages visually * Fix disappearing labels * Make reactions sightly bigger * Fix authorRole being deleted by websocket * Fix rounded corners not updating correctly * Bump Core Modules to 14.0.0 * Increment Version Number --- .../xcshareddata/swiftpm/Package.resolved | 8 +- Artemis/Supporting/Info.plist | 2 +- ArtemisKit/Package.swift | 2 +- .../Models/BaseMessage+IsContinuation.swift | 13 ++- .../Models/OfflineMessageOrAnswer.swift | 2 +- .../ConversationViewModel.swift | 15 +++- .../MessageCellModel.swift | 10 +++ .../ConversationDaySection.swift | 14 +++- .../ConversationOfflineSection.swift | 4 +- .../Views/MessageDetailView/MessageCell.swift | 80 ++++++++++++------- .../MessageDetailView/MessageDetailView.swift | 17 +++- .../MessageDetailView/ReactionsView.swift | 2 +- 12 files changed, 119 insertions(+), 50 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 22267995..141b562c 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "ce0d4e6e74cbb9c55e9dbc8f9ec2d15a8bd1c233", - "version" : "13.2.0" + "revision" : "ad51e949c738b9a9d242d436e49d3414b8281b06", + "version" : "14.0.0" } }, { @@ -194,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { - "revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76", - "version" : "5.1.2" + "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", + "version" : "5.1.3" } } ], diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index cd3b097b..bb4d76ee 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1.0 + 1.2.0 CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 2a2221ac..72ac4505 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "13.2.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.0.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift index f7301231..61f0f4bb 100644 --- a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift +++ b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift @@ -13,8 +13,9 @@ private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 extension BaseMessage { /// Whether the same author messaged multiple times within 5 minutes. - func isContinuation(of message: some BaseMessage) -> Bool { - guard author == message.author, + func isContinuation(of message: BaseMessage?) -> Bool { + guard let message, + author == message.author, let lhs = creationDate, let rhs = message.creationDate else { return false @@ -23,3 +24,11 @@ extension BaseMessage { return lhs < rhs.addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60)) } } + +// https://stackoverflow.com/a/30593673 +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/ArtemisKit/Sources/Messages/Models/OfflineMessageOrAnswer.swift b/ArtemisKit/Sources/Messages/Models/OfflineMessageOrAnswer.swift index bf70981e..4e49055c 100644 --- a/ArtemisKit/Sources/Messages/Models/OfflineMessageOrAnswer.swift +++ b/ArtemisKit/Sources/Messages/Models/OfflineMessageOrAnswer.swift @@ -15,7 +15,7 @@ struct OfflineMessageOrAnswer: BaseMessage { var updatedDate: Date? var content: String? var tokenizedContent: String? - var authorRoleTransient: UserRole? + var authorRole: UserRole? var reactions: [Reaction]? } diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index de16f845..ab8f53d4 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -338,7 +338,20 @@ private extension ConversationViewModel { func handle(update message: Message) { shouldScrollToId = nil if messages.contains(.of(id: message.id)) { - messages.update(with: .message(message)) + let oldMessage = messages.first { $0.id == message.id } + + // We do not get `authorRole` via websockets, thus we need to manually keep it + var newMessage = message + newMessage.authorRole = newMessage.authorRole ?? oldMessage?.rawValue.authorRole + // Same for answers + newMessage.answers = newMessage.answers?.map { answer in + var newAnswer = answer + let oldAnswer = oldMessage?.rawValue.answers?.first { $0.id == answer.id } + newAnswer.authorRole = newAnswer.authorRole ?? oldAnswer?.authorRole + return newAnswer + } + + messages.update(with: .message(newMessage)) } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift index 54f8d761..e3017573 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -8,6 +8,7 @@ import Foundation import Navigation import SharedModels +import SwiftUI import UserStore @MainActor @@ -17,6 +18,7 @@ final class MessageCellModel { let conversationPath: ConversationPath? let isHeaderVisible: Bool + let roundBottomCorners: Bool let retryButtonAction: (() -> Void)? var isActionSheetPresented = false @@ -29,6 +31,7 @@ final class MessageCellModel { course: Course, conversationPath: ConversationPath?, isHeaderVisible: Bool, + roundBottomCorners: Bool, retryButtonAction: (() -> Void)?, messagesService: MessagesService = MessagesServiceFactory.shared, userSession: UserSession = UserSessionFactory.shared @@ -36,6 +39,7 @@ final class MessageCellModel { self.course = course self.conversationPath = conversationPath self.isHeaderVisible = isHeaderVisible + self.roundBottomCorners = roundBottomCorners self.retryButtonAction = retryButtonAction self.messagesService = messagesService self.userSession = userSession @@ -53,6 +57,12 @@ extension MessageCellModel { return lastReadDate < creationDate && userSession.user?.id != authorId } + var roundedCorners: RectangleCornerRadii { + let top: CGFloat = isHeaderVisible ? .m : 0 + let bottom: CGFloat = roundBottomCorners ? .m : 0 + return .init(topLeading: top, bottomLeading: bottom, bottomTrailing: bottom, topTrailing: top) + } + // MARK: Navigation func getOneToOneChatOrCreate(login: String) async -> Conversation? { diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift index 0ca1ba27..af7c8251 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationDaySection.swift @@ -20,20 +20,24 @@ struct ConversationDaySection: View { } var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { Text(day, formatter: DateFormatter.dateOnly) .font(.headline) - .padding(.top, .m) + .padding(.vertical, .m) .padding(.horizontal, .l) Divider() .padding(.horizontal, .l) + .padding(.bottom, .s) ForEach(Array(messages.enumerated()), id: \.1.id) { index, message in + let needsRoundedCorners = !(messages[safe: index + 1]?.isContinuation(of: message) ?? false) MessageCellWrapper( viewModel: viewModel, day: day, message: message, conversationPath: conversationPath, - isHeaderVisible: index == 0 || !message.isContinuation(of: messages[index - 1])) + isHeaderVisible: !message.isContinuation(of: messages[safe: index - 1]), + roundBottomCorners: needsRoundedCorners) + .id(index == messages.count - 1 ? nil : message.id) } } } @@ -46,6 +50,7 @@ private struct MessageCellWrapper: View { let message: Message let conversationPath: ConversationPath let isHeaderVisible: Bool + let roundBottomCorners: Bool private var messageBinding: Binding> { Binding { @@ -66,7 +71,8 @@ private struct MessageCellWrapper: View { conversationViewModel: viewModel, message: messageBinding, conversationPath: conversationPath, - isHeaderVisible: isHeaderVisible) + isHeaderVisible: isHeaderVisible, + roundBottomCorners: roundBottomCorners) } } diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift index 7bedadb6..c9746b10 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationOfflineSection.swift @@ -20,6 +20,7 @@ struct ConversationOfflineSection: View { message: Binding.constant(DataState.done(response: OfflineMessageOrAnswer(viewModel.message))), conversationPath: nil, isHeaderVisible: viewModel.taskDidFail, + roundBottomCorners: true, retryButtonAction: viewModel.retryButtonAction ) .task { @@ -33,7 +34,8 @@ struct ConversationOfflineSection: View { conversationViewModel: conversationViewModel, message: Binding.constant(DataState.done(response: OfflineMessageOrAnswer(message))), conversationPath: nil, - isHeaderVisible: false + isHeaderVisible: false, + roundBottomCorners: false ) } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index ad1e51da..97cb78b4 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -23,38 +23,38 @@ struct MessageCell: View { @State var viewModel: MessageCellModel var body: some View { - HStack(alignment: .top, spacing: .m) { - Image(systemName: "person") - .resizable() - .scaledToFit() - .frame(width: 40, height: viewModel.isHeaderVisible ? 40 : 0) - .padding(.top, .s) - VStack(alignment: .leading, spacing: .xs) { - HStack { - VStack(alignment: .leading, spacing: .xs) { - headerIfVisible - ArtemisMarkdownView(string: content) - .opacity(isMessageOffline ? 0.5 : 1) - .environment(\.openURL, OpenURLAction(handler: handle)) - } - Spacer() - } - .background { - RoundedRectangle(cornerRadius: .m) - .foregroundStyle(backgroundOnPress) + VStack(alignment: .leading, spacing: .s) { + HStack { + VStack(alignment: .leading, spacing: .s) { + headerIfVisible + ArtemisMarkdownView(string: content) + .opacity(isMessageOffline ? 0.5 : 1) + .environment(\.openURL, OpenURLAction(handler: handle)) } - .contentShape(.rect) - .onTapGesture(perform: onTapPresentMessage) - .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in - viewModel.isDetectingLongPress = changed - } - - ReactionsView(viewModel: conversationViewModel, message: $message) - retryButtonIfAvailable - replyButtonIfAvailable + Spacer() } - .id(message.value?.id.description) + .background { + RoundedRectangle(cornerRadius: .m) + .foregroundStyle(backgroundOnPress) + } + .contentShape(.rect) + .onTapGesture(perform: onTapPresentMessage) + .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in + viewModel.isDetectingLongPress = changed + } + + ReactionsView(viewModel: conversationViewModel, message: $message) + retryButtonIfAvailable + replyButtonIfAvailable } + .padding(.horizontal, .m) + .padding(viewModel.isHeaderVisible ? .vertical : .bottom, .m) + .background( + Color(uiColor: .secondarySystemBackground), + in: .rect(cornerRadii: viewModel.roundedCorners) + ) + .padding(.top, viewModel.isHeaderVisible ? .m : 0) + .id(message.value?.id.description) .padding(.horizontal, .l) .sheet(isPresented: $viewModel.isActionSheetPresented) { MessageActionSheet( @@ -73,6 +73,7 @@ extension MessageCell { message: Binding>, conversationPath: ConversationPath?, isHeaderVisible: Bool, + roundBottomCorners: Bool, retryButtonAction: (() -> Void)? = nil ) { self.init( @@ -82,6 +83,7 @@ extension MessageCell { course: conversationViewModel.course, conversationPath: conversationPath, isHeaderVisible: isHeaderVisible, + roundBottomCorners: roundBottomCorners, retryButtonAction: retryButtonAction) ) } @@ -92,6 +94,10 @@ private extension MessageCell { message.value?.author?.name ?? "" } + private var authorRole: UserRole? { + message.value?.authorRole + } + var creationDate: Date? { message.value?.creationDate } @@ -104,9 +110,22 @@ private extension MessageCell { (viewModel.isDetectingLongPress || viewModel.isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear } + @ViewBuilder var roleBadge: some View { + if let authorRole { + Chip( + text: authorRole.displayName, + backgroundColor: authorRole.badgeColor, + horizontalPadding: .m, + verticalPadding: .s + ) + .font(.footnote) + } + } + @ViewBuilder var headerIfVisible: some View { if viewModel.isHeaderVisible { HStack(alignment: .firstTextBaseline, spacing: .m) { + roleBadge Text(isMessageOffline ? "Redacted" : author) .bold() .redacted(reason: isMessageOffline ? .placeholder : []) @@ -279,6 +298,7 @@ extension EnvironmentValues { conversation: MessagesServiceStub.conversation, coursePath: CoursePath(course: MessagesServiceStub.course) ), - isHeaderVisible: true + isHeaderVisible: true, + roundBottomCorners: true ) } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index e0efbdc8..e50511bf 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -70,7 +70,8 @@ private extension MessageDetailView { conversationViewModel: viewModel, message: Binding.constant(DataState.done(response: message)), conversationPath: nil, - isHeaderVisible: true + isHeaderVisible: true, + roundBottomCorners: true ) .environment(\.isEmojiPickerButtonVisible, true) .onLongPressGesture(maximumDistance: 30) { @@ -88,15 +89,21 @@ private extension MessageDetailView { func answers(of message: BaseMessage, proxy: ScrollViewProxy) -> some View { if let message = message as? Message { Divider() - VStack { + .padding(.top, .s) + VStack(spacing: 0) { let sortedArray = (message.answers ?? []).sorted { $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday } + let totalMessages = sortedArray.count ForEach(Array(sortedArray.enumerated()), id: \.1) { index, answerMessage in + let isHeaderVisible = !answerMessage.isContinuation(of: sortedArray[safe: index - 1]) + let needsRoundedCorners = !(sortedArray[safe: index + 1]?.isContinuation(of: answerMessage) ?? false) MessageCellWrapper( viewModel: viewModel, answerMessage: answerMessage, - isHeaderVisible: index == 0 || !answerMessage.isContinuation(of: sortedArray[index - 1])) + isHeaderVisible: isHeaderVisible, + roundBottomCorners: needsRoundedCorners) + .id(index == totalMessages - 1 ? nil : answerMessage) } Spacer() .id("bottom") @@ -136,6 +143,7 @@ private struct MessageCellWrapper: View { let answerMessage: AnswerMessage let isHeaderVisible: Bool + let roundBottomCorners: Bool private var answerMessageBinding: Binding> { @@ -168,7 +176,8 @@ private struct MessageCellWrapper: View { conversationViewModel: viewModel, message: answerMessageBinding, conversationPath: nil, - isHeaderVisible: isHeaderVisible) + isHeaderVisible: isHeaderVisible, + roundBottomCorners: roundBottomCorners) } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift index 916cb0a3..be97b05d 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift @@ -75,7 +75,7 @@ private struct EmojiTextButton: View { } } label: { Text("\(pair.0) \(pair.1.count)") - .font(.caption) + .font(.footnote) .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) .frame(height: .extraSmallImage) .padding(.m) From 6f0e30b059b61178389cbebaf11f3c611ba522d5 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:53:47 +0200 Subject: [PATCH 34/48] `Communication`: Add sheet for viewing reaction authors (#133) * Add ReactionsViewModel * Add sheet for viewing reaction authors * Remove view rendering workarounds --- .../Resources/en.lproj/Localizable.strings | 1 + .../ReactionsViewModel.swift | 114 +++++++++ .../MessageDetailView/ReactionsView.swift | 223 ++++++++++-------- 3 files changed, 246 insertions(+), 92 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 7b3a4ca7..a28cb323 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -30,6 +30,7 @@ // MARK: ReactionsView "emojis" = "Emojis"; +"all" = "All (%i)"; // MARK: MessagesTabView "browseChannels" = "Browse Channels"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift new file mode 100644 index 00000000..28d5f672 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift @@ -0,0 +1,114 @@ +// +// ReactionsViewModel.swift +// +// +// Created by Anian Schleyer on 17.07.24. +// + +import Common +import Foundation +import SharedModels +import Smile +import SwiftUI + +@Observable +class ReactionsViewModel { + private var conversationViewModel: ConversationViewModel + var showAuthorsSheet = false + var selectedReactionSheet = "All" + + // Binding to pass updates back to ConversationViewModel + private var messageBinding: Binding> + // Regular variable to take advantage of automatic Observable updates + var message: DataState + + init(conversationViewModel: ConversationViewModel, message: Binding>) { + self.conversationViewModel = conversationViewModel + self.messageBinding = message + self.message = message.wrappedValue + } + + var mappedReaction: [String: [Reaction]] { + var reactions = [String: [Reaction]]() + + message.value?.reactions?.forEach { + guard let emoji = Smile.emoji(alias: $0.emojiId) else { + return + } + if reactions[emoji] != nil { + reactions[emoji]?.append($0) + } else { + reactions[emoji] = [$0] + } + } + return reactions + } + + @MainActor + func addReaction(emojiId: String) async { + if let message = message.value as? Message { + let result = await conversationViewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.messageBinding.wrappedValue = .loading + case .failure(let error): + self.messageBinding.wrappedValue = .failure(error: error) + case .done(let response): + self.messageBinding.wrappedValue = .done(response: response) + } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await conversationViewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.messageBinding.wrappedValue = .loading + case .failure(let error): + self.messageBinding.wrappedValue = .failure(error: error) + case .done(let response): + self.messageBinding.wrappedValue = .done(response: response) + } + } + } + + func isMyReaction(_ emoji: String) -> Bool { + guard let emojiId = Smile.alias(emoji: emoji), + let message = message.value else { + return false + } + + return message.containsReactionFromMe(emojiId: emojiId) + } +} + +// MARK: DataState+Equatable + +/// We need conformance of DataState to Equatable for this ViewModel +/// to receive updates to `message` using SwiftUI's `onChange` modifier. +extension DataState: Equatable { + public static func == (lhs: DataState, rhs: DataState) -> Bool { + switch lhs { + case .loading: + return rhs == .loading + case .failure: + return false + case .done(let responseLhs): + switch rhs { + case .done(let responseRhs): + var hashLhs = Hasher() + var hashRhs = Hasher() + hashLhs.combine(responseLhs.id) + hashRhs.combine(responseRhs.id) + hashLhs.combine(responseLhs.author) + hashRhs.combine(responseRhs.author) + hashLhs.combine(responseLhs.reactions) + hashRhs.combine(responseRhs.reactions) + hashLhs.combine(responseLhs.updatedDate) + hashRhs.combine(responseRhs.updatedDate) + hashLhs.combine(responseLhs.content) + hashRhs.combine(responseRhs.content) + return hashLhs.finalize() == hashRhs.finalize() + default: + return false + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift index be97b05d..fbe48eda 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift @@ -15,73 +15,54 @@ import UserStore struct ReactionsView: View { @Environment(\.isEmojiPickerButtonVisible) var isEmojiPickerButtonVisible: Bool - @ObservedObject private var viewModel: ConversationViewModel - + @State var viewModel: ReactionsViewModel @Binding var message: DataState - @State private var viewRerenderWorkaround = false - - let columns = [ GridItem(.adaptive(minimum: 45)) ] - - var mappedReaction: [String: [Reaction]] { - var reactions = [String: [Reaction]]() - - message.value?.reactions?.forEach { - guard let emoji = Smile.emoji(alias: $0.emojiId) else { - return - } - if reactions[emoji] != nil { - reactions[emoji]?.append($0) - } else { - reactions[emoji] = [$0] - } - } - return reactions - } + let columns = [ GridItem(.adaptive(minimum: 50)) ] init( viewModel: ConversationViewModel, message: Binding> ) { - self.viewModel = viewModel + self._viewModel = State(initialValue: ReactionsViewModel(conversationViewModel: viewModel, message: message)) self._message = message } var body: some View { LazyVGrid(columns: columns, alignment: .leading) { - ForEach(mappedReaction.sorted(by: { $0.key < $1.key }), id: \.key) { map in - EmojiTextButton(viewModel: viewModel, pair: (map.key, map.value), message: $message) + ForEach(viewModel.mappedReaction.sorted(by: { $0.key < $1.key }), id: \.key) { map in + EmojiTextButton(viewModel: viewModel, pair: (map.key, map.value)) } - if !mappedReaction.isEmpty || isEmojiPickerButtonVisible { - EmojiPickerButton(viewModel: viewModel, message: $message, viewRerenderWorkaround: $viewRerenderWorkaround) + if !viewModel.mappedReaction.isEmpty || isEmojiPickerButtonVisible { + EmojiPickerButton(viewModel: viewModel) } } + .popover(isPresented: $viewModel.showAuthorsSheet, attachmentAnchor: .point(.bottom), arrowEdge: .top) { + ReactionAuthorsSheet(viewModel: viewModel) + } + .onChange(of: message, { _, newValue in + viewModel.message = newValue + }) } } private struct EmojiTextButton: View { - @ObservedObject var viewModel: ConversationViewModel + var viewModel: ReactionsViewModel let pair: (String, [Reaction]) - @Binding var message: DataState var body: some View { Button { - if let emojiId = Smile.alias(emoji: pair.0) { - Task { - await addReaction(emojiId: emojiId) - } - } } label: { Text("\(pair.0) \(pair.1.count)") .font(.footnote) - .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) + .foregroundColor(viewModel.isMyReaction(pair.0) ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) .frame(height: .extraSmallImage) .padding(.m) .background( Group { - if isMyReaction { + if viewModel.isMyReaction(pair.0) { Capsule() .strokeBorder(Color.Artemis.artemisBlue, lineWidth: 1) .background(Capsule().foregroundColor(Color.Artemis.artemisBlue.opacity(0.25))) @@ -92,54 +73,139 @@ private struct EmojiTextButton: View { } ) } + .simultaneousGesture(TapGesture() + .onEnded { _ in + if let emojiId = Smile.alias(emoji: pair.0) { + Task { + await viewModel.addReaction(emojiId: emojiId) + } + } + } + ) + .simultaneousGesture(LongPressGesture() + .onEnded { _ in + viewModel.selectedReactionSheet = pair.0 + viewModel.showAuthorsSheet = true + } + ) } } -private extension EmojiTextButton { - func addReaction(emojiId: String) async { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) +struct ReactionAuthorsSheet: View { + @Bindable var viewModel: ReactionsViewModel + + var body: some View { + VStack { + let mappedReactions = viewModel.mappedReaction + + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + filterRow(mappedReactions: mappedReactions) + } + .frame(height: 40, alignment: .top) + .contentMargins(.leading, .l, for: .scrollContent) + .contentMargins(.trailing, 90, for: .scrollContent) + .onChange(of: viewModel.selectedReactionSheet, initial: true) { _, newValue in + withAnimation { + proxy.scrollTo(newValue) + } + } } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) + .overlay(alignment: .trailing) { + closeButton } + + TabView(selection: $viewModel.selectedReactionSheet) { + ForEach(["All"] + mappedReactions.keys.sorted(), id: \.self) { key in + reactionsList(for: key, mappedReactions: mappedReactions) + .tag(key) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .padding(.top) + .presentationDetents([.medium, .large]) + .frame(minWidth: 250, minHeight: 300) + } + + @ViewBuilder var closeButton: some View { + ZStack(alignment: .trailing) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .init( + uiColor: .systemBackground + ), location: 0.4) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 90) + .allowsHitTesting(false) + + Button { + viewModel.showAuthorsSheet = false + } label: { + Image(systemName: "xmark.circle.fill") + .resizable() + .padding(5) + .frame(width: 40, height: 40) + } + .foregroundStyle(.secondary) + .padding(.trailing, .m) } } - var isMyReaction: Bool { - guard let emojiId = Smile.alias(emoji: pair.0), - let message = message.value else { - return false + @ViewBuilder + func filterRow(mappedReactions: [String: [Reaction]]) -> some View { + LazyHStack(alignment: .top) { + ForEach(["All"] + mappedReactions.keys.sorted(), id: \.self) { key in + Button { + withAnimation { + viewModel.selectedReactionSheet = key + } + } label: { + let total = mappedReactions.reduce(0) { partialResult, pair in + partialResult + pair.1.count + } + Text(key == "All" ? R.string.localizable.all(total) : key) + .containerRelativeFrame(.vertical) + .padding(.horizontal, .m) + .font(key == "All" ? .body : .title) + .background(key == viewModel.selectedReactionSheet ? .gray.opacity(0.5) : .clear, in: .capsule) + } + .buttonStyle(.plain) + } } + } - return message.containsReactionFromMe(emojiId: emojiId) + @ViewBuilder + func reactionsList(for key: String, mappedReactions: [String: [Reaction]]) -> some View { + ScrollView { + let keys = key == "All" ? mappedReactions.keys.sorted() : [key] + ForEach(keys, id: \.self) { key in + if let reactions = mappedReactions[key] { + ForEach(reactions, id: \.id) { reaction in + if let name = reaction.user?.name { + Text("\(key) \(name)") + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .padding([.top, .horizontal]) + } + } + } + } + } } } private struct EmojiPickerButton: View { - @ObservedObject var viewModel: ConversationViewModel + var viewModel: ReactionsViewModel @State private var isEmojiPickerPresented = false @State var selectedEmoji: Emoji? - @Binding var message: DataState - @Binding var viewRerenderWorkaround: Bool - var body: some View { Button { isEmojiPickerPresented = true @@ -164,8 +230,7 @@ private struct EmojiPickerButton: View { if let newEmoji, let emojiId = Smile.alias(emoji: newEmoji.value) { Task { - await addReaction(emojiId: emojiId) - viewRerenderWorkaround.toggle() + await viewModel.addReaction(emojiId: emojiId) selectedEmoji = nil } } @@ -173,32 +238,6 @@ private struct EmojiPickerButton: View { } } -private extension EmojiPickerButton { - func addReaction(emojiId: String) async { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } - } -} - // MARK: - Environment+IsEmojiPickerVisible private enum IsEmojiPickerVisibleEnvironmentKey: EnvironmentKey { From ea1b14a858b5d451abeaa028c8f01224af514e00 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 31 Jul 2024 18:07:36 +0200 Subject: [PATCH 35/48] `Communication`: Improve discoverability of message actions (#134) * Introduce actions bar in MessageDetailView * Refactor for MessageActionsSheet * Make top message in detail view full size * Display channel name in thread * Change copy button * Move edit label and adjust displaying of dates --- .../Resources/en.lproj/Localizable.strings | 1 + .../ReactionsViewModel.swift | 2 +- .../MessageActionSheet.swift | 406 ++++++++++-------- .../Views/MessageDetailView/MessageCell.swift | 58 ++- .../MessageDetailView/MessageDetailView.swift | 34 +- 5 files changed, 288 insertions(+), 213 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index a28cb323..0134f610 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -59,6 +59,7 @@ "edited" = "Edited"; "messageCouldNotBeLoadedError" = "Message could not be loaded."; "thread" = "Thread"; +"replies" = "Reply"; // MARK: MessageActionSheet "replyInThread" = "Reply in Thread"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift index 28d5f672..7d6d8be9 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift @@ -87,7 +87,7 @@ extension DataState: Equatable { public static func == (lhs: DataState, rhs: DataState) -> Bool { switch lhs { case .loading: - return rhs == .loading + return false case .failure: return false case .done(let responseLhs): diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index b30f1127..c697c92e 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -13,190 +13,245 @@ import Smile import SwiftUI import UserStore -struct MessageActionSheet: View { - - @EnvironmentObject var navigationController: NavigationController - @Environment(\.dismiss) var dismiss - +struct MessageActions: View { @ObservedObject var viewModel: ConversationViewModel - @Binding var message: DataState let conversationPath: ConversationPath? - @State private var showDeleteAlert = false - @State private var showEditSheet = false - - var isAbleToEditDelete: Bool { - guard let message = message.value else { - return false + var body: some View { + Group { + ReplyInThreadButton(viewModel: viewModel, message: $message, conversationPath: conversationPath) + CopyTextButton(message: $message) + EditDeleteSection(viewModel: viewModel, message: $message) } + .environment(\.allowAutoDismiss, false) + .lineLimit(1) + .font(.title3.bold()) + } - if message.isCurrentUserAuthor { - return true - } + struct ReplyInThreadButton: View { + @EnvironmentObject var navigationController: NavigationController + @Environment(\.dismiss) var dismiss + @Environment(\.allowAutoDismiss) var allowDismiss - guard let channel = viewModel.conversation.baseConversation as? Channel else { - return false - } - if channel.hasChannelModerationRights ?? false && message is Message { - return true - } + @ObservedObject var viewModel: ConversationViewModel + @Binding var message: DataState + let conversationPath: ConversationPath? - return false - } - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: .l) { - HStack(spacing: .m) { - EmojiTextButton(viewModel: viewModel, message: $message, emoji: "😂") - EmojiTextButton(viewModel: viewModel, message: $message, emoji: "👍") - EmojiTextButton(viewModel: viewModel, message: $message, emoji: "➕") - EmojiTextButton(viewModel: viewModel, message: $message, emoji: "🚀") - EmojiPickerButton(viewModel: viewModel, message: $message) - } - .padding(.l) - if message.value is Message, - let conversationPath { - Divider() - Button { - if let messagePath = MessagePath( - message: $message, - conversationPath: conversationPath, - conversationViewModel: viewModel - ) { + var body: some View { + if message.value is Message, + let conversationPath { + Divider() + Button(R.string.localizable.replyInThread(), systemImage: "text.bubble") { + if let messagePath = MessagePath( + message: $message, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { + if allowDismiss { dismiss() - navigationController.path.append(messagePath) - } else { - viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } - } label: { - ButtonContent(title: R.string.localizable.replyInThread(), icon: "text.bubble.fill") + navigationController.path.append(messagePath) + } else { + viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } } - Divider() - Button { - UIPasteboard.general.string = message.value?.content + } + } + } + + struct CopyTextButton: View { + @Environment(\.allowAutoDismiss) var allowDismiss + @Environment(\.dismiss) var dismiss + @Binding var message: DataState + @State private var showSuccess = false + + var body: some View { + Button(R.string.localizable.copyText(), systemImage: "doc.on.doc") { + UIPasteboard.general.string = message.value?.content + if allowDismiss { dismiss() - } label: { - ButtonContent(title: R.string.localizable.copyText(), icon: "clipboard.fill") + } else { + showSuccess = true + } + } + .opacity(showSuccess ? 0 : 1) + .overlay { + if showSuccess { + Label("Copied", systemImage: "checkmark.circle.fill") + .font(.title3.bold()) + .foregroundStyle(.green) + .transition(.scale) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showSuccess = false + } + } } + } + .animation(.spring(), value: showSuccess) + } + } - editDeleteSection + struct EditDeleteSection: View { + @Environment(\.allowAutoDismiss) var allowDismiss + @Environment(\.dismiss) var dismiss + @EnvironmentObject var navigationController: NavigationController + @ObservedObject var viewModel: ConversationViewModel + @Binding var message: DataState - Spacer() + @State private var showDeleteAlert = false + @State private var showEditSheet = false + + var isAbleToEditDelete: Bool { + guard let message = message.value else { + return false } - Spacer() + + if message.isCurrentUserAuthor { + return true + } + + guard let channel = viewModel.conversation.baseConversation as? Channel else { + return false + } + if channel.hasChannelModerationRights ?? false && message is Message { + return true + } + + return false } - .padding(.vertical, .xxl) - .loadingIndicator(isLoading: $viewModel.isLoading) - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) - } -} -private extension MessageActionSheet { - var editDeleteSection: some View { - Group { - if isAbleToEditDelete { - Divider() + var body: some View { + Group { + if isAbleToEditDelete { + Divider() - Button { - showEditSheet = true - } label: { - ButtonContent(title: R.string.localizable.editMessage(), icon: "pencil") - } - .sheet(isPresented: $showEditSheet) { - editMessage - } + Button(R.string.localizable.editMessage(), systemImage: "pencil") { + showEditSheet = true + } + .sheet(isPresented: $showEditSheet) { + editMessage + .font(nil) + } - Button { - showDeleteAlert = true - } label: { - ButtonContent(title: R.string.localizable.deleteMessage(), icon: "trash.fill") - .foregroundColor(.red) - } - .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { - Button(R.string.localizable.confirm(), role: .destructive) { - viewModel.isLoading = true - Task(priority: .userInitiated) { - let success: Bool - let tempMessage = message.value - if message.value is AnswerMessage { - success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) - } else { - success = await viewModel.deleteMessage(messageId: message.value?.id) - } - viewModel.isLoading = false - if success { - dismiss() - // if we deleted a Message and are in the MessageDetailView we pop it - if navigationController.path.count == 3 && tempMessage is Message { - navigationController.path.removeLast() + Button(R.string.localizable.deleteMessage(), systemImage: "trash", role: .destructive) { + showDeleteAlert = true + } + .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { + Button(R.string.localizable.confirm(), role: .destructive) { + viewModel.isLoading = true + Task(priority: .userInitiated) { + let success: Bool + let tempMessage = message.value + if message.value is AnswerMessage { + success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) + } else { + success = await viewModel.deleteMessage(messageId: message.value?.id) + } + viewModel.isLoading = false + if success { + if allowDismiss { + dismiss() + } + // if we deleted a Message and are in the MessageDetailView we pop it + if navigationController.path.count == 3 && tempMessage is Message { + navigationController.path.removeLast() + } } } } + Button(R.string.localizable.cancel(), role: .cancel) { } } - Button(R.string.localizable.cancel(), role: .cancel) { } } } } - } - var editMessage: some View { - NavigationView { - Group { - if let message = message.value as? Message { - SendMessageView( - viewModel: SendMessageViewModel( - course: viewModel.course, - conversation: viewModel.conversation, - configuration: .editMessage(message, { self.dismiss() }), - delegate: SendMessageViewModelDelegate(viewModel) + var editMessage: some View { + NavigationView { + Group { + if let message = message.value as? Message { + SendMessageView( + viewModel: SendMessageViewModel( + course: viewModel.course, + conversation: viewModel.conversation, + configuration: .editMessage(message, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) ) - ) - } else if let answerMessage = message.value as? AnswerMessage { - SendMessageView( - viewModel: SendMessageViewModel( - course: viewModel.course, - conversation: viewModel.conversation, - configuration: .editAnswerMessage(answerMessage, { self.dismiss() }), - delegate: SendMessageViewModelDelegate(viewModel) + } else if let answerMessage = message.value as? AnswerMessage { + SendMessageView( + viewModel: SendMessageViewModel( + course: viewModel.course, + conversation: viewModel.conversation, + configuration: .editAnswerMessage(answerMessage, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) ) - ) - } else { - Text(R.string.localizable.loading()) + } else { + Text(R.string.localizable.loading()) + } } - } - .navigationTitle(R.string.localizable.editMessage()) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(R.string.localizable.cancel()) { - showEditSheet = false + .navigationTitle(R.string.localizable.editMessage()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(R.string.localizable.cancel()) { + showEditSheet = false + } } } } + .presentationDetents([.height(200), .medium]) } - .presentationDetents([.height(200), .medium]) } } -private struct ButtonContent: View { +struct MessageActionSheet: View { + @ObservedObject var viewModel: ConversationViewModel + @Binding var message: DataState + let conversationPath: ConversationPath? + @State var reactionsViewModel: ReactionsViewModel - let title: String - let icon: String + init(viewModel: ConversationViewModel, message: Binding>, conversationPath: ConversationPath?) { + self.viewModel = viewModel + self._message = message + self.conversationPath = conversationPath + self._reactionsViewModel = State(initialValue: ReactionsViewModel(conversationViewModel: viewModel, message: message)) + } var body: some View { - HStack(spacing: .s) { - Image(systemName: icon) - .resizable() - .scaledToFit() - .frame(width: .mediumImage, height: .smallImage) - Text(title) - .font(.headline) + HStack { + VStack(alignment: .leading, spacing: .l) { + HStack(spacing: .m) { + EmojiTextButton(viewModel: reactionsViewModel, emoji: "😂") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "👍") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "➕") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "🚀") + EmojiPickerButton(viewModel: reactionsViewModel) + } + .padding(.l) + MessageActions.ReplyInThreadButton(viewModel: viewModel, message: $message, conversationPath: conversationPath) + .padding(.horizontal) + Divider() + MessageActions.CopyTextButton(message: $message) + .padding(.horizontal) + + MessageActions.EditDeleteSection(viewModel: viewModel, message: $message) + .padding(.horizontal) + + Spacer() + } + .buttonStyle(.plain) + .font(.headline) + .symbolVariant(.fill) + .imageScale(.large) + Spacer() } - .padding(.horizontal, .l) - .foregroundColor(.Artemis.primaryLabel) + .padding(.vertical, .xl) + .frame(maxHeight: .infinity, alignment: .top) + .loadingIndicator(isLoading: $viewModel.isLoading) + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } } @@ -204,9 +259,7 @@ private struct EmojiTextButton: View { @Environment(\.dismiss) var dismiss - @ObservedObject var viewModel: ConversationViewModel - - @Binding var message: DataState + var viewModel: ReactionsViewModel let emoji: String @@ -222,27 +275,7 @@ private struct EmojiTextButton: View { .onTapGesture { if let emojiId = Smile.alias(emoji: emoji) { Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } + await viewModel.addReaction(emojiId: emojiId) dismiss() } } @@ -254,9 +287,7 @@ private struct EmojiPickerButton: View { @Environment(\.dismiss) var dismiss - @ObservedObject var viewModel: ConversationViewModel - - @Binding var message: DataState + var viewModel: ReactionsViewModel @State private var showEmojiPicker = false @State var selectedEmoji: Emoji? @@ -285,27 +316,7 @@ private struct EmojiPickerButton: View { if let newEmoji, let emojiId = Smile.alias(emoji: newEmoji.value) { Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } + await viewModel.addReaction(emojiId: emojiId) selectedEmoji = nil dismiss() } @@ -313,3 +324,20 @@ private struct EmojiPickerButton: View { } } } + +// MARK: - Environment+AutoDismiss + +private enum SheetAutoDismissEnvironmentKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var allowAutoDismiss: Bool { + get { + self[SheetAutoDismissEnvironmentKey.self] + } + set { + self[SheetAutoDismissEnvironmentKey.self] = newValue + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 97cb78b4..e47e2f7d 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -14,6 +14,7 @@ import SwiftUI struct MessageCell: View { @Environment(\.isMessageOffline) var isMessageOffline: Bool + @Environment(\.messageUseFullWidth) var useFullWidth: Bool @EnvironmentObject var navigationController: NavigationController @ObservedObject var conversationViewModel: ConversationViewModel @@ -30,6 +31,7 @@ struct MessageCell: View { ArtemisMarkdownView(string: content) .opacity(isMessageOffline ? 0.5 : 1) .environment(\.openURL, OpenURLAction(handler: handle)) + editedLabel } Spacer() } @@ -48,14 +50,14 @@ struct MessageCell: View { replyButtonIfAvailable } .padding(.horizontal, .m) - .padding(viewModel.isHeaderVisible ? .vertical : .bottom, .m) + .padding(viewModel.isHeaderVisible ? .vertical : .bottom, useFullWidth ? 0 : .m) .background( - Color(uiColor: .secondarySystemBackground), + useFullWidth ? .clear : Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadii: viewModel.roundedCorners) ) .padding(.top, viewModel.isHeaderVisible ? .m : 0) .id(message.value?.id.description) - .padding(.horizontal, .l) + .padding(.horizontal, useFullWidth ? 0 : .l) .sheet(isPresented: $viewModel.isActionSheetPresented) { MessageActionSheet( viewModel: conversationViewModel, @@ -130,29 +132,34 @@ private extension MessageCell { .bold() .redacted(reason: isMessageOffline ? .placeholder : []) if let creationDate { - Group { - Text(creationDate, formatter: DateFormatter.timeOnly) - - if message.value?.updatedDate != nil { - Text(R.string.localizable.edited()) - .foregroundColor(.Artemis.secondaryLabel) - } + let formatter: DateFormatter = viewModel.conversationPath == nil ? .superShortDateAndTime : .timeOnly + Text(creationDate, formatter: formatter) + .font(.caption) + if viewModel.isChipVisible(creationDate: creationDate, authorId: message.value?.author?.id) { + Chip( + text: R.string.localizable.new(), + backgroundColor: .Artemis.artemisBlue, + padding: .s + ) + .font(.footnote) } - .font(.caption) - Chip( - text: R.string.localizable.new(), - backgroundColor: .Artemis.artemisBlue, - padding: .s - ) - .font(.footnote) - .opacity( - viewModel.isChipVisible(creationDate: creationDate, authorId: message.value?.author?.id) ? 1 : 0 - ) } } } } + @ViewBuilder var editedLabel: some View { + if let updatedDate = message.value?.updatedDate { + Group { + Text(R.string.localizable.edited() + " (") + + Text(updatedDate, formatter: DateFormatter.superShortDateAndTime) + + Text(")") + } + .font(.caption) + .foregroundColor(.Artemis.secondaryLabel) + } + } + @ViewBuilder var retryButtonIfAvailable: some View { if let retryButtonAction = viewModel.retryButtonAction { Button(action: retryButtonAction) { @@ -276,6 +283,9 @@ private extension MessageCell { private enum IsMessageOfflineEnvironmentKey: EnvironmentKey { static let defaultValue = false } +private enum MessageFullWidthEnvironmentKey: EnvironmentKey { + static let defaultValue = false +} extension EnvironmentValues { var isMessageOffline: Bool { @@ -286,6 +296,14 @@ extension EnvironmentValues { self[IsMessageOfflineEnvironmentKey.self] = newValue } } + var messageUseFullWidth: Bool { + get { + self[MessageFullWidthEnvironmentKey.self] + } + set { + self[MessageFullWidthEnvironmentKey.self] = newValue + } + } } #Preview { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index e50511bf..84db927c 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -54,7 +54,7 @@ struct MessageDetailView: View { } } } - .navigationTitle(R.string.localizable.thread()) + .navigationTitle(R.string.localizable.thread() + " (\(viewModel.conversation.baseConversation.conversationName))") .task { if message.value == nil { await reloadMessage() @@ -74,6 +74,7 @@ private extension MessageDetailView { roundBottomCorners: true ) .environment(\.isEmojiPickerButtonVisible, true) + .environment(\.messageUseFullWidth, true) .onLongPressGesture(maximumDistance: 30) { let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() @@ -85,11 +86,38 @@ private extension MessageDetailView { } } + @ViewBuilder var divider: some View { + VStack { + Divider() + HStack { + let replies = (message.value as? Message)?.answers?.count ?? 0 + Text("^[\(replies) \(R.string.localizable.replies())](inflect:true)") + .foregroundStyle(.secondary) + .padding(.top, .s) + + Spacer() + + // Only display labels if we have enough space + ViewThatFits(in: .horizontal) { + HStack { + MessageActions(viewModel: viewModel, message: $message, conversationPath: nil) + } + HStack(spacing: .l) { + MessageActions(viewModel: viewModel, message: $message, conversationPath: nil) + .labelStyle(.iconOnly) + } + } + } + .padding(.horizontal) + Divider() + }.padding(.top, .s) + } + @ViewBuilder func answers(of message: BaseMessage, proxy: ScrollViewProxy) -> some View { if let message = message as? Message { - Divider() - .padding(.top, .s) + divider + VStack(spacing: 0) { let sortedArray = (message.answers ?? []).sorted { $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday From 589f1bcc74d894a5d143450a90bfa9b51471505d Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 2 Aug 2024 16:43:34 +0200 Subject: [PATCH 36/48] `Communication`: Add Swipe to Reply gesture (#135) --- ...igationDestinationThreadViewModifier.swift | 2 +- .../Sources/Messages/Navigation/Paths.swift | 5 +- .../MessageCellModel.swift | 57 +++++++++++++++++++ .../SendMessageViewModel.swift | 5 +- .../MessageActionSheet.swift | 3 +- .../Views/MessageDetailView/MessageCell.swift | 55 +++++++++++++----- .../MessageDetailView/MessageDetailView.swift | 10 ++-- .../SendMessageViews/SendMessageView.swift | 3 + 8 files changed, 117 insertions(+), 23 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift index c8224765..4286da8b 100644 --- a/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift +++ b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift @@ -13,7 +13,7 @@ public struct NavigationDestinationThreadViewModifier: ViewModifier { public func body(content: Content) -> some View { content.navigationDestination(for: MessagePath.self) { messagePath in - MessageDetailView(viewModel: messagePath.conversationViewModel, message: messagePath.message) + MessageDetailView(viewModel: messagePath.conversationViewModel, message: messagePath.message, presentKeyboardOnAppear: messagePath.presentKeyboardOnAppear) } } } diff --git a/ArtemisKit/Sources/Messages/Navigation/Paths.swift b/ArtemisKit/Sources/Messages/Navigation/Paths.swift index eb81e10a..673cce7f 100644 --- a/ArtemisKit/Sources/Messages/Navigation/Paths.swift +++ b/ArtemisKit/Sources/Messages/Navigation/Paths.swift @@ -15,11 +15,13 @@ struct MessagePath: Hashable { let message: Binding> let conversationPath: ConversationPath let conversationViewModel: ConversationViewModel + let presentKeyboardOnAppear: Bool init?( message: Binding>, conversationPath: ConversationPath, - conversationViewModel: ConversationViewModel + conversationViewModel: ConversationViewModel, + presentKeyboardOnAppear: Bool = false ) { guard let id = message.wrappedValue.value?.id else { return nil @@ -29,6 +31,7 @@ struct MessagePath: Hashable { self.message = message self.conversationPath = conversationPath self.conversationViewModel = conversationViewModel + self.presentKeyboardOnAppear = presentKeyboardOnAppear } static func == (lhs: MessagePath, rhs: MessagePath) -> Bool { diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift index e3017573..6bf25202 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -23,6 +23,7 @@ final class MessageCellModel { var isActionSheetPresented = false var isDetectingLongPress = false + var swipeToReplyState = SwipeToReplyState() private let messagesService: MessagesService private let userSession: UserSession @@ -63,6 +64,32 @@ extension MessageCellModel { return .init(topLeading: top, bottomLeading: bottom, bottomTrailing: bottom, topTrailing: top) } + func swipeToReplyGesture(openThread: @escaping () -> Void) -> some Gesture { + DragGesture(minimumDistance: 20) + .onChanged { value in + // No swiping in Thread View + guard self.conversationPath != nil else { return } + + // Only allow swipe to the left + let distance = min(value.translation.width, 0) + + self.swipeToReplyState.update(with: distance) + } + .onEnded { _ in + if self.swipeToReplyState.swiped { + openThread() + } else { + withAnimation(.easeInOut(duration: 0.2)) { + self.resetSwipeToReply() + } + } + } + } + + func resetSwipeToReply() { + swipeToReplyState = .init() + } + // MARK: Navigation func getOneToOneChatOrCreate(login: String) async -> Conversation? { @@ -85,3 +112,33 @@ extension MessageCellModel { return nil } } + +// MARK: Swipe to Reply +@Observable +class SwipeToReplyState { + var swiped = false + var overlayOffset: CGFloat = 100 + var overlayOpacity: CGFloat = 0 + var overlayScale: CGFloat = 0 + var messageBlur: CGFloat = 0 + + // Configurable properties + private let blurIntensity: CGFloat = 0.75 + + /// 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)) + + // If user dragged far enough to activate reply, let them know + if !swiped && distance < -70 { + swiped = true + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } else if swiped && distance >= -70 { + swiped = false + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index aa5d304b..72ce0d9e 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -69,7 +69,8 @@ final class SendMessageViewModel { var isMemberPickerSuppressed = false var isChannelPickerSuppressed = false - var wantsToAddMessageMentionContentType: MessageMentionContentType? = nil + var wantsToAddMessageMentionContentType: MessageMentionContentType? + var presentKeyboardOnAppear: Bool // MARK: Life cycle @@ -78,6 +79,7 @@ final class SendMessageViewModel { conversation: Conversation, configuration: Configuration, delegate: SendMessageViewModelDelegate, + presentKeyboardOnAppear: Bool = false, messagesRepository: MessagesRepository = .shared, messagesService: MessagesService = MessagesServiceFactory.shared, userSession: UserSession = UserSessionFactory.shared @@ -85,6 +87,7 @@ final class SendMessageViewModel { self.course = course self.conversation = conversation self.configuration = configuration + self.presentKeyboardOnAppear = presentKeyboardOnAppear self.delegate = delegate self.messagesRepository = messagesRepository diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index c697c92e..60f5945e 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -26,7 +26,7 @@ struct MessageActions: View { } .environment(\.allowAutoDismiss, false) .lineLimit(1) - .font(.title3.bold()) + .font(.title3) } struct ReplyInThreadButton: View { @@ -248,6 +248,7 @@ struct MessageActionSheet: View { .imageScale(.large) Spacer() } + .fontWeight(.bold) .padding(.vertical, .xl) .frame(maxHeight: .infinity, alignment: .top) .loadingIndicator(isLoading: $viewModel.isLoading) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index e47e2f7d..5de73f7a 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -51,13 +51,20 @@ struct MessageCell: View { } .padding(.horizontal, .m) .padding(viewModel.isHeaderVisible ? .vertical : .bottom, useFullWidth ? 0 : .m) + .contentShape(.rect) + .gesture(viewModel.swipeToReplyGesture(openThread: onSwipePresentMessage)) + .blur(radius: viewModel.swipeToReplyState.messageBlur) + .overlay(alignment: .trailing) { + swipeToReplyOverlay + } .background( useFullWidth ? .clear : Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadii: viewModel.roundedCorners) ) .padding(.top, viewModel.isHeaderVisible ? .m : 0) .id(message.value?.id.description) - .padding(.horizontal, useFullWidth ? 0 : .l) + .padding(.horizontal, useFullWidth ? 0 : (.m + .l) / 2) + .onDisappear(perform: viewModel.resetSwipeToReply) .sheet(isPresented: $viewModel.isActionSheetPresented) { MessageActionSheet( viewModel: conversationViewModel, @@ -178,15 +185,7 @@ private extension MessageCell { let answerCount = message.answers?.count, answerCount > 0, let conversationPath = viewModel.conversationPath { Button { - if let messagePath = MessagePath( - message: self.$message, - conversationPath: conversationPath, - conversationViewModel: conversationViewModel - ) { - navigationController.path.append(messagePath) - } else { - conversationViewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) - } + openThread(showErrorOnFailure: true) } label: { Label { Text("^[\(answerCount) \(R.string.localizable.reply())](inflect: true)") @@ -197,19 +196,45 @@ private extension MessageCell { } } - // MARK: Gestures + @ViewBuilder var swipeToReplyOverlay: some View { + Image(systemName: "arrowshape.turn.up.left.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 40) + .foregroundStyle(viewModel.swipeToReplyState.swiped ? .blue : .gray) + .padding(.horizontal) + .offset(x: viewModel.swipeToReplyState.overlayOffset) + .scaleEffect(x: viewModel.swipeToReplyState.overlayScale, y: viewModel.swipeToReplyState.overlayScale, anchor: .trailing) + .opacity(viewModel.swipeToReplyState.overlayOpacity) + .animation(.easeInOut(duration: 0.1), value: viewModel.swipeToReplyState.swiped) + .accessibilityHidden(true) + } - func onTapPresentMessage() { - // Tap is disabled, if conversation path is nil, e.g., in the message detail view. - if let conversationPath = viewModel.conversationPath, let messagePath = MessagePath( + func openThread(showErrorOnFailure: Bool = true, presentKeyboard: Bool = false) { + // We cannot navigate to details if conversation path is nil, e.g. in the message detail view. + if let conversationPath = viewModel.conversationPath, + let messagePath = MessagePath( message: $message, conversationPath: conversationPath, - conversationViewModel: conversationViewModel + conversationViewModel: conversationViewModel, + presentKeyboardOnAppear: presentKeyboard ) { navigationController.path.append(messagePath) + } else if showErrorOnFailure { + conversationViewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } } + // MARK: Gestures + + func onTapPresentMessage() { + openThread(showErrorOnFailure: false) + } + + func onSwipePresentMessage() { + openThread(presentKeyboard: true) + } + func onLongPressPresentActionSheet() { if let channel = conversationViewModel.conversation.baseConversation as? Channel, channel.isArchived ?? false { return diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 84db927c..effb89d2 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -21,13 +21,13 @@ struct MessageDetailView: View { @State private var viewRerenderWorkaround = false private let messageId: Int64? + private let presentKeyboardOnAppear: Bool - @State private var internalMessage: BaseMessage? - - init(viewModel: ConversationViewModel, message: Binding>) { + init(viewModel: ConversationViewModel, message: Binding>, presentKeyboardOnAppear: Bool = false) { self.viewModel = viewModel self.messageId = message.wrappedValue.value?.id self._message = message + self.presentKeyboardOnAppear = presentKeyboardOnAppear } var body: some View { @@ -48,7 +48,8 @@ struct MessageDetailView: View { course: viewModel.course, conversation: viewModel.conversation, configuration: .answerMessage(message, reloadMessage), - delegate: SendMessageViewModelDelegate(viewModel) + delegate: SendMessageViewModelDelegate(viewModel), + presentKeyboardOnAppear: presentKeyboardOnAppear ) ) } @@ -105,6 +106,7 @@ private extension MessageDetailView { HStack(spacing: .l) { MessageActions(viewModel: viewModel, message: $message, conversationPath: nil) .labelStyle(.iconOnly) + .fontWeight(.bold) } } } diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index de27aca4..8aef1be8 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -36,6 +36,9 @@ struct SendMessageView: View { } .onAppear { viewModel.performOnAppear() + if viewModel.presentKeyboardOnAppear { + isFocused = true + } } .onDisappear { viewModel.performOnDisappear() From b2f08ab7ce9bc52b04a57f98769dd25bb04fa44c Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 7 Aug 2024 22:56:06 +0200 Subject: [PATCH 37/48] `Communication`: Highlight pinned messages (#138) * Add pinned indicator to message cell * Make pinned messages stand out * Improve pressed message background --- .../Models/BaseMessage+IsContinuation.swift | 8 +++++++ .../Resources/en.lproj/Localizable.strings | 1 + .../ReactionsViewModel.swift | 8 +++---- .../Views/MessageDetailView/MessageCell.swift | 23 ++++++++++++++----- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift index 61f0f4bb..bd4da388 100644 --- a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift +++ b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift @@ -21,6 +21,14 @@ extension BaseMessage { return false } + // Don't merge pinned messages + if (message as? Message)?.displayPriority == .pinned { + return false + } + if (self as? Message)?.displayPriority == .pinned { + return false + } + return lhs < rhs.addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60)) } } diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 0134f610..6de3eb41 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -74,6 +74,7 @@ "noMessagesDescription" = "Write the first message to kickstart this conversation."; "reply" = "reply"; "new" = "New"; +"pinned" = "Pinned"; // MARK: CreateChannelView "channelNameLabel" = "Name"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift index 7d6d8be9..02c6cec2 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift @@ -86,10 +86,6 @@ class ReactionsViewModel { extension DataState: Equatable { public static func == (lhs: DataState, rhs: DataState) -> Bool { switch lhs { - case .loading: - return false - case .failure: - return false case .done(let responseLhs): switch rhs { case .done(let responseRhs): @@ -103,12 +99,16 @@ extension DataState: Equatable { hashRhs.combine(responseRhs.reactions) hashLhs.combine(responseLhs.updatedDate) hashRhs.combine(responseRhs.updatedDate) + hashLhs.combine((responseLhs as? Message)?.displayPriority) + hashRhs.combine((responseRhs as? Message)?.displayPriority) hashLhs.combine(responseLhs.content) hashRhs.combine(responseRhs.content) return hashLhs.finalize() == hashRhs.finalize() default: return false } + default: + return false } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 5de73f7a..72c8aa62 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -27,6 +27,7 @@ struct MessageCell: View { VStack(alignment: .leading, spacing: .s) { HStack { VStack(alignment: .leading, spacing: .s) { + pinnedIndicator headerIfVisible ArtemisMarkdownView(string: content) .opacity(isMessageOffline ? 0.5 : 1) @@ -35,10 +36,7 @@ struct MessageCell: View { } Spacer() } - .background { - RoundedRectangle(cornerRadius: .m) - .foregroundStyle(backgroundOnPress) - } + .background(backgroundOnPress, in: .rect(cornerRadius: .m)) .contentShape(.rect) .onTapGesture(perform: onTapPresentMessage) .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in @@ -58,7 +56,9 @@ struct MessageCell: View { swipeToReplyOverlay } .background( - useFullWidth ? .clear : Color(uiColor: .secondarySystemBackground), + useFullWidth ? + .clear : + isPinned ? .orange.opacity(0.25) : Color(uiColor: .secondarySystemBackground), in: .rect(cornerRadii: viewModel.roundedCorners) ) .padding(.top, viewModel.isHeaderVisible ? .m : 0) @@ -115,8 +115,12 @@ private extension MessageCell { message.value?.content ?? "" } + var isPinned: Bool { + (message.value as? Message)?.displayPriority == .pinned + } + var backgroundOnPress: Color { - (viewModel.isDetectingLongPress || viewModel.isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear + (viewModel.isDetectingLongPress || viewModel.isActionSheetPresented) ? Color.primary.opacity(0.1) : Color.clear } @ViewBuilder var roleBadge: some View { @@ -131,6 +135,13 @@ private extension MessageCell { } } + @ViewBuilder var pinnedIndicator: some View { + if isPinned { + Label(R.string.localizable.pinned(), systemImage: "pin") + .font(.caption) + } + } + @ViewBuilder var headerIfVisible: some View { if viewModel.isHeaderVisible { HStack(alignment: .firstTextBaseline, spacing: .m) { From e80324e1e4622a2a305a4a37dfb52aecb9fe2850 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Fri, 9 Aug 2024 00:17:25 +0200 Subject: [PATCH 38/48] `Communication`: Add resolving posts (#139) * Add resolved indicator to resolved messages * Highlight resolving messages * Allow tutors & higher to mark/unmark as resolved * Allow original message author to mark resolving * Refactor message background --- .../Models/BaseMessage+IsContinuation.swift | 8 +++ .../Resources/en.lproj/Localizable.strings | 4 ++ .../ConversationViewModel.swift | 27 ++++++++ .../ReactionsViewModel.swift | 4 ++ .../MessageActionSheet.swift | 69 +++++++++++++++++++ .../Views/MessageDetailView/MessageCell.swift | 43 ++++++++++-- .../MessageDetailView/MessageDetailView.swift | 1 + 7 files changed, 149 insertions(+), 7 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift index bd4da388..4eec7158 100644 --- a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift +++ b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift @@ -29,6 +29,14 @@ extension BaseMessage { return false } + // Don't merge resolving answers + if (message as? AnswerMessage)?.resolvesPost ?? false { + return false + } + if (self as? AnswerMessage)?.resolvesPost ?? false { + return false + } + return lhs < rhs.addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60)) } } diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 6de3eb41..3684d0c9 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -68,6 +68,8 @@ "deleteMessage" = "Delete Message"; "confirmDeletionTitle" = "Please confirm that you want to delete this post!"; "deletionErrorLabel" = "Could not delete message. Try again later."; +"markAsResolving" = "Resolves Post"; +"unmarkAsResolving" = "Doesn't Resolve Post"; // MARK: ConversationView "noMessages" = "No Messages"; @@ -75,6 +77,8 @@ "reply" = "reply"; "new" = "New"; "pinned" = "Pinned"; +"resolved" = "Resolved"; +"resolvesPost" = "Resolves Post"; // MARK: CreateChannelView "channelNameLabel" = "Name"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index ab8f53d4..c1d37b7a 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -234,6 +234,33 @@ extension ConversationViewModel { return false } } + + // MARK: Mark as Resolving + + func toggleResolving(for message: AnswerMessage) async -> Bool { + isLoading = true + + var message = message + message.resolvesPost = !(message.resolvesPost ?? false) + + let result = await messagesService.editAnswerMessage(for: course.id, answerMessage: message) + isLoading = false + switch result { + case .failure(let error): + if let apiClientError = error as? APIClientError { + let userFacingError = UserFacingError(error: apiClientError) + presentError(userFacingError: userFacingError) + } else { + let userFacingError = UserFacingError(title: error.localizedDescription) + presentError(userFacingError: userFacingError) + } + case .success: + return true + default: + break + } + return false + } } // MARK: - Fileprivate diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift index 02c6cec2..bb8a9ba6 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift @@ -101,6 +101,10 @@ extension DataState: Equatable { hashRhs.combine(responseRhs.updatedDate) hashLhs.combine((responseLhs as? Message)?.displayPriority) hashRhs.combine((responseRhs as? Message)?.displayPriority) + hashLhs.combine((responseLhs as? Message)?.resolved) + hashRhs.combine((responseRhs as? Message)?.resolved) + hashLhs.combine((responseLhs as? Message)?.answers) + hashRhs.combine((responseRhs as? Message)?.answers) hashLhs.combine(responseLhs.content) hashRhs.combine(responseRhs.content) return hashLhs.finalize() == hashRhs.finalize() diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index 60f5945e..6045f124 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -205,6 +205,55 @@ struct MessageActions: View { .presentationDetents([.height(200), .medium]) } } + + struct MarkResolvingButton: View { + @Environment(\.allowAutoDismiss) var allowDismiss + @Environment(\.dismiss) var dismiss + @EnvironmentObject var navigationController: NavigationController + @ObservedObject var viewModel: ConversationViewModel + @Binding var message: DataState + + @Environment(\.isOriginalMessageAuthor) var isOriginalMessageAuthor + + var isAbleToMarkResolving: Bool { + guard let message = message.value, message is AnswerMessage else { + return false + } + guard viewModel.conversation.baseConversation is Channel else { + return false + } + + // Author as well as Tutors and higher level can mark as resolving + if viewModel.course.isAtLeastTutorInCourse || isOriginalMessageAuthor { + return true + } + + return false + } + + var body: some View { + Group { + if isAbleToMarkResolving { + Divider() + + if (message.value as? AnswerMessage)?.resolvesPost ?? false { + Button(R.string.localizable.unmarkAsResolving(), systemImage: "xmark", action: toggleResolved) + } else { + Button(R.string.localizable.markAsResolving(), systemImage: "checkmark", action: toggleResolved) + } + } + } + } + + func toggleResolved() { + guard let message = message.value as? AnswerMessage else { return } + Task { + if await viewModel.toggleResolving(for: message) && allowDismiss { + dismiss() + } + } + } + } } struct MessageActionSheet: View { @@ -240,6 +289,9 @@ struct MessageActionSheet: View { MessageActions.EditDeleteSection(viewModel: viewModel, message: $message) .padding(.horizontal) + MessageActions.MarkResolvingButton(viewModel: viewModel, message: $message) + .padding(.horizontal) + Spacer() } .buttonStyle(.plain) @@ -342,3 +394,20 @@ extension EnvironmentValues { } } } + +// MARK: - Environment+OriginalPostAuthor + +private enum OriginalPostAuthorEnvironmentKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var isOriginalMessageAuthor: Bool { + get { + self[OriginalPostAuthorEnvironmentKey.self] + } + set { + self[OriginalPostAuthorEnvironmentKey.self] = newValue + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 72c8aa62..10704c0a 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -28,11 +28,13 @@ struct MessageCell: View { HStack { VStack(alignment: .leading, spacing: .s) { pinnedIndicator + resolvesPostIndicator headerIfVisible ArtemisMarkdownView(string: content) .opacity(isMessageOffline ? 0.5 : 1) .environment(\.openURL, OpenURLAction(handler: handle)) editedLabel + resolvedIndicator } Spacer() } @@ -55,12 +57,7 @@ struct MessageCell: View { .overlay(alignment: .trailing) { swipeToReplyOverlay } - .background( - useFullWidth ? - .clear : - isPinned ? .orange.opacity(0.25) : Color(uiColor: .secondarySystemBackground), - in: .rect(cornerRadii: viewModel.roundedCorners) - ) + .background(messageBackground, in: .rect(cornerRadii: viewModel.roundedCorners)) .padding(.top, viewModel.isHeaderVisible ? .m : 0) .id(message.value?.id.description) .padding(.horizontal, useFullWidth ? 0 : (.m + .l) / 2) @@ -119,10 +116,28 @@ private extension MessageCell { (message.value as? Message)?.displayPriority == .pinned } + var isResolved: Bool { + (message.value as? Message)?.resolved ?? false || + (message.value as? Message)?.answers?.contains { answer in + answer.resolvesPost ?? false + } ?? false + } + + var resolvesPost: Bool { + (message.value as? AnswerMessage)?.resolvesPost ?? false + } + var backgroundOnPress: Color { (viewModel.isDetectingLongPress || viewModel.isActionSheetPresented) ? Color.primary.opacity(0.1) : Color.clear } + var messageBackground: Color { + useFullWidth ? .clear : + isPinned ? .orange.opacity(0.25) : + resolvesPost ? .green.opacity(0.2) : + Color(uiColor: .secondarySystemBackground) + } + @ViewBuilder var roleBadge: some View { if let authorRole { Chip( @@ -142,6 +157,20 @@ private extension MessageCell { } } + @ViewBuilder var resolvedIndicator: some View { + if isResolved && viewModel.conversationPath != nil { + Label(R.string.localizable.resolved(), systemImage: "checkmark") + .font(.caption) + } + } + + @ViewBuilder var resolvesPostIndicator: some View { + if resolvesPost { + Label(R.string.localizable.resolvesPost(), systemImage: "checkmark") + .font(.caption) + } + } + @ViewBuilder var headerIfVisible: some View { if viewModel.isHeaderVisible { HStack(alignment: .firstTextBaseline, spacing: .m) { @@ -194,7 +223,7 @@ private extension MessageCell { @ViewBuilder var replyButtonIfAvailable: some View { if let message = message.value as? Message, let answerCount = message.answers?.count, answerCount > 0, - let conversationPath = viewModel.conversationPath { + viewModel.conversationPath != nil { Button { openThread(showErrorOnFailure: true) } label: { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index effb89d2..088c387a 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -148,6 +148,7 @@ private extension MessageDetailView { } } } + .environment(\.isOriginalMessageAuthor, message.isCurrentUserAuthor) } } From f5ce9e91204b1e8dacf909e02c1157689e8ccc86 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 10 Aug 2024 22:47:54 +0200 Subject: [PATCH 39/48] `Communication`: Improve conversation title bars (#140) * Improve conversation titlte bar * Add conversation icon to title bar * Improve title bar in Thread View * Add i icon to open conversation details * Add done button to info sheet * Replace text characters with chevrons * Add icons to buttons on info sheet * Add chevron next to conversation title --- .../ConversationInfoSheetView.swift | 20 +++++++++----- .../ConversationView/ConversationView.swift | 26 ++++++++++++++++--- .../MessageDetailView/MessageDetailView.swift | 17 +++++++++++- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift index 87185d61..a940ddd7 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift @@ -17,6 +17,7 @@ private var PAGINATION_SIZE = 20 struct ConversationInfoSheetView: View { @EnvironmentObject var navigationController: NavigationController + @Environment(\.dismiss) var dismiss @StateObject private var viewModel: ConversationInfoSheetViewModel @@ -39,6 +40,13 @@ struct ConversationInfoSheetView: View { await viewModel.loadMembers() } .navigationTitle(conversation.baseConversation.conversationName) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(R.string.localizable.done()) { + dismiss() + } + } + } .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) .loadingIndicator(isLoading: $viewModel.isLoading) } @@ -56,14 +64,14 @@ private extension ConversationInfoSheetView { Group { Section(R.string.localizable.settings()) { if viewModel.canAddUsers { - Button(R.string.localizable.addUsers()) { + Button(R.string.localizable.addUsers(), systemImage: "person.fill.badge.plus") { viewModel.isAddMemberSheetPresented = true } } if let channel = conversation.baseConversation as? Channel, channel.hasChannelModerationRights ?? false { if channel.isArchived ?? false { - Button(R.string.localizable.unarchiveChannelButtonLabel()) { + Button(R.string.localizable.unarchiveChannelButtonLabel(), systemImage: "archivebox.fill") { viewModel.isLoading = true Task { await viewModel.unarchiveChannel() @@ -72,7 +80,7 @@ private extension ConversationInfoSheetView { } .foregroundColor(.Artemis.badgeWarningColor) } else { - Button(R.string.localizable.archiveChannelButtonLabel()) { + Button(R.string.localizable.archiveChannelButtonLabel(), systemImage: "archivebox.fill") { viewModel.isLoading = true Task { await viewModel.archiveChannel() @@ -83,7 +91,7 @@ private extension ConversationInfoSheetView { } } if viewModel.canLeaveConversation { - Button(R.string.localizable.leaveConversationButtonLabel()) { + Button(R.string.localizable.leaveConversationButtonLabel(), systemImage: "rectangle.portrait.and.arrow.forward") { viewModel.isLoading = true Task { let success = await viewModel.leaveConversation() @@ -153,7 +161,7 @@ private extension ConversationInfoSheetView { if (conversation.baseConversation.numberOfMembers ?? 0) > PAGINATION_SIZE || viewModel.page > 0 { HStack(spacing: .l) { Spacer() - Text("< \(R.string.localizable.previous())") + Text("\(Image(systemName: "chevron.backward")) \(R.string.localizable.previous())") .onTapGesture { Task { await viewModel.loadPreviousMemberPage() @@ -162,7 +170,7 @@ private extension ConversationInfoSheetView { .disabled(viewModel.page == 0) .foregroundColor(viewModel.page == 0 ? .Artemis.buttonDisabledColor : .Artemis.artemisBlue) Text("\(viewModel.page + 1)") - Text("\(R.string.localizable.next()) >") + Text("\(R.string.localizable.next()) \(Image(systemName: "chevron.forward"))") .onTapGesture { Task { await viewModel.loadNextMemberPage() diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index a4cfd1b6..80cacf68 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -81,13 +81,31 @@ public struct ConversationView: View { } } .toolbar { - ToolbarItem(placement: .principal) { + ToolbarItem(placement: .topBarLeading) { Button { viewModel.isConversationInfoSheetPresented = true } label: { - Text(viewModel.conversation.baseConversation.conversationName) - .foregroundColor(.Artemis.primaryLabel) - .frame(width: UIScreen.main.bounds.size.width * 0.6) + HStack(alignment: .center, spacing: .m) { + viewModel.conversation.baseConversation.icon? + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 20) + Text(viewModel.conversation.baseConversation.conversationName) + .fontWeight(.semibold) + Image(systemName: "chevron.forward") + .font(.caption2) + .offset(x: -5, y: 1) + } + .padding(.horizontal, .m) + .foregroundStyle(Color.Artemis.primaryLabel) + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.isConversationInfoSheetPresented = true + } label: { + Image(systemName: "info.circle") } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 088c387a..a718b7d3 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -55,7 +55,22 @@ struct MessageDetailView: View { } } } - .navigationTitle(R.string.localizable.thread() + " (\(viewModel.conversation.baseConversation.conversationName))") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + VStack(alignment: .leading, spacing: 0) { + Text(R.string.localizable.thread()) + .fontWeight(.semibold) + HStack(spacing: .s) { + viewModel.conversation.baseConversation.icon? + .resizable() + .scaledToFit() + .frame(height: .m * 1.5) + Text(viewModel.conversation.baseConversation.conversationName) + } + .font(.footnote) + }.padding(.leading, .m) + } + } .task { if message.value == nil { await reloadMessage() From d1bcf50ab24fcfe66be78981f131d210e18ea44d Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sun, 11 Aug 2024 01:58:55 +0200 Subject: [PATCH 40/48] `Communication`: Refactor Swipe to Reply (#141) --- .../MessageCellModel.swift | 36 +++-------- .../Views/MessageDetailView/MessageCell.swift | 21 +------ .../MessageDetailView/SwipeToReply.swift | 61 +++++++++++++++++++ 3 files changed, 71 insertions(+), 47 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift index 6bf25202..6b335afd 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel.swift @@ -23,7 +23,6 @@ final class MessageCellModel { var isActionSheetPresented = false var isDetectingLongPress = false - var swipeToReplyState = SwipeToReplyState() private let messagesService: MessagesService private let userSession: UserSession @@ -64,32 +63,6 @@ extension MessageCellModel { return .init(topLeading: top, bottomLeading: bottom, bottomTrailing: bottom, topTrailing: top) } - func swipeToReplyGesture(openThread: @escaping () -> Void) -> some Gesture { - DragGesture(minimumDistance: 20) - .onChanged { value in - // No swiping in Thread View - guard self.conversationPath != nil else { return } - - // Only allow swipe to the left - let distance = min(value.translation.width, 0) - - self.swipeToReplyState.update(with: distance) - } - .onEnded { _ in - if self.swipeToReplyState.swiped { - openThread() - } else { - withAnimation(.easeInOut(duration: 0.2)) { - self.resetSwipeToReply() - } - } - } - } - - func resetSwipeToReply() { - swipeToReplyState = .init() - } - // MARK: Navigation func getOneToOneChatOrCreate(login: String) async -> Conversation? { @@ -141,4 +114,13 @@ class SwipeToReplyState { UIImpactFeedbackGenerator(style: .soft).impactOccurred() } } + + /// Sets all values back to default + func reset() { + swiped = false + overlayOffset = 100 + overlayOpacity = 0 + overlayScale = 0 + messageBlur = 0 + } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 10704c0a..36af6d0e 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -52,16 +52,11 @@ struct MessageCell: View { .padding(.horizontal, .m) .padding(viewModel.isHeaderVisible ? .vertical : .bottom, useFullWidth ? 0 : .m) .contentShape(.rect) - .gesture(viewModel.swipeToReplyGesture(openThread: onSwipePresentMessage)) - .blur(radius: viewModel.swipeToReplyState.messageBlur) - .overlay(alignment: .trailing) { - swipeToReplyOverlay - } + .modifier(SwipeToReply(enabled: viewModel.conversationPath != nil, onSwipe: onSwipePresentMessage)) .background(messageBackground, in: .rect(cornerRadii: viewModel.roundedCorners)) .padding(.top, viewModel.isHeaderVisible ? .m : 0) .id(message.value?.id.description) .padding(.horizontal, useFullWidth ? 0 : (.m + .l) / 2) - .onDisappear(perform: viewModel.resetSwipeToReply) .sheet(isPresented: $viewModel.isActionSheetPresented) { MessageActionSheet( viewModel: conversationViewModel, @@ -236,20 +231,6 @@ private extension MessageCell { } } - @ViewBuilder var swipeToReplyOverlay: some View { - Image(systemName: "arrowshape.turn.up.left.circle.fill") - .resizable() - .scaledToFit() - .frame(width: 40) - .foregroundStyle(viewModel.swipeToReplyState.swiped ? .blue : .gray) - .padding(.horizontal) - .offset(x: viewModel.swipeToReplyState.overlayOffset) - .scaleEffect(x: viewModel.swipeToReplyState.overlayScale, y: viewModel.swipeToReplyState.overlayScale, anchor: .trailing) - .opacity(viewModel.swipeToReplyState.overlayOpacity) - .animation(.easeInOut(duration: 0.1), value: viewModel.swipeToReplyState.swiped) - .accessibilityHidden(true) - } - func openThread(showErrorOnFailure: Bool = true, presentKeyboard: Bool = false) { // We cannot navigate to details if conversation path is nil, e.g. in the message detail view. if let conversationPath = viewModel.conversationPath, diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift new file mode 100644 index 00000000..a1bed1a5 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/SwipeToReply.swift @@ -0,0 +1,61 @@ +// +// SwipeToReply.swift +// +// +// Created by Anian Schleyer on 10.08.24. +// + +import SwiftUI + +struct SwipeToReply: ViewModifier { + @State private var state = SwipeToReplyState() + + let enabled: Bool + let onSwipe: () -> Void + + func body(content: Content) -> some View { + content + .gesture(swipeToReplyGesture) + .blur(radius: state.messageBlur) + .overlay(alignment: .trailing) { + swipeToReplyOverlay + } + .onDisappear(perform: state.reset) + } + + @ViewBuilder var swipeToReplyOverlay: some View { + Image(systemName: "arrowshape.turn.up.left.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 40) + .foregroundStyle(state.swiped ? .blue : .gray) + .padding(.horizontal) + .offset(x: state.overlayOffset) + .scaleEffect(x: state.overlayScale, y: state.overlayScale, anchor: .trailing) + .opacity(state.overlayOpacity) + .animation(.easeInOut(duration: 0.1), value: state.swiped) + .accessibilityHidden(true) + } + + var swipeToReplyGesture: some Gesture { + DragGesture(minimumDistance: 20) + .onChanged { value in + // No swiping in Thread View + guard enabled else { return } + + // Only allow swipe to the left + let distance = min(value.translation.width, 0) + + self.state.update(with: distance) + } + .onEnded { _ in + if self.state.swiped { + onSwipe() + } else { + withAnimation(.easeInOut(duration: 0.2)) { + self.state.reset() + } + } + } + } +} From f226e350c2fdb95f56bb93d2933a32d723d48fa4 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Wed, 14 Aug 2024 19:12:04 +0200 Subject: [PATCH 41/48] `Communication`: Add pin message button (#143) * Add method for pinning to MessageService * Add pin button * Add pin button to quick action bar * Improve sheet content alignment * Use latest core modules version --- .../xcshareddata/swiftpm/Package.resolved | 4 +- ArtemisKit/Package.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 2 + .../MessagesService/MessagesService.swift | 5 ++ .../MessagesService/MessagesServiceImpl.swift | 31 +++++++++ .../MessagesService/MessagesServiceStub.swift | 4 ++ .../ConversationViewModel.swift | 20 +++++- .../MessageActionSheet.swift | 69 +++++++++++++++++-- .../MessageDetailView/MessageDetailView.swift | 1 + 9 files changed, 129 insertions(+), 9 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 141b562c..3372b58b 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "ad51e949c738b9a9d242d436e49d3414b8281b06", - "version" : "14.0.0" + "revision" : "9b7ed6485832f30f7e8e6c0dbffdc5a6592b026a", + "version" : "14.0.1" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 72ac4505..ac99309d 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "14.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.0.1")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 3684d0c9..15253472 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -70,6 +70,8 @@ "deletionErrorLabel" = "Could not delete message. Try again later."; "markAsResolving" = "Resolves Post"; "unmarkAsResolving" = "Doesn't Resolve Post"; +"pinMessage" = "Pin Message"; +"unpinMessage" = "Unpin Message"; // MARK: ConversationView "noMessages" = "No Messages"; diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index ba381e58..957a30c4 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -58,6 +58,11 @@ protocol MessagesService { */ func deleteAnswerMessage(for courseId: Int, anserMessageId: Int64) async -> NetworkResponse + /** + * Perform a put request to update a message's display priority in a specific course to the server. + */ + func updateMessageDisplayPriority(for courseId: Int64, messageId: Int64, displayPriority: DisplayPriority) async -> DataState + /** * Perform a put request to update 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 728c890b..d2747063 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -311,6 +311,37 @@ struct MessagesServiceImpl: MessagesService { } } + struct UpdateDisplayPriorityRequest: APIRequest { + typealias Response = Message + + let courseId: Int64 + let messageId: Int64 + let displayPriority: DisplayPriority + + var method: HTTPMethod { + return .put + } + + var resourceName: String { + return "api/courses/\(courseId)/messages/\(messageId)/display-priority" + } + + var params: [URLQueryItem] { + [.init(name: "displayPriority", value: displayPriority.rawValue)] + } + } + + func updateMessageDisplayPriority(for courseId: Int64, messageId: Int64, displayPriority: DisplayPriority) async -> DataState { + let result = await client.sendRequest(UpdateDisplayPriorityRequest(courseId: courseId, messageId: messageId, displayPriority: displayPriority)) + + switch result { + case .success((let message, _)): + return .done(response: message) + case let .failure(error): + return .failure(error: .init(error: error)) + } + } + struct EditAnswerMessageRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index c31a16a4..15219ef6 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -133,6 +133,10 @@ extension MessagesServiceStub: MessagesService { .loading } + func updateMessageDisplayPriority(for courseId: Int64, messageId: Int64, displayPriority: DisplayPriority) async -> DataState { + .loading + } + func editAnswerMessage(for courseId: Int, answerMessage: AnswerMessage) async -> NetworkResponse { .loading } diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index c1d37b7a..fc2e863c 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -235,7 +235,7 @@ extension ConversationViewModel { } } - // MARK: Mark as Resolving + // MARK: Mark as Resolving, Pin func toggleResolving(for message: AnswerMessage) async -> Bool { isLoading = true @@ -261,6 +261,24 @@ extension ConversationViewModel { } return false } + + func togglePinned(for message: Message) async -> DataState { + isLoading = true + + let isPinned = message.displayPriority == .pinned + + let result = await messagesService.updateMessageDisplayPriority(for: Int64(course.id), messageId: message.id, displayPriority: isPinned ? .noInformation : .pinned) + isLoading = false + switch result { + case .failure(let error): + presentError(userFacingError: error) + return .failure(error: error) + case .done(let message): + return .done(response: message) + case .loading: + return .loading + } + } } // MARK: - Fileprivate diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index 6045f124..5291fb81 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -22,6 +22,7 @@ struct MessageActions: View { Group { ReplyInThreadButton(viewModel: viewModel, message: $message, conversationPath: conversationPath) CopyTextButton(message: $message) + PinButton(viewModel: viewModel, message: $message) EditDeleteSection(viewModel: viewModel, message: $message) } .environment(\.allowAutoDismiss, false) @@ -206,6 +207,63 @@ struct MessageActions: View { } } + struct PinButton: View { + @Environment(\.allowAutoDismiss) var allowDismiss + @Environment(\.dismiss) var dismiss + @EnvironmentObject var navigationController: NavigationController + @ObservedObject var viewModel: ConversationViewModel + @Binding var message: DataState + + var isAbleToPin: Bool { + guard let message = message.value, message is Message else { + return false + } + + // Channel: Only Moderators can pin + let isModerator = (viewModel.conversation.baseConversation as? Channel)?.isChannelModerator ?? false + if viewModel.conversation.baseConversation is Channel && !isModerator { + return false + } + + // Group Chat: Only Creator can pin + let isCreator = viewModel.conversation.baseConversation.isCreator ?? false + if viewModel.conversation.baseConversation is GroupChat && !isCreator { + return false + } + + return true + } + + var body: some View { + Group { + if isAbleToPin { + Divider() + + if (message.value as? Message)?.displayPriority == .pinned { + Button(R.string.localizable.unpinMessage(), systemImage: "pin.slash", action: togglePinned) + } else { + Button(R.string.localizable.pinMessage(), systemImage: "pin", action: togglePinned) + } + } + } + } + + func togglePinned() { + guard let message = message.value as? Message else { return } + Task { + var result = await viewModel.togglePinned(for: message) + let oldRole = message.authorRole + if var newMessageResult = result.value as? Message { + newMessageResult.authorRole = oldRole + self.$message.wrappedValue = .done(response: newMessageResult) + if allowDismiss { + dismiss() + } + } + } + } + } + struct MarkResolvingButton: View { @Environment(\.allowAutoDismiss) var allowDismiss @Environment(\.dismiss) var dismiss @@ -270,7 +328,7 @@ struct MessageActionSheet: View { } var body: some View { - HStack { + ScrollView { VStack(alignment: .leading, spacing: .l) { HStack(spacing: .m) { EmojiTextButton(viewModel: reactionsViewModel, emoji: "😂") @@ -286,23 +344,24 @@ struct MessageActionSheet: View { MessageActions.CopyTextButton(message: $message) .padding(.horizontal) - MessageActions.EditDeleteSection(viewModel: viewModel, message: $message) + MessageActions.PinButton(viewModel: viewModel, message: $message) .padding(.horizontal) MessageActions.MarkResolvingButton(viewModel: viewModel, message: $message) .padding(.horizontal) + MessageActions.EditDeleteSection(viewModel: viewModel, message: $message) + .padding(.horizontal) + Spacer() } .buttonStyle(.plain) .font(.headline) .symbolVariant(.fill) .imageScale(.large) - Spacer() } .fontWeight(.bold) - .padding(.vertical, .xl) - .frame(maxHeight: .infinity, alignment: .top) + .contentMargins(.vertical, .l) .loadingIndicator(isLoading: $viewModel.isLoading) .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index a718b7d3..6054606a 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -124,6 +124,7 @@ private extension MessageDetailView { .fontWeight(.bold) } } + .loadingIndicator(isLoading: $viewModel.isLoading) } .padding(.horizontal) Divider() From a48c18ab0383a079a6ee1824f863697c50be6fd0 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 17 Aug 2024 00:31:07 +0200 Subject: [PATCH 42/48] `Communication`,`Lectures`: Add Navigation to associated Channels (#144) --- .../LectureTab/LectureDetailView.swift | 47 +++++++++++++++++++ .../LectureTab/LectureDetailViewModel.swift | 8 ++++ .../Resources/en.lproj/Localizable.strings | 1 + .../LectureService/LectureService.swift | 1 + .../LectureService/LectureServiceImpl.swift | 26 ++++++++++ 5 files changed, 83 insertions(+) diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index a7318da3..de8b0969 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -10,6 +10,7 @@ import Common import SharedModels import ArtemisMarkdown import DesignLibrary +import Navigation public struct LectureDetailView: View { @@ -58,11 +59,23 @@ public struct LectureDetailView: View { AttachmentCell(attachment: attachment) } } + if let channel = viewModel.channel.value { + Text(R.string.localizable.discussion()) + .font(.headline) + ChannelCell(courseId: viewModel.courseId, channel: channel) + } Spacer() } Spacer() }.padding(.l) } + .onChange(of: viewModel.course.value, initial: true) { _, newValue in + if newValue != nil { + Task { + await viewModel.loadAssociatedChannel() + } + } + } } .navigationTitle(viewModel.lecture.value?.title ?? R.string.localizable.loading()) .navigationBarTitleDisplayMode(.inline) @@ -73,6 +86,40 @@ public struct LectureDetailView: View { } } +private struct ChannelCell: View { + + @EnvironmentObject var navigationController: NavigationController + let courseId: Int + let channel: Channel + + var body: some View { + Button { + navigationController.path.append(ConversationPath(id: channel.id, coursePath: CoursePath(id: courseId))) + } label: { + HStack { + VStack(alignment: .leading, spacing: .l) { + Label { + Text(channel.conversationName) + } icon: { + channel.icon + } + .font(.title3) + + if let description = channel.description { + Text(description) + } + } + .foregroundColor(Color.Artemis.primaryLabel) + Spacer() + Image(systemName: "chevron.forward") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.l) + .cardModifier(backgroundColor: .Artemis.exerciseCardBackgroundColor, cornerRadius: .m) + } + } +} + private struct AttachmentCell: View { let attachment: Attachment diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift index e9497645..d698a84b 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift @@ -14,6 +14,7 @@ class LectureDetailViewModel: BaseViewModel { @Published var lecture: DataState = .loading @Published var course: DataState = .loading + @Published var channel: DataState = .loading let lectureId: Int let courseId: Int @@ -55,6 +56,13 @@ class LectureDetailViewModel: BaseViewModel { } } + func loadAssociatedChannel() async { + // We only have a channel if communication is enabled + guard course.value?.courseInformationSharingConfiguration != .disabled else { return } + + channel = await LectureServiceFactory.shared.getAssociatedChannel(for: lectureId, in: courseId) + } + func updateLectureUnitCompletion(lectureUnit: LectureUnit, completed: Bool) async -> LectureUnit { let result = await LectureServiceFactory.shared.updateLectureUnitCompletion(lectureId: lectureId, lectureUnitId: lectureUnit.id, diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index dcf47dce..db32c95b 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -96,3 +96,4 @@ "lectureUnits" = "Lecture Units"; "lecturesGroupTitle" = "%s (Lectures: %i)"; "attachments" = "Attachments"; +"discussion" = "Discussion"; diff --git a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift index 3d013d2c..277fb44c 100644 --- a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift +++ b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift @@ -13,6 +13,7 @@ protocol LectureService { func getLectureDetails(lectureId: Int) async -> DataState func getAttachmentFile(link: String) async -> DataState func updateLectureUnitCompletion(lectureId: Int, lectureUnitId: Int64, completed: Bool) async -> NetworkResponse + func getAssociatedChannel(for lectureId: Int, in courseId: Int) async -> DataState } enum LectureServiceFactory { diff --git a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift index f15d3626..2f01083d 100644 --- a/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift +++ b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift @@ -84,4 +84,30 @@ class LectureServiceImpl: LectureService { return .failure(error: UserFacingError(title: error.localizedDescription)) } } + + struct GetLectureChannelRequest: APIRequest { + typealias Response = Channel + + let courseId: Int + let lectureId: Int + + var method: HTTPMethod { + .get + } + + var resourceName: String { + "api/courses/\(courseId)/lectures/\(lectureId)/channel" + } + } + + func getAssociatedChannel(for lectureId: Int, in courseId: Int) async -> DataState { + let result = await client.sendRequest(GetLectureChannelRequest(courseId: courseId, lectureId: lectureId)) + + switch result { + case let .success((response, _)): + return .done(response: response) + case let .failure(error): + return .failure(error: UserFacingError(error: error)) + } + } } From 6e7a5748f265f5e2d8ba2cddcc079d2c36a4f7af Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sun, 18 Aug 2024 18:21:15 +0200 Subject: [PATCH 43/48] `Communication`,`Exercises`: Navigate to associated channels (#146) --- .../ExerciseTab/ExerciseDetailView.swift | 14 +++++++ .../ExerciseTab/ExerciseDetailViewModel.swift | 5 +++ .../ExerciseChannelService.swift | 18 ++++++++ .../ExerciseChannelServiceImpl.swift | 41 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelService.swift create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelServiceImpl.swift diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index ac23ecee..ce06dec7 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -47,6 +47,7 @@ public struct ExerciseDetailView: View { } .task { await viewModel.loadExercise() + await viewModel.loadAssociatedChannel() } .refreshable { await viewModel.refreshExercise() @@ -218,6 +219,19 @@ private extension ExerciseDetailView { } } } + + if let channel = viewModel.channel.value { + Divider() + .frame(height: 1.0) + .overlay(Color.Artemis.artemisBlue) + + ExerciseDetailCell(descriptionText: R.string.localizable.discussion() + ":") { + NavigationLink(value: ConversationPath(conversation: .channel(conversation: channel), + coursePath: .init(id: viewModel.courseId))) { + Text("\(channel.conversationName) \(Image(systemName: "chevron.forward"))") + } + } + } } .background { RoundedRectangle(cornerRadius: 3.0) diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift index 09a2bdac..d1fa4a5e 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift @@ -17,6 +17,7 @@ final class ExerciseDetailViewModel { let exerciseId: Int var exercise: DataState + var channel: DataState = .loading var isFeedbackPresented = false var latestResultId: Int? @@ -77,6 +78,10 @@ final class ExerciseDetailViewModel { webViewId = UUID() } + func loadAssociatedChannel() async { + channel = await ExerciseChannelServiceFactory.shared.getAssociatedChannel(for: exerciseId, in: courseId) + } + private func setParticipationAndResultId(from exercise: Exercise) { isWebViewLoading = true diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelService.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelService.swift new file mode 100644 index 00000000..bff4977d --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelService.swift @@ -0,0 +1,18 @@ +// +// ExerciseChannelService.swift +// +// +// Created by Anian Schleyer on 18.08.24. +// + +import Foundation +import Common +import SharedModels + +protocol ExerciseChannelService { + func getAssociatedChannel(for exerciseId: Int, in courseId: Int) async -> DataState +} + +enum ExerciseChannelServiceFactory { + static let shared: ExerciseChannelService = ExerciseChannelServiceImpl() +} diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelServiceImpl.swift new file mode 100644 index 00000000..07a73f1a --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseChannelServiceImpl.swift @@ -0,0 +1,41 @@ +// +// ExerciseChannelServiceImpl.swift +// +// +// Created by Anian Schleyer on 18.08.24. +// + +import APIClient +import Common +import SharedModels + +struct ExerciseChannelServiceImpl: ExerciseChannelService { + + let client = APIClient() + + struct GetExerciseChannelRequest: APIRequest { + typealias Response = Channel + + let courseId: Int + let exerciseId: Int + + var method: HTTPMethod { + .get + } + + var resourceName: String { + "api/courses/\(courseId)/exercises/\(exerciseId)/channel" + } + } + + func getAssociatedChannel(for exerciseId: Int, in courseId: Int) async -> DataState { + let result = await client.sendRequest(GetExerciseChannelRequest(courseId: courseId, exerciseId: exerciseId)) + + switch result { + case let .success((response, _)): + return .done(response: response) + case let .failure(error): + return .failure(error: UserFacingError(error: error)) + } + } +} From 9bd90452e8fed30d3a5954cc61cda6e8fb431f4c Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:29:33 +0200 Subject: [PATCH 44/48] `Communication`: Add favorite and start DM button to info sheet (#145) * Add mark favorite button to Info Sheet * Add DM Button for users on Info sheet * Add swipe action for remove user * Hide DM Button for OneToOneChats --- .../Resources/en.lproj/Localizable.strings | 3 + .../ConversationInfoSheetViewModel.swift | 45 ++++++++++++++ .../ConversationInfoSheetView.swift | 59 ++++++++++++++----- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 15253472..6edc69ec 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -135,6 +135,9 @@ "moreInfoLabel" = "More info"; "createdByLabel" = "Created by: %s"; "createdOnLabel" = "Created on: %s"; +"addFavorite" = "Add to Favorites"; +"removeFavorite" = "Remove from Favorites"; +"sendMessage" = "Send Message"; // MARK: Errors "detailViewCantBeOpened" = "Detail View can not be opened. Please try again later."; diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift index a426a8b8..f327ff5c 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift @@ -10,6 +10,8 @@ import Common import UserStore import SharedModels import SwiftUI +import APIClient +import Navigation @MainActor class ConversationInfoSheetViewModel: BaseViewModel { @@ -226,4 +228,47 @@ extension ConversationInfoSheetViewModel { conversation = response } } + + func setIsConversationFavorite(isFavorite: Bool) async { + isLoading = true + let result = await messagesService.updateIsConversationFavorite(for: course.id, and: conversation.id, isFavorite: isFavorite) + isLoading = false + switch result { + case .notStarted, .loading: + break + case .success: + toggleChannelIsFavorite() + case .failure(let error): + if let apiClientError = error as? APIClientError { + presentError(userFacingError: UserFacingError(error: apiClientError)) + } else { + presentError(userFacingError: UserFacingError(title: error.localizedDescription)) + } + } + } + + private func toggleChannelIsFavorite() { + if var convo = conversation.baseConversation as? Channel { + convo.isFavorite?.toggle() + conversation = .channel(conversation: convo) + } else if var convo = conversation.baseConversation as? GroupChat { + convo.isFavorite?.toggle() + conversation = .groupChat(conversation: convo) + } else if var convo = conversation.baseConversation as? OneToOneChat { + convo.isFavorite?.toggle() + conversation = .oneToOneChat(conversation: convo) + } + } + + func sendMessageToUser(with login: String, navigationController: NavigationController, completion: @escaping () -> Void) { + isLoading = true + Task { + let messageCellModel = MessageCellModel(course: course, conversationPath: nil, isHeaderVisible: false, roundBottomCorners: false, retryButtonAction: {}) + if let conversation = await messageCellModel.getOneToOneChatOrCreate(login: login) { + navigationController.path.append(ConversationPath(conversation: conversation, coursePath: CoursePath(course: course))) + completion() + } + isLoading = false + } + } } diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift index a940ddd7..014dfa55 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift @@ -68,6 +68,16 @@ private extension ConversationInfoSheetView { viewModel.isAddMemberSheetPresented = true } } + + let isFavorite = conversation.baseConversation.isFavorite ?? false + Button(isFavorite ? R.string.localizable.removeFavorite() : R.string.localizable.addFavorite(), systemImage: "heart") { + Task { + await viewModel.setIsConversationFavorite(isFavorite: !isFavorite) + } + } + .symbolVariant(isFavorite ? .slash.fill : .fill) + .foregroundStyle(.orange) + if let channel = conversation.baseConversation as? Channel, channel.hasChannelModerationRights ?? false { if channel.isArchived ?? false { @@ -126,24 +136,29 @@ private extension ConversationInfoSheetView { } content: { members in ForEach(members, id: \.id) { member in if let name = member.name { - HStack { - Text(name) - Spacer() - if UserSessionFactory.shared.user?.login == member.login { - Chip(text: R.string.localizable.youLabel(), backgroundColor: .Artemis.artemisBlue) - } - } - .contextMenu { - if UserSessionFactory.shared.user?.login != member.login, - viewModel.canRemoveUsers { - Button(R.string.localizable.removeUserButtonLabel()) { - viewModel.isLoading = true - Task { - await viewModel.removeMemberFromConversation(member: member) - viewModel.isLoading = false + Menu { + if let login = member.login, + !(conversation.baseConversation is OneToOneChat) { + Button(R.string.localizable.sendMessage(), systemImage: "bubble.left.fill") { + viewModel.sendMessageToUser(with: login, navigationController: navigationController) { + dismiss() } } } + Divider() + removeUserButton(member: member) + } label: { + HStack { + Text(name) + Spacer() + if UserSessionFactory.shared.user?.login == member.login { + Chip(text: R.string.localizable.youLabel(), backgroundColor: .Artemis.artemisBlue) + } + } + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing) { + removeUserButton(member: member) } } } @@ -156,6 +171,20 @@ private extension ConversationInfoSheetView { } } + @ViewBuilder + func removeUserButton(member: ConversationUser) -> some View { + if UserSessionFactory.shared.user?.login != member.login, + viewModel.canRemoveUsers { + Button(R.string.localizable.removeUserButtonLabel(), systemImage: "person.badge.minus", role: .destructive) { + viewModel.isLoading = true + Task { + await viewModel.removeMemberFromConversation(member: member) + viewModel.isLoading = false + } + } + } + } + var pageActions: some View { Group { if (conversation.baseConversation.numberOfMembers ?? 0) > PAGINATION_SIZE || viewModel.page > 0 { From b5fa936c2be6a982a0664cce0bdcf3e0034ed019 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:38:36 +0200 Subject: [PATCH 45/48] `Communication`: Improve Unread Indicators (#136) * Add new unread indicator for disclosure group * Use Label for Conversation Row * Remove unused parameter from DisclosureLabel * Replace Channel row unread indicator --- .../ConversationRow/ConversationRow.swift | 48 +++++++++++++++---- .../MessagesAvailableView.swift | 30 ++++++++---- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index a721f207..49fba182 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -25,17 +25,23 @@ struct ConversationRow: View { } } label: { HStack { - if let icon = conversation.icon { - icon - .resizable() - .scaledToFit() - .frame(width: .extraSmallImage, height: .extraSmallImage) + Label { + HStack(alignment: .firstTextBaseline) { + Text(conversation.conversationName) + Spacer() + if let unreadCount = conversation.unreadMessagesCount, unreadCount > 0 { + Text(unreadCount, format: .number.notation(.compactName)) + .font(.footnote) + .foregroundStyle(.white) + .padding(.vertical, .s) + .padding(.horizontal, .m) + .background(Color.Artemis.artemisBlue, in: .capsule) + } + } + } icon: { + conversationIcon } - Text(conversation.conversationName) Spacer() - if let unreadCount = conversation.unreadMessagesCount { - Badge(count: unreadCount) - } Menu { contextMenuItems } label: { @@ -49,6 +55,7 @@ struct ConversationRow: View { } } .foregroundStyle((conversation.isMuted ?? false) ? .secondary : .primary) + .listRowInsets(EdgeInsets(top: 0, leading: .s * -1, bottom: 0, trailing: .m)) .swipeActions(edge: .leading) { favoriteButton } @@ -58,6 +65,27 @@ struct ConversationRow: View { } } +private extension ConversationRow { + @ViewBuilder var conversationIcon: some View { + if let icon = conversation.icon { + icon + .resizable() + .scaledToFit() + .frame(height: .extraSmallImage) + .frame(maxWidth: .infinity, alignment: .trailing) + .overlay(alignment: .topTrailing) { + if let unreadCount = conversation.unreadMessagesCount, unreadCount > 0 { + Circle() + .stroke(.background, lineWidth: .xs) + .fill(Color.Artemis.artemisBlue) + .frame(width: .m, height: .m) + .offset(x: .s, y: .xs * -1) + } + } + } + } +} + private extension ConversationRow { @ViewBuilder var favoriteButton: some View { let isFavorite = conversation.isFavorite ?? false @@ -86,7 +114,7 @@ private extension ConversationRow { } }.tint(.indigo) } - + @ViewBuilder var contextMenuItems: some View { favoriteButton hideAndMuteButtons diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 3f9b2fc7..c19fcdf9 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -289,7 +289,6 @@ private struct MixedMessageSection: View { } } label: { SectionDisclosureLabel( - viewModel: viewModel, sectionTitle: sectionTitle, sectionIconName: sectionIconName, sectionUnreadCount: sectionUnreadCount, @@ -303,8 +302,6 @@ private struct MixedMessageSection: View { private struct SectionDisclosureLabel: View { - @ObservedObject var viewModel: MessagesAvailableViewModel - let sectionTitle: String let sectionIconName: String let sectionUnreadCount: Int @@ -312,14 +309,30 @@ private struct SectionDisclosureLabel: View { var body: some View { HStack { - Label(sectionTitle, systemImage: sectionIconName) - .font(.headline) - .foregroundStyle(.primary) + Label { + Text(sectionTitle) + } icon: { + Image(systemName: sectionIconName) + .overlay(alignment: .top) { + if isUnreadCountVisible && sectionUnreadCount > 0 { + Circle() + .stroke(.background, lineWidth: .s) + .fill(Color.Artemis.artemisBlue) + .frame(width: .m * 1.5, height: .m * 1.5) + .frame(width: 30, alignment: .trailing) + .offset(x: .s, y: .s * -1) + } + } + } Spacer() - if isUnreadCountVisible { - Badge(count: sectionUnreadCount) + if isUnreadCountVisible && sectionUnreadCount > 0 { + Text(sectionUnreadCount, format: .number.notation(.compactName)) + .font(.body) + .foregroundStyle(.secondary) } } + .font(.headline) + .foregroundStyle(.primary) .padding(.vertical, .m) } } @@ -373,7 +386,6 @@ private struct MessageSection: View { } } label: { SectionDisclosureLabel( - viewModel: viewModel, sectionTitle: sectionTitle, sectionIconName: sectionIconName, sectionUnreadCount: sectionUnreadCount, From a0bb3d83c73b7b231bda56130d64a79ab9b97c13 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:50:25 +0200 Subject: [PATCH 46/48] `Communication`: Change Channel NavLink Title to Communication (#148) --- .../Sources/CourseView/ExerciseTab/ExerciseDetailView.swift | 2 +- .../Sources/CourseView/LectureTab/LectureDetailView.swift | 2 +- .../Sources/CourseView/Resources/en.lproj/Localizable.strings | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index ce06dec7..08a46293 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -225,7 +225,7 @@ private extension ExerciseDetailView { .frame(height: 1.0) .overlay(Color.Artemis.artemisBlue) - ExerciseDetailCell(descriptionText: R.string.localizable.discussion() + ":") { + ExerciseDetailCell(descriptionText: R.string.localizable.communication() + ":") { NavigationLink(value: ConversationPath(conversation: .channel(conversation: channel), coursePath: .init(id: viewModel.courseId))) { Text("\(channel.conversationName) \(Image(systemName: "chevron.forward"))") diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index de8b0969..1e27feab 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -60,7 +60,7 @@ public struct LectureDetailView: View { } } if let channel = viewModel.channel.value { - Text(R.string.localizable.discussion()) + Text(R.string.localizable.communication()) .font(.headline) ChannelCell(courseId: viewModel.courseId, channel: channel) } diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index db32c95b..0f4436e0 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -96,4 +96,4 @@ "lectureUnits" = "Lecture Units"; "lecturesGroupTitle" = "%s (Lectures: %i)"; "attachments" = "Attachments"; -"discussion" = "Discussion"; +"communication" = "Communication"; From 352da8325f88b59a3ac25921f3bbcf072dfc2511 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 20 Aug 2024 20:37:22 +0200 Subject: [PATCH 47/48] `Notifications`: In-app notifications not navigating to correct targets (#147) * Correct target names in Deeplink Handlers * Bump Core Modules version --- .../xcshareddata/swiftpm/Package.resolved | 16 ++++++++-------- ArtemisKit/Package.swift | 2 +- .../Deeplinks/Handlers/MessageHandler.swift | 2 +- .../Deeplinks/Handlers/MessagesHandler.swift | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3372b58b..e4f53808 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "9b7ed6485832f30f7e8e6c0dbffdc5a6592b026a", - "version" : "14.0.1" + "revision" : "4ddccb661a2a9c01972d97e738872e1dcc8b68f2", + "version" : "14.0.2" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", - "version" : "1.8.2" + "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", + "version" : "1.8.3" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "9a8119b37e09a770367eeb26e05267c75d854053", - "version" : "2.3.1" + "revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc", + "version" : "2.4.0" } }, { @@ -131,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index ac99309d..83b0678b 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "14.0.1")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.0.2")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift index 5dc28a06..3afb5f1a 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift @@ -16,7 +16,7 @@ struct MessageHandler: Deeplink { guard let indexOfCourseId = url.pathComponents.firstIndex(where: { $0 == "courses" }), url.pathComponents.count > indexOfCourseId + 1, let courseId = Int(url.pathComponents[indexOfCourseId + 1]), - url.pathComponents.contains("messages"), + url.pathComponents.contains("communication"), let urlComponent = URLComponents(string: url.absoluteString), let conversationIdString = urlComponent.queryItems?.first(where: { $0.name == "conversationId" })?.value, let conversationId = Int64(conversationIdString) else { return nil } diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift index 24b8ba36..234dd015 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift @@ -15,7 +15,7 @@ struct MessagesHandler: Deeplink { guard let indexOfCourseId = url.pathComponents.firstIndex(where: { $0 == "courses" }), url.pathComponents.count > indexOfCourseId + 1, let courseId = Int(url.pathComponents[indexOfCourseId + 1]), - url.pathComponents.contains("messages") else { return nil } + url.pathComponents.contains("communication") else { return nil } return MessagesHandler(courseId: courseId) } From ba0442529d4f44e10d07ef264788a877d51d0953 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Thu, 22 Aug 2024 01:14:42 +0200 Subject: [PATCH 48/48] `Notifications`: Fix wrong content in notifications and add missing targets (#150) * Use new version of Core Modules --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- ArtemisKit/Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e4f53808..5e5bbd2f 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "4ddccb661a2a9c01972d97e738872e1dcc8b68f2", - "version" : "14.0.2" + "revision" : "b6456ffac746054f34e53a3ea42987b3c06e7c70", + "version" : "14.0.3" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 83b0678b..c043d496 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -22,7 +22,7 @@ let package = Package( .package(url: "https://github.com/daltoniam/Starscream.git", exact: "4.0.4"), .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: "14.0.2")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.0.3")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [