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/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e9942512..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" : "9c70eae3336c21f9de1e84ae7d25134d019b4dac", - "version" : "11.0.0" + "revision" : "ce0d4e6e74cbb9c55e9dbc8f9ec2d15a8bd1c233", + "version" : "13.2.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/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/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 diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index ec1d6995..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: "11.0.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/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/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) 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/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 a6307dd2..ac23ecee 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -5,226 +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: UserSession.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: UserSession.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: webViewContentJS) - .frame(height: webViewHeight) - .allowsHitTesting(false) - .loadingIndicator(isLoading: $isWebViewLoading) - .id(webViewId) + feedback(exercise: exercise) + hint + score(exercise: exercise) + detail(exercise: exercise) + problem } } .toolbar { @@ -243,54 +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)!))) + } - private func loadExercise() async { - if let exercise = exercise.value { - setParticipationAndResultId(from: exercise) - } else { - await refreshExercise() + 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 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: UserSession.shared.institution?.baseURL)!) + .padding(.horizontal, .m) } - /// 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"); + // 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) + } + } + } } - document.querySelector(".instructions__content").scrollHeight - """ + .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 { @@ -338,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) } @@ -356,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) } @@ -373,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) } @@ -390,7 +364,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/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/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index a4c69f56..d930e026 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -137,58 +137,72 @@ 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, spacing: 0) { + 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(.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/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))) } diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 633a409e..dcf47dce 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -46,12 +46,12 @@ // MARK: - ExerciseDetailView "exerciseDetails" = "Exercise details"; -"dueDate" = "Due Date: %s"; "noDueDate" = "No due date"; "points" = "Points: %s of %s"; "assessment" = "Assessment: %s"; "showFeedback" = "Show feedback"; "startExercise" = "Start exercise"; +"openExercise" = "Open exercise"; "openModelingEditor" = "Open modeling editor"; "submitSubmission" = "Submit"; "viewSubmission" = "View submission"; @@ -94,5 +94,5 @@ "date" = "Date"; "noDateAssociated" = "No date associated"; "lectureUnits" = "Lecture Units"; -"lecturesGroupTitle" = "%s (Exercises: %i)"; +"lecturesGroupTitle" = "%s (Lectures: %i)"; "attachments" = "Attachments"; 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() + } +} 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/Dashboard/CourseGridCell.swift b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift index 7f87a54f..4dd95618 100644 --- a/ArtemisKit/Sources/Dashboard/CourseGridCell.swift +++ b/ArtemisKit/Sources/Dashboard/CourseGridCell.swift @@ -46,24 +46,20 @@ 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) .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)) 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/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 diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 5f43ce34..7b3a4ca7 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -13,6 +13,20 @@ "lecturesUnavailable" = "No Lectures"; "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"; +"mention" = "Mention"; // MARK: ReactionsView "emojis" = "Emojis"; @@ -22,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"; @@ -32,6 +47,7 @@ "hide" = "Hide"; "show" = "Show"; "channels" = "Channels"; +"generalTopics" = "General Topics"; "exercises" = "Exercises"; "lectures" = "Lectures"; "exams" = "Exams"; diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift index 912c138b..cfe0d499 100644 --- a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -30,6 +30,8 @@ protocol CodeOfConductService { func getTemplate() async -> DataState } -enum CodeOfConductServiceFactory { - static let shared: CodeOfConductService = CodeOfConductServiceImpl() +enum CodeOfConductServiceFactory: DependencyFactory { + static let liveValue: CodeOfConductService = CodeOfConductServiceImpl() + + static let testValue: CodeOfConductService = CodeOfConductServiceStub() } 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 new file mode 100644 index 00000000..725c0f6a --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift @@ -0,0 +1,27 @@ +// +// CodeOfConductServiceStub.swift +// +// +// Created by Anian Schleyer on 03.06.24. +// + +import Foundation +import Common + +struct CodeOfConductServiceStub: CodeOfConductService { + func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse { + return .success + } + + 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/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/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/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 3e74a94e..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,6 +178,8 @@ extension MessagesService { } } -enum MessagesServiceFactory { - static let shared: MessagesService = MessagesServiceImpl() +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/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 { 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+MentionScheme.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift index 548c70e7..6a1ba6ca 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/MessageCellModel+MentionScheme.swift @@ -8,34 +8,62 @@ import Foundation enum MentionScheme { - case channel(Int64) - case exercise(Int) - case lecture(Int) - case member(String) + 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 { 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[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 + 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 = Int64(url.lastPathComponent) { + self = .message(id: id) + return + } + case "slide": + // E.g., mention://slide/attachment-unit/10/slide/1 + 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/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..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 @@ -42,7 +46,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/SendMessageLecturePickerViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift new file mode 100644 index 00000000..607b65a2 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift @@ -0,0 +1,82 @@ +// +// SendMessageLecturePickerViewModel.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import SharedModels +import SwiftUI + +@Observable +@MainActor +final class SendMessageLecturePickerViewModel { + + let course: Course + var lectures: [Lecture] + + private let delegate: SendMessageMentionContentDelegate + private let lectureService: LectureService + + init( + course: Course, + lectures: [Lecture] = [], + delegate: SendMessageMentionContentDelegate = SendMessageMentionContentDelegate { _ in }, + lectureService: LectureService = LectureServiceFactory.shared + ) { + self.course = course + self.lectures = lectures + self.delegate = delegate + self.lectureService = lectureService + } + + func loadLecturesWithSlides() async { + let lectures = await lectureService.getLecturesWithSlides(courseId: course.id) + + if case let .done(lectures) = lectures { + self.lectures = lectures + } + } + + 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]") + } + } + + 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/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..aa5d304b 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 wantsToAddMessageMentionContentType: MessageMentionContentType? = nil // MARK: Life cycle @@ -89,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/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 30d7551b..ad1e51da 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -199,18 +199,52 @@ private extension MessageCell { if let mention = MentionScheme(url) { let coursePath = CoursePath(course: conversationViewModel.course) switch mention { + 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(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) { navigationController.path.append(ConversationPath(conversation: conversation, coursePath: coursePath)) } } + case let .message(id): + 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/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index 3d92ea08..a721f207 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 { @@ -43,26 +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 { - Button((conversation.isFavorite ?? false) ? R.string.localizable.unfavorite() : R.string.localizable.favorite()) { + @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)) } - } - Button((conversation.isMuted ?? false) ? R.string.localizable.unmute() : R.string.localizable.mute()) { + }.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.setIsConversationMuted(conversationId: conversation.id, isMuted: !(conversation.isMuted ?? false)) + await viewModel.setConversationIsHidden(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) } - } - Button((conversation.isHidden ?? false) ? R.string.localizable.show() : R.string.localizable.hide()) { + }.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.setConversationIsHidden(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) + 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 2383d7e0..3f9b2fc7 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -51,54 +51,58 @@ public struct MessagesAvailableView: View { if let oneToOneChat = conversation.baseConversation as? OneToOneChat { ConversationRow(viewModel: viewModel, conversation: oneToOneChat) } - } + }.listRowBackground(Color.clear) } else { Group { 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(), - conversationType: .channel) + sectionTitle: R.string.localizable.generalTopics(), + sectionIconName: "bubble.left.fill") MessageSection( viewModel: viewModel, conversations: $viewModel.exercises, sectionTitle: R.string.localizable.exercises(), - conversationType: .channel, + sectionIconName: "list.bullet", isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.lectures, sectionTitle: R.string.localizable.lectures(), - conversationType: .channel, + sectionIconName: "doc.fill", isExpanded: false) MessageSection( viewModel: viewModel, conversations: $viewModel.exams, sectionTitle: R.string.localizable.exams(), - conversationType: .channel, + sectionIconName: "graduationcap.fill", isExpanded: false) - MessageSection( - viewModel: viewModel, - conversations: $viewModel.groupChats, - sectionTitle: R.string.localizable.groupChats(), - conversationType: .groupChat) - MessageSection( - viewModel: viewModel, - conversations: $viewModel.oneToOneChats, - sectionTitle: R.string.localizable.directMessages(), - conversationType: .oneToOneChat) + if viewModel.isDirectMessagingEnabled { + MessageSection( + viewModel: viewModel, + conversations: $viewModel.groupChats, + sectionTitle: R.string.localizable.groupChats(), + sectionIconName: "bubble.left.and.bubble.right.fill") + MessageSection( + viewModel: viewModel, + conversations: $viewModel.oneToOneChats, + sectionTitle: R.string.localizable.directMessages(), + sectionIconName: "bubble.left.fill") + } MixedMessageSection( viewModel: viewModel, conversations: $viewModel.hiddenConversations, sectionTitle: R.string.localizable.hiddenSection(), + 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() @@ -112,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() } @@ -125,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) { @@ -132,7 +146,7 @@ public struct MessagesAvailableView: View { ScrollView { CodeOfConductView(course: viewModel.course) } - .padding() + .contentMargins(.l, for: .scrollContent) .navigationTitle(R.string.localizable.codeOfConduct()) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -149,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 @@ -158,16 +242,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) } @@ -182,38 +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, - sectionUnreadCount: sectionUnreadCount, - isUnreadCountVisible: !isExpanded, - conversationType: nil) } } } @@ -224,65 +305,22 @@ 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 { - Text(sectionTitle) + Label(sectionTitle, systemImage: sectionIconName) .font(.headline) + .foregroundStyle(.primary) Spacer() 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 - } - } - } - } - .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 - } } + .padding(.vertical, .m) } } @@ -294,8 +332,8 @@ private struct MessageSection: View { @State private var isExpanded = true - var sectionTitle: String - var conversationType: ConversationType + let sectionTitle: String + let sectionIconName: String var sectionUnreadCount: Int { (conversations.value ?? []).reduce(0) { @@ -307,41 +345,40 @@ private struct MessageSection: View { viewModel: MessagesAvailableViewModel, conversations: Binding>, sectionTitle: String, - conversationType: ConversationType, + sectionIconName: String, isExpanded: Bool = true ) { self.viewModel = viewModel self._conversations = conversations self.sectionTitle = sectionTitle - self.conversationType = conversationType + self.sectionIconName = sectionIconName 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, - sectionUnreadCount: sectionUnreadCount, - isUnreadCountVisible: !isExpanded, - conversationType: conversationType) } } } 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..3f54ebb3 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift @@ -10,24 +10,96 @@ import SwiftUI struct SendMessageLecturePicker: View { - @Environment(\.dismiss) var dismiss + @State var viewModel: SendMessageLecturePickerViewModel - @Binding var text: String + var body: some View { + Group { + if !viewModel.lectures.isEmpty { + List(viewModel.lectures) { lecture in + rowContent(lecture: lecture) + } + .listStyle(.plain) + } else { + ContentUnavailableView(R.string.localizable.lecturesUnavailable(), systemImage: "magnifyingglass") + } + } + .task { + await viewModel.loadLecturesWithSlides() + } + .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) + } + if let lectureUnits = lecture.lectureUnits { + ForEach(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..fb125743 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -0,0 +1,47 @@ +// +// SendMessageMentionContentView.swift +// +// +// Created by Nityananda Zbil on 30.05.24. +// + +import SwiftUI + +struct SendMessageMentionContentView: View { + + @Bindable var viewModel: SendMessageViewModel + let type: MessageMentionContentType + + var body: some View { + NavigationStack { + 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) + case .lecture: + SendMessageLecturePicker(course: viewModel.course, delegate: delegate) + } + } + .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 cf1ea0ba..de27aca4 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(item: $viewModel.wantsToAddMessageMentionContentType) { type in + SendMessageMentionContentView(viewModel: viewModel, type: type) + .presentationDetents([.fraction(0.5), .medium]) } } } @@ -112,63 +106,78 @@ private extension SendMessageView { var keyboardToolbarContent: some View { HStack { ScrollView(.horizontal, showsIndicators: false) { - HStack { - 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") + Label(R.string.localizable.quote(), systemImage: "quote.opening") + .labelStyle(.iconOnly) } - Button { - viewModel.didTapCodeButton() - } label: { - Image(systemName: "curlybraces") - } - 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") - } - 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()) + Label(R.string.localizable.link(), systemImage: "link") + .labelStyle(.iconOnly) } } } 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/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/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, 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() } diff --git a/ArtemisUITests/ArtemisUITests.swift b/ArtemisUITests/ArtemisUITests.swift new file mode 100644 index 00000000..02827fc0 --- /dev/null +++ b/ArtemisUITests/ArtemisUITests.swift @@ -0,0 +1,43 @@ +// +// 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) + } + + @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] 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