From c6738b514effcc17684d177d093867a06f5a4d21 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Wed, 14 Feb 2024 10:24:29 +0100 Subject: [PATCH 1/4] `General`: Release (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Do not retain self ConversationViewModel did not deinitialize * Subscribe to new topics * Apply suggestions from code review Co-authored-by: Tarlan Ismayilsoy * Correct indentation * Correct indentation again * Correct indentation (this time it should be fine) * Cherry-pick exercises/align-list-row * Add Report Post button * Local Core Modules * Revert "Add Report Post button" This reverts commit c3d2fdd024be27fb87c9e01c368a82917aa9ac2c. * Prototype * Revert "Prototype" This reverts commit 39c79725b5c1907a41b973dd477499c509b1aa60. * Prototype * On review * Add code of conduct to messages service * Update Swift tools version, platforms, and minimum deployment * Fix Starscream and warnings: - 'onChange(of:perform:)' was deprecated in iOS 17.0: Use `onChange` with a two or zero parameter action closure instead. - Vertical Whitespace before Closing Braces Violation: Don't include vertical whitespace (empty line) before closing braces (vertical_whitespace_closing_braces) - Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace) - no rule to process file '…/artemis-ios/readme.md' of type 'net.daringfireball.markdown' for architecture 'arm64' * Update to recommended settings: - Asset Catalog: Enable Generated Asset Symbol Extensions - Build Settings: Enable User Script Sandboxing - Project Settings: Enable Parallelization in Command Line Builds using '-target' * ENABLE_USER_SCRIPT_SANDBOXING = NO; warning: The file “.swiftlint.yml” couldn’t be opened because you don’t have permission to view it. – Falling back to default configuration * Revert "Local Core Modules" This reverts commit cb8100a464be9fa1e2ec63ac6ed2320b2f8d3b80. * Update core module ≥ 3.5.0 * Update messages service * Add `ResponsibleUserDTO` * Create CodeOfConductView * Center enrollment button * Format code * Update core module to 4.0.0 * Resolve latest package versions * Supplement * Fix warnings: - Empty XCTest Method Violation: Empty XCTest method should be avoided (empty_xctest_method) - Attributes Violation: Attributes should be on their own lines in functions and types, but on the same line as variables and imports (attributes) * Fix warnings: - Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace) - 'onChange(of:perform:)' was deprecated in iOS 17.0: Use `onChange` with a two or zero parameter action closure instead. - Attributes Violation: Attributes should be on their own lines in functions and types, but on the same line as variables and imports (attributes) * Add ContentUnavailableView * Fix warning: - File Length Violation: File should contain 400 lines or less: currently contains 428 (file_length) * Not searchable if agreement is false * Fitler HTML comments * Create MessagesPreferences * Add navigation title, fix grammar * Prefer labeled trailing closures * Create some service * On review * Add getCodeOfConductTemplate * Wrap MessagesAvailableView * Move getCodeOfConductTemplate * Extract CodeOfConductService * Organize CodeOfConductStorageService * Hash code of conduct template * Shorten method names * On review * On review * On review * Replace Hashable.hashValue with SHA256 digest > Hash values are not guaranteed to be equal across different executions of your program * Create CodeOfConductViewModel * On review * Handle errors * Fix cancellation error on view re-computation * Fix markdown heading not leading * Fix progress view not centered * Init courseCodeOfConduct * Replace legacy test servers * Remove trailing slash * Update core module * Supplement b957dae7659ecd63469edbb324375d42a49a5637 * add exercise details list * resolve issues * Adapt to: https://github.com/ls1intum/Artemis/pull/7437 * Init Artemiskit * Add reference * Move Navigation * Move Messages * Move Notifications * Move CourseRegestration * Move CourseView * Move Dashboard * Delete legacy * Move dependencies * Move app sources * Reference auxiliary files * Rename docu/ * Fix warnings: - 'applicationIconBadgeNumber' was deprecated in iOS 17.0: Use -[UNUserNotificationCenter setBadgeCount:withCompletionHandler:] instead. - empty_xctest_method - trailing_comma * package.resolved * Implement exercise submission service * Update Package.resolved * update dependencies * add apollon-ios-module package dependency * Fastfile update * remove xcode_select * Preview * Add SwiftLint dependency * Use LazyVGrid * Rename CoursesOverview* * Move CourseCollectionView * Iterate * Remove range * Remove project dependency * On review * Adjust API * On review * On review * On review * Fix transient dependency * Prototype * Update SwiftLint to 0.54.0 * Skip macro validation * Prototype no messages * Prototype no notifications * Prototype no lectures * Center no notifications * Refine no messages * Refine no messages * Fix apollon * Format ConversationView * Revert "Prototype no lectures" This reverts commit c897b151278a6c931f700a1feaf96c684b9747ae. * Refine no notifications and format * Extract NotificationToolbar * Move files * Replace NavigationView: deprecated * Display navigation bar title inline * Format * On review * On review * Format * Naming * On review * wip * Refine padding * On review * On review * Get CoursesForDashboard * Branch: feature/general/dashboard-performance * Remove project dependencies; update core * Supplement f10cd658aea113fb10bd2944c1cd2a01dd4ad1fa * Pin Starscream to 4.0.4 * Supplement 2884d5b6f1ce58349df1c993755d065bc41a52b5 * Format * Sort by start date: https://github.com/ls1intum/Artemis/blob/af9a52b41cbf5d8a857b304dcf5ad261c6e757e5/src/main/webapp/app/overview/course-lectures/course-lectures.component.ts#L172 * Format * Localization * Fix topics * `Modeling exercises`: Modeling exercises implementation (#55) * Add "Start Exercise" Button * Fetching exercise data from Artemis * create start participation button * update from develop * add problem statement view * Open modeling editor * StartExerciseButton * update ModelingExerciseView * View modeling exercise submission and result * Update Packed.resolved * update to newest apollon-ios version * Update Core Modules dependency * Update Apollon-iOS-Module revision * initial review changes * further review changes * Remove whitespace --------- Co-authored-by: Nityananda Zbil * Guard message corresponds to conversation * Unwrap id * Use module bundle * Format * Avoid singletons * Supplement 6d75eefed9861b6c40de14e2ff1017871b09d6e5 * Use Observation * Revert "Use Observation" This reverts commit 5e8d1efda41261eccf67d8ae067e197756610028. * `Modeling exercises`: Add feedback and results view for modeling exercises (#61) * Add "Start Exercise" Button * Fetching exercise data from Artemis * create start participation button * update from develop * add problem statement view * Open modeling editor * StartExerciseButton * update ModelingExerciseView * View modeling exercise submission and result * Update Packed.resolved * Added logic and UI for viewing feedback * update to newest apollon-ios version * update to new apollon-ios version * UI improvements for feedback and assessment * Update apollon-ios-module dependency * refactor code * remove feature folder * Change Apollon-iOS-Module dependency to version "1.0.0" * update Apollon-iOS-Module dependency * changes after review * `Communication`: Adapt WebSocket messages (#75) * Fix topic * Rename MetisPostAction: MetisCrudAction https://github.com/ls1intum/Artemis/commit/27cd9644e0d6a36945605408119be2012af26a82 * Add notification field * Unite WebSocket topics * `Notifications`: Add muting notifications for conversations (#51) * Add mute/unmute * Treat file length * English default localization * Update to latest package versions --------- Co-authored-by: Tarlan Ismayilsoy Co-authored-by: Alexander Görtzen <40467337+AlexanderG2207@users.noreply.github.com> --- .gitignore | 2 +- .swiftlint.yml | 5 +- Artemis.entitlements | 36 +- Artemis.xcodeproj/project.pbxproj | 252 ++---------- .../xcshareddata/swiftpm/Package.resolved | 56 ++- .../xcshareddata/xcschemes/Artemis.xcscheme | 2 +- .../iOSApp.swift => Artemis/ArtemisApp.swift | 9 +- .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/100.png | Bin .../AppIcon.appiconset/1024.png | Bin .../AppIcon.appiconset/114.png | Bin .../AppIcon.appiconset/120.png | Bin .../AppIcon.appiconset/128.png | Bin .../AppIcon.appiconset/144.png | Bin .../AppIcon.appiconset/152.png | Bin .../Assets.xcassets/AppIcon.appiconset/16.png | Bin .../AppIcon.appiconset/167.png | Bin .../AppIcon.appiconset/172.png | Bin .../AppIcon.appiconset/180.png | Bin .../AppIcon.appiconset/196.png | Bin .../Assets.xcassets/AppIcon.appiconset/20.png | Bin .../AppIcon.appiconset/216.png | Bin .../AppIcon.appiconset/256.png | Bin .../Assets.xcassets/AppIcon.appiconset/29.png | Bin .../Assets.xcassets/AppIcon.appiconset/32.png | Bin .../Assets.xcassets/AppIcon.appiconset/40.png | Bin .../Assets.xcassets/AppIcon.appiconset/48.png | Bin .../Assets.xcassets/AppIcon.appiconset/50.png | Bin .../AppIcon.appiconset/512.png | Bin .../Assets.xcassets/AppIcon.appiconset/55.png | Bin .../Assets.xcassets/AppIcon.appiconset/57.png | Bin .../Assets.xcassets/AppIcon.appiconset/58.png | Bin .../Assets.xcassets/AppIcon.appiconset/60.png | Bin .../Assets.xcassets/AppIcon.appiconset/64.png | Bin .../Assets.xcassets/AppIcon.appiconset/66.png | Bin .../Assets.xcassets/AppIcon.appiconset/72.png | Bin .../Assets.xcassets/AppIcon.appiconset/76.png | Bin .../Assets.xcassets/AppIcon.appiconset/80.png | Bin .../Assets.xcassets/AppIcon.appiconset/87.png | Bin .../Assets.xcassets/AppIcon.appiconset/88.png | Bin .../Assets.xcassets/AppIcon.appiconset/92.png | Bin .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../CourseRegistration/package.swift | 0 .../Preview Assets.xcassets/Contents.json | 0 {iosApp => Artemis}/Supporting/Info.plist | 0 {core/Navigation => ArtemisKit}/.gitignore | 5 +- ArtemisKit/Package.swift | 130 ++++++ .../Sources/ArtemisKit}/AppDelegate.swift | 44 +- .../Artemis-Logo.imageset/Contents.json | 0 .../android-chrome-512x512.png | Bin .../Artemis-Logo.imageset/favicon.svg | 0 .../Resources/Assets.xcassets/Contents.json | 0 .../Sources/ArtemisKit}/RootView.swift | 12 +- .../Sources/ArtemisKit}/RootViewModel.swift | 61 ++- .../CourseRegistrationView.swift | 0 .../CourseRegistrationViewModel.swift | 0 .../Resources/en.lproj/Localizable.strings | 0 .../CourseRegistrationService.swift | 0 .../CourseRegistrationServiceImpl.swift | 0 .../Sources/CourseView/Config.swift | 0 .../Sources/CourseView/CourseView.swift | 79 ++++ .../Sources/CourseView/CourseViewModel.swift | 8 + .../ModelingExerciseViewModel.swift | 336 +++++++++++++++ .../Views/EditModelingExerciseView.swift | 119 ++++++ .../ViewModelingExerciseResultView.swift | 246 +++++++++++ .../Views/ViewModelingExerciseView.swift | 46 +++ .../ExerciseTab/ExerciseDetailView.swift | 384 ++++++++++++++++++ .../ExerciseTab/ExerciseListView.swift | 2 +- .../SubmissionResultStatusView.swift | 0 .../ExerciseTab/SubmissionResultView.swift | 0 .../LectureTab/LectureAttachmentSheet.swift | 0 .../LectureTab/LectureDetailView.swift | 4 +- .../LectureTab/LectureDetailViewModel.swift | 0 .../LectureTab/LectureListView.swift | 128 +++--- .../Resources}/Assets.xcassets/Contents.json | 0 .../circle-check-solid.imageset/Contents.json | 0 .../circle-check-solid.svg | 0 .../Contents.json | 0 .../circle-exclamation-solid.svg | 0 .../circle-notch-solid.imageset/Contents.json | 0 .../circle-notch-solid.svg | 0 .../Contents.json | 0 .../circle-question-solid.svg | 0 .../circle-solid.imageset/Contents.json | 0 .../circle-solid.imageset/circle-solid.svg | 0 .../circle-xmark-solid.imageset/Contents.json | 0 .../circle-xmark-solid.svg | 0 .../Assets.xcassets/modeling/Contents.json | 6 + .../checkmark-badge.imageset/Contents.json | 22 + .../badge_checkmark_dark.png | Bin 0 -> 8016 bytes .../badge_checkmark_light.png | Bin 0 -> 9586 bytes .../exclamation-badge.imageset/Contents.json | 22 + .../badge_exclamation_dark.png | Bin 0 -> 6471 bytes .../badge_exclamation_light.png | Bin 0 -> 6224 bytes .../xmark-badge.imageset/Contents.json | 22 + .../xmark-badge.imageset/badge_xmark_dark.png | Bin 0 -> 5913 bytes .../badge_xmark_light.png | Bin 0 -> 5376 bytes .../Resources/en.lproj/Localizable.strings | 46 ++- .../ExerciseSubmissionService.swift | 31 ++ ...odelingExerciseSubmissionServiceImpl.swift | 101 +++++ ...UnknownExerciseSubmissionServiceImpl.swift | 29 ++ .../LectureService/LectureService.swift | 0 .../LectureService/LectureServiceImpl.swift | 0 .../Sources/Dashboard/CourseGridView.swift | 168 ++++++++ .../Sources/Dashboard/DashboardView.swift | 34 ++ .../Dashboard/DashboardViewModel.swift | 21 + .../Resources/en.lproj/Localizable.strings | 7 + .../Models/ConversationWebsocketDTO.swift | 5 +- .../Messages/Models/MessageWebsocketDTO.swift | 7 +- .../Messages/Models/ResponsibleUserDTO.swift | 11 + .../Messages/Networking/WebSocketTopic.swift | 31 ++ .../Resources/Assets.xcassets/Contents.json | 6 + .../face-smile.imageset/Contents.json | 0 .../face-smile-regular.svg | 0 .../Resources/en.lproj/Localizable.strings | 8 +- .../CodeOfConductService.swift | 35 ++ .../CodeOfConductServiceImpl.swift | 89 ++++ .../CodeOfConductStorageService.swift | 25 ++ .../CodeOfConductStorageServiceImpl.swift | 31 ++ .../MessagesService/MessagesService.swift | 15 +- .../MessagesService/MessagesServiceImpl.swift | 192 +++++---- .../ConversationInfoSheetViewModel.swift | 0 .../ConversationViewModel.swift | 68 ++-- .../BrowseChannelsViewModel.swift | 0 .../CreateChannelViewModel.swift | 0 .../CreateChatViewModel.swift | 0 .../CodeOfConductViewModel.swift | 48 +++ .../MessagesAvailableViewModel.swift | 126 ++++-- .../MessagesTabViewModel.swift | 89 ++++ .../ConversationInfoSheetView.swift | 1 - .../ConversationView}/ConversationView.swift | 145 +++---- .../BrowseChannelsView.swift | 0 .../CreateChannelView.swift | 0 .../CreateOrAddToChatView.swift | 0 .../MessageActionSheet.swift | 2 +- .../MessageDetailView}/MessageCell.swift | 0 .../MessageDetailView.swift | 2 +- .../MessageDetailView}/ReactionsView.swift | 2 +- .../MessagesTabView/CodeOfConductView.swift | 59 +++ .../ConversationRow/Badge.swift | 28 ++ .../ConversationRow/ConversationRow.swift | 68 ++++ .../MessagesAvailableView.swift | 347 ++++++++++++++++ .../MessagesTabView/MessagesPreferences.swift | 18 + .../MessagesTabView/MessagesTabView.swift | 59 +++ .../Messages/Views/SendMessageView.swift | 0 .../Deeplinks/DeeplinkHandler.swift | 0 .../Deeplinks/Handlers/CourseHandler.swift | 0 .../Deeplinks/Handlers/DashboardHandler.swift | 0 .../Deeplinks/Handlers/ExerciseHandler.swift | 0 .../Deeplinks/Handlers/LectureHandler.swift | 0 .../Deeplinks/Handlers/MessageHandler.swift | 0 .../Deeplinks/Handlers/MessagesHandler.swift | 0 .../Handlers/UnknownLinkHandler.swift | 0 .../Navigation/NavigationController.swift | 0 .../Notifications/Models}/Notification.swift | 0 .../Resources/en.lproj/Localizable.strings | 7 + .../Services/NotificationService.swift | 0 .../Services/NotificationServiceImpl.swift | 0 .../NotificationWebsocketService.swift | 0 .../NotificationWebsocketServiceImpl.swift | 112 ++--- .../ViewModels}/NotificationViewModel.swift | 0 .../Views/NotificationView.swift | 88 ++++ .../Views/View+NotificationToolbar.swift | 64 +++ .../ArtemisKitTests/ArtemisKitTests.swift | 3 + README.md | 4 +- core/Navigation/Package.swift | 34 -- core/Navigation/README.md | 3 - .../NavigationTests/NavigationTests.swift | 11 - {docu => docs}/APP-subsystem.png | Bin fastlane/Fastfile | 4 +- feature/CourseRegistration/.gitignore | 9 - feature/CourseRegistration/Package.swift | 39 -- feature/CourseRegistration/README.md | 3 - .../CourseRegistrationTests.swift | 11 - feature/CourseView/.gitignore | 9 - feature/CourseView/Package.swift | 45 -- feature/CourseView/README.md | 3 - .../Sources/CourseView/CourseView.swift | 61 --- .../ExerciseTab/ExerciseDetailView.swift | 193 --------- .../CourseViewTests/CourseViewTests.swift | 11 - feature/Dashboard/.gitignore | 9 - feature/Dashboard/Package.swift | 49 --- feature/Dashboard/README.md | 3 - .../Dashboard/CoursesOverviewView.swift | 156 ------- .../Dashboard/CoursesOverviewViewModel.swift | 27 -- .../Resources/en.lproj/Localizable.strings | 7 - .../Tests/DashboardTests/DashboardTests.swift | 11 - feature/Messages/.gitignore | 9 - feature/Messages/Package.swift | 46 --- feature/Messages/README.md | 3 - .../Messages/Views/MessagesTabView.swift | 337 --------------- .../Tests/MessagesTests/MessagesTests.swift | 11 - feature/Notifications/.gitignore | 9 - feature/Notifications/Package.swift | 42 -- feature/Notifications/README.md | 3 - .../Notifications/NotificationView.swift | 145 ------- .../Resources/en.lproj/Localizable.strings | 6 - .../NotificationsTests.swift | 11 - iosApp/Localizable.strings | 1 - 200 files changed, 3590 insertions(+), 2001 deletions(-) rename iosApp/iOSApp.swift => Artemis/ArtemisApp.swift (59%) rename {iosApp => Artemis}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/100.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/1024.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/114.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/120.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/128.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/144.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/152.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/16.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/167.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/172.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/180.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/196.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/20.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/216.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/256.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/29.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/32.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/40.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/48.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/50.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/512.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/55.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/57.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/58.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/60.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/64.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/66.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/72.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/76.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/80.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/87.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/88.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/92.png (100%) rename {iosApp => Artemis}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {feature/CourseView/Sources/CourseView/Resources => Artemis}/Assets.xcassets/Contents.json (100%) rename {iosApp => Artemis}/CourseRegistration/package.swift (100%) rename {iosApp => Artemis}/Preview Content/Preview Assets.xcassets/Contents.json (100%) rename {iosApp => Artemis}/Supporting/Info.plist (100%) rename {core/Navigation => ArtemisKit}/.gitignore (67%) create mode 100644 ArtemisKit/Package.swift rename {iosApp => ArtemisKit/Sources/ArtemisKit}/AppDelegate.swift (59%) rename {iosApp => ArtemisKit/Sources/ArtemisKit/Resources}/Assets.xcassets/Artemis-Logo.imageset/Contents.json (100%) rename {iosApp => ArtemisKit/Sources/ArtemisKit/Resources}/Assets.xcassets/Artemis-Logo.imageset/android-chrome-512x512.png (100%) rename {iosApp => ArtemisKit/Sources/ArtemisKit/Resources}/Assets.xcassets/Artemis-Logo.imageset/favicon.svg (100%) rename {feature/Messages/Sources/Messages => ArtemisKit/Sources/ArtemisKit}/Resources/Assets.xcassets/Contents.json (100%) rename {iosApp => ArtemisKit/Sources/ArtemisKit}/RootView.swift (96%) rename {iosApp => ArtemisKit/Sources/ArtemisKit}/RootViewModel.swift (50%) rename {feature/CourseRegistration => ArtemisKit}/Sources/CourseRegistration/CourseRegistrationView.swift (100%) rename {feature/CourseRegistration => ArtemisKit}/Sources/CourseRegistration/CourseRegistrationViewModel.swift (100%) rename {feature/CourseRegistration => ArtemisKit}/Sources/CourseRegistration/Resources/en.lproj/Localizable.strings (100%) rename {feature/CourseRegistration => ArtemisKit}/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationService.swift (100%) rename {feature/CourseRegistration => ArtemisKit}/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Config.swift (100%) create mode 100644 ArtemisKit/Sources/CourseView/CourseView.swift rename {feature/CourseView => ArtemisKit}/Sources/CourseView/CourseViewModel.swift (71%) create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift rename {feature/CourseView => ArtemisKit}/Sources/CourseView/ExerciseTab/ExerciseListView.swift (99%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/ExerciseTab/SubmissionResultStatusView.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/ExerciseTab/SubmissionResultView.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/LectureTab/LectureAttachmentSheet.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/LectureTab/LectureDetailView.swift (98%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/LectureTab/LectureDetailViewModel.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/LectureTab/LectureListView.swift (58%) rename {iosApp => ArtemisKit/Sources/CourseView/Resources}/Assets.xcassets/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/circle-check-solid.svg (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/circle-exclamation-solid.svg (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/circle-notch-solid.svg (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/circle-question-solid.svg (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/circle-solid.svg (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/Contents.json (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/circle-xmark-solid.svg (100%) create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/Contents.json create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/Contents.json create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_dark.png create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_light.png create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/Contents.json create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_dark.png create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_light.png create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/Contents.json create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_dark.png create mode 100644 ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_light.png rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Resources/en.lproj/Localizable.strings (67%) create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/ExerciseSubmissionService.swift create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/ModelingExerciseSubmissionServiceImpl.swift create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/UnknownExerciseSubmissionServiceImpl.swift rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Services/LectureService/LectureService.swift (100%) rename {feature/CourseView => ArtemisKit}/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift (100%) create mode 100644 ArtemisKit/Sources/Dashboard/CourseGridView.swift create mode 100644 ArtemisKit/Sources/Dashboard/DashboardView.swift create mode 100644 ArtemisKit/Sources/Dashboard/DashboardViewModel.swift create mode 100644 ArtemisKit/Sources/Dashboard/Resources/en.lproj/Localizable.strings rename {feature/Messages => ArtemisKit}/Sources/Messages/Models/ConversationWebsocketDTO.swift (72%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Models/MessageWebsocketDTO.swift (52%) create mode 100644 ArtemisKit/Sources/Messages/Models/ResponsibleUserDTO.swift create mode 100644 ArtemisKit/Sources/Messages/Networking/WebSocketTopic.swift create mode 100644 ArtemisKit/Sources/Messages/Resources/Assets.xcassets/Contents.json rename {feature/Messages => ArtemisKit}/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/Contents.json (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/face-smile-regular.svg (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Resources/en.lproj/Localizable.strings (94%) create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageService.swift create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift rename {feature/Messages => ArtemisKit}/Sources/Messages/Services/MessagesService/MessagesService.swift (90%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift (78%) rename {feature/Messages/Sources/Messages/ViewModels => ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels}/ConversationInfoSheetViewModel.swift (100%) rename {feature/Messages/Sources/Messages/ViewModels => ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels}/ConversationViewModel.swift (92%) rename {feature/Messages => ArtemisKit}/Sources/Messages/ViewModels/CreateConversationViewModels/BrowseChannelsViewModel.swift (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChannelViewModel.swift (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChatViewModel.swift (100%) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/CodeOfConductViewModel.swift rename feature/Messages/Sources/Messages/ViewModels/MessagesTabViewModel.swift => ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift (62%) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/ConversationView}/ConversationInfoSheetView.swift (99%) rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/ConversationView}/ConversationView.swift (58%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Views/CreateConversationViews/CreateChannelView.swift (100%) rename {feature/Messages => ArtemisKit}/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift (100%) rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/MessageDetailView}/MessageActionSheet.swift (99%) rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/MessageDetailView}/MessageCell.swift (100%) rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/MessageDetailView}/MessageDetailView.swift (99%) rename {feature/Messages/Sources/Messages/Views => ArtemisKit/Sources/Messages/Views/MessageDetailView}/ReactionsView.swift (99%) create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/Badge.swift create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift create mode 100644 ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift rename {feature/Messages => ArtemisKit}/Sources/Messages/Views/SendMessageView.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/DeeplinkHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/CourseHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/DashboardHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/ExerciseHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/LectureHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/Deeplinks/Handlers/UnknownLinkHandler.swift (100%) rename {core/Navigation => ArtemisKit}/Sources/Navigation/NavigationController.swift (100%) rename {feature/Notifications/Sources/Notifications => ArtemisKit/Sources/Notifications/Models}/Notification.swift (100%) create mode 100644 ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings rename {feature/Notifications => ArtemisKit}/Sources/Notifications/Services/NotificationService.swift (100%) rename {feature/Notifications => ArtemisKit}/Sources/Notifications/Services/NotificationServiceImpl.swift (100%) rename {feature/Notifications => ArtemisKit}/Sources/Notifications/Services/NotificationWebsocketService.swift (100%) rename {feature/Notifications => ArtemisKit}/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift (74%) rename {feature/Notifications/Sources/Notifications => ArtemisKit/Sources/Notifications/ViewModels}/NotificationViewModel.swift (100%) create mode 100644 ArtemisKit/Sources/Notifications/Views/NotificationView.swift create mode 100644 ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/ArtemisKitTests.swift delete mode 100644 core/Navigation/Package.swift delete mode 100644 core/Navigation/README.md delete mode 100644 core/Navigation/Tests/NavigationTests/NavigationTests.swift rename {docu => docs}/APP-subsystem.png (100%) delete mode 100644 feature/CourseRegistration/.gitignore delete mode 100644 feature/CourseRegistration/Package.swift delete mode 100644 feature/CourseRegistration/README.md delete mode 100644 feature/CourseRegistration/Tests/CourseRegistrationTests/CourseRegistrationTests.swift delete mode 100644 feature/CourseView/.gitignore delete mode 100644 feature/CourseView/Package.swift delete mode 100644 feature/CourseView/README.md delete mode 100644 feature/CourseView/Sources/CourseView/CourseView.swift delete mode 100644 feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift delete mode 100644 feature/CourseView/Tests/CourseViewTests/CourseViewTests.swift delete mode 100644 feature/Dashboard/.gitignore delete mode 100644 feature/Dashboard/Package.swift delete mode 100644 feature/Dashboard/README.md delete mode 100644 feature/Dashboard/Sources/Dashboard/CoursesOverviewView.swift delete mode 100644 feature/Dashboard/Sources/Dashboard/CoursesOverviewViewModel.swift delete mode 100644 feature/Dashboard/Sources/Dashboard/Resources/en.lproj/Localizable.strings delete mode 100644 feature/Dashboard/Tests/DashboardTests/DashboardTests.swift delete mode 100644 feature/Messages/.gitignore delete mode 100644 feature/Messages/Package.swift delete mode 100644 feature/Messages/README.md delete mode 100644 feature/Messages/Sources/Messages/Views/MessagesTabView.swift delete mode 100644 feature/Messages/Tests/MessagesTests/MessagesTests.swift delete mode 100644 feature/Notifications/.gitignore delete mode 100644 feature/Notifications/Package.swift delete mode 100644 feature/Notifications/README.md delete mode 100644 feature/Notifications/Sources/Notifications/NotificationView.swift delete mode 100644 feature/Notifications/Sources/Notifications/Resources/en.lproj/Localizable.strings delete mode 100644 feature/Notifications/Tests/NotificationsTests/NotificationsTests.swift delete mode 100644 iosApp/Localizable.strings diff --git a/.gitignore b/.gitignore index 7764c4f0..7971a220 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ build/ *.perspectivev3 *.xccheckout xcuserdata/ -iosApp.xcworkspace/xcuserdata +Artemis.xcworkspace/xcuserdata diff --git a/.swiftlint.yml b/.swiftlint.yml index f885c5df..d5de5115 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,7 +1,6 @@ included: - - iosApp - - core - - feature + - Artemis + - ArtemisKit excluded: - Tests/SwiftLintFrameworkTests/Resources analyzer_rules: diff --git a/Artemis.entitlements b/Artemis.entitlements index 2a0fe76b..e0e0ac64 100644 --- a/Artemis.entitlements +++ b/Artemis.entitlements @@ -6,24 +6,24 @@ development com.apple.developer.associated-domains - applinks:artemis.in.tum.de - applinks:artemis.ase.in.tum.de - applinks:artemis-test1.artemis.in.tum.de - applinks:artemis-test2.artemis.in.tum.de - applinks:artemis-staging.artemis.in.tum.de/ - applinks:artemis-test3.artemis.in.tum.de - applinks:artemis-test5.artemis.in.tum.de - applinks:artemis-test6.artemis.in.tum.de - applinks:artemis-test10.artemis.in.tum.de - webcredentials:artemis.in.tum.de - webcredentials:artemis.ase.in.tum.de - webcredentials:artemis-test1.artemis.in.tum.de - webcredentials:artemis-test2.artemis.in.tum.de - webcredentials:artemis-staging.artemis.in.tum.de/ - webcredentials:artemis-test3.artemis.in.tum.de - webcredentials:artemis-test5.artemis.in.tum.de - webcredentials:artemis-test6.artemis.in.tum.de - webcredentials:artemis-test10.artemis.in.tum.de + applinks:artemis.cit.tum.de + applinks:artemis-staging.artemis.cit.tum.de + applinks:artemis-test1.artemis.cit.tum.de + applinks:artemis-test2.artemis.cit.tum.de + applinks:artemis-test3.artemis.cit.tum.de + applinks:artemis-test4.artemis.cit.tum.de + applinks:artemis-test5.artemis.cit.tum.de + applinks:artemis-test6.artemis.cit.tum.de + applinks:artemis-test9.artemis.cit.tum.de + webcredentials:artemis.cit.tum.de + webcredentials:artemis-staging.artemis.cit.tum.de + webcredentials:artemis-test1.artemis.cit.tum.de + webcredentials:artemis-test2.artemis.cit.tum.de + webcredentials:artemis-test3.artemis.cit.tum.de + webcredentials:artemis-test4.artemis.cit.tum.de + webcredentials:artemis-test5.artemis.cit.tum.de + webcredentials:artemis-test6.artemis.cit.tum.de + webcredentials:artemis-test9.artemis.cit.tum.de diff --git a/Artemis.xcodeproj/project.pbxproj b/Artemis.xcodeproj/project.pbxproj index 6f1e0d89..0916b53e 100644 --- a/Artemis.xcodeproj/project.pbxproj +++ b/Artemis.xcodeproj/project.pbxproj @@ -3,55 +3,31 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; - 1C098983990545800E8F932E /* readme.md in Sources */ = {isa = PBXBuildFile; fileRef = 1C098BE94B1D25D8F4F19DAD /* readme.md */; }; - 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; - 220409A728D4EF1A00204B1C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220409A628D4EF1A00204B1C /* RootView.swift */; }; - 22B12972290048A800C4910E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 22B12971290048A800C4910E /* Localizable.strings */; }; - 22B6A924292D815A00F08C7E /* CourseView in Frameworks */ = {isa = PBXBuildFile; productRef = 22B6A923292D815A00F08C7E /* CourseView */; }; - 22B6A926292D815A00F08C7E /* Dashboard in Frameworks */ = {isa = PBXBuildFile; productRef = 22B6A925292D815A00F08C7E /* Dashboard */; }; - D515291529AD2E83002CAD2F /* Navigation in Frameworks */ = {isa = PBXBuildFile; productRef = D515291429AD2E83002CAD2F /* Navigation */; }; - D54B8BF7297082C400BB1AA3 /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54B8BF6297082C400BB1AA3 /* RootViewModel.swift */; }; - D54F685929C467E300F05A43 /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = D54F685829C467E300F05A43 /* Notifications */; }; - D59E86A429ACC6EB0012C378 /* CourseRegistration in Frameworks */ = {isa = PBXBuildFile; productRef = D59E86A329ACC6EB0012C378 /* CourseRegistration */; }; - D5B8C106299E73F100835976 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B8C105299E73F100835976 /* AppDelegate.swift */; }; - D5B9225129DB0B2100C73192 /* Messages in Frameworks */ = {isa = PBXBuildFile; productRef = D5B9225029DB0B2100C73192 /* Messages */; }; - D5DC36632A0434BB00DA32AF /* APIClient in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36622A0434BB00DA32AF /* APIClient */; }; - D5DC36652A0434BB00DA32AF /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36642A0434BB00DA32AF /* Account */; }; - D5DC36672A0434BB00DA32AF /* ArtemisMarkdown in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36662A0434BB00DA32AF /* ArtemisMarkdown */; }; - D5DC36692A0434BB00DA32AF /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36682A0434BB00DA32AF /* Common */; }; - D5DC366B2A0434BB00DA32AF /* DesignLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC366A2A0434BB00DA32AF /* DesignLibrary */; }; - D5DC366D2A0434BB00DA32AF /* Login in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC366C2A0434BB00DA32AF /* Login */; }; - D5DC366F2A0434BB00DA32AF /* ProfileInfo in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC366E2A0434BB00DA32AF /* ProfileInfo */; }; - D5DC36712A0434BB00DA32AF /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36702A0434BB00DA32AF /* PushNotifications */; }; - D5DC36732A0434BC00DA32AF /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36722A0434BC00DA32AF /* SharedModels */; }; - D5DC36752A0434BC00DA32AF /* UserStore in Frameworks */ = {isa = PBXBuildFile; productRef = D5DC36742A0434BC00DA32AF /* UserStore */; }; + 2152FB042600AC8F00CF470E /* ArtemisApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* ArtemisApp.swift */; }; + A166A2592B0381F000AB6119 /* ArtemisKit in Frameworks */ = {isa = PBXBuildFile; productRef = A166A2582B0381F000AB6119 /* ArtemisKit */; }; /* End PBXBuildFile 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 = ""; }; - 1C098681E5B54683749B1859 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = ""; }; - 1C098BE94B1D25D8F4F19DAD /* readme.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = readme.md; sourceTree = ""; }; - 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; - 220409A628D4EF1A00204B1C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - 22B12971290048A800C4910E /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; - 22B6A916292D417900F08C7E /* CourseView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = CourseView; path = feature/CourseView; sourceTree = ""; }; - 22B6A91A292D529400F08C7E /* CourseRegistration */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = CourseRegistration; path = feature/CourseRegistration; sourceTree = ""; }; + 2152FB032600AC8F00CF470E /* ArtemisApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtemisApp.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* Artemis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Artemis.app; sourceTree = BUILT_PRODUCTS_DIR; }; - D515291329AD2DFB002CAD2F /* Navigation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Navigation; path = core/Navigation; sourceTree = ""; }; + 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; }; + A166A2642B03893900AB6119 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = SOURCE_ROOT; }; + A166A2652B03893900AB6119 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = SOURCE_ROOT; }; + A166A2662B03893900AB6119 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; }; + A166A2672B03893900AB6119 /* Gemfile.lock */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile.lock; sourceTree = SOURCE_ROOT; }; + A166A26E2B03899A00AB6119 /* fastlane */ = {isa = PBXFileReference; lastKnownFileType = text; path = fastlane; sourceTree = SOURCE_ROOT; }; + A1C7E0A92B03754200804542 /* ArtemisKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ArtemisKit; sourceTree = ""; }; D51AD00C299E390700FA5B94 /* Artemis.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Artemis.entitlements; sourceTree = ""; }; D52CEEAA29B8FA2D003C7B2E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - D54B8BF6297082C400BB1AA3 /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; - D54F685729C466E000F05A43 /* Notifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Notifications; path = feature/Notifications; sourceTree = ""; }; - D59E86A229ACB26C0012C378 /* Dashboard */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Dashboard; path = feature/Dashboard; sourceTree = ""; }; - D5B8C105299E73F100835976 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D5CD8C1F29DACF4200F9F262 /* Messages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Messages; path = feature/Messages; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -59,22 +35,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D5B9225129DB0B2100C73192 /* Messages in Frameworks */, - D5DC36712A0434BB00DA32AF /* PushNotifications in Frameworks */, - D5DC36752A0434BC00DA32AF /* UserStore in Frameworks */, - D59E86A429ACC6EB0012C378 /* CourseRegistration in Frameworks */, - D5DC36632A0434BB00DA32AF /* APIClient in Frameworks */, - D5DC366D2A0434BB00DA32AF /* Login in Frameworks */, - D5DC366F2A0434BB00DA32AF /* ProfileInfo in Frameworks */, - D5DC36692A0434BB00DA32AF /* Common in Frameworks */, - D5DC36732A0434BC00DA32AF /* SharedModels in Frameworks */, - D515291529AD2E83002CAD2F /* Navigation in Frameworks */, - 22B6A924292D815A00F08C7E /* CourseView in Frameworks */, - D5DC36672A0434BB00DA32AF /* ArtemisMarkdown in Frameworks */, - D5DC366B2A0434BB00DA32AF /* DesignLibrary in Frameworks */, - D5DC36652A0434BB00DA32AF /* Account in Frameworks */, - D54F685929C467E300F05A43 /* Notifications in Frameworks */, - 22B6A926292D815A00F08C7E /* Dashboard in Frameworks */, + A166A2592B0381F000AB6119 /* ArtemisKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,15 +50,6 @@ path = "Preview Content"; sourceTree = ""; }; - 22B6A90D292D3CAB00F08C7E /* Packages */ = { - isa = PBXGroup; - children = ( - D5383C7529AB8B01007BAECB /* Features */, - D5ED2D0529AB82C600B5EE69 /* Core */, - ); - name = Packages; - sourceTree = ""; - }; 22B6A91C292D785600F08C7E /* Frameworks */ = { isa = PBXGroup; children = ( @@ -109,11 +61,9 @@ isa = PBXGroup; children = ( D51AD00C299E390700FA5B94 /* Artemis.entitlements */, - 22B6A90D292D3CAB00F08C7E /* Packages */, - 7555FF7D242A565900829871 /* iosApp */, + A1C7E0A92B03754200804542 /* ArtemisKit */, + 7555FF7D242A565900829871 /* Artemis */, 7555FF7C242A565900829871 /* Products */, - 1C098BE94B1D25D8F4F19DAD /* readme.md */, - 1C098681E5B54683749B1859 /* .gitignore */, 22B6A91C292D785600F08C7E /* Frameworks */, ); sourceTree = ""; @@ -126,19 +76,22 @@ name = Products; sourceTree = ""; }; - 7555FF7D242A565900829871 /* iosApp */ = { + 7555FF7D242A565900829871 /* Artemis */ = { isa = PBXGroup; children = ( - D52CEEA929B8F9FB003C7B2E /* Supporting */, - 220409A628D4EF1A00204B1C /* RootView.swift */, - D54B8BF6297082C400BB1AA3 /* RootViewModel.swift */, + A166A26E2B03899A00AB6119 /* fastlane */, + A166A2652B03893900AB6119 /* .gitignore */, + A166A2662B03893900AB6119 /* .swiftlint.yml */, + A166A2622B03893900AB6119 /* Gemfile */, + A166A2672B03893900AB6119 /* Gemfile.lock */, + A166A2642B03893900AB6119 /* LICENSE */, + A166A2632B03893900AB6119 /* README.md */, + 2152FB032600AC8F00CF470E /* ArtemisApp.swift */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, - 2152FB032600AC8F00CF470E /* iOSApp.swift */, - D5B8C105299E73F100835976 /* AppDelegate.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, - 22B12971290048A800C4910E /* Localizable.strings */, + D52CEEA929B8F9FB003C7B2E /* Supporting */, ); - path = iosApp; + path = Artemis; sourceTree = ""; }; D52CEEA929B8F9FB003C7B2E /* Supporting */ = { @@ -149,26 +102,6 @@ path = Supporting; sourceTree = ""; }; - D5383C7529AB8B01007BAECB /* Features */ = { - isa = PBXGroup; - children = ( - D5CD8C1F29DACF4200F9F262 /* Messages */, - D54F685729C466E000F05A43 /* Notifications */, - 22B6A916292D417900F08C7E /* CourseView */, - D59E86A229ACB26C0012C378 /* Dashboard */, - 22B6A91A292D529400F08C7E /* CourseRegistration */, - ); - name = Features; - sourceTree = ""; - }; - D5ED2D0529AB82C600B5EE69 /* Core */ = { - isa = PBXGroup; - children = ( - D515291329AD2DFB002CAD2F /* Navigation */, - ); - name = Core; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -187,22 +120,7 @@ ); name = Artemis; packageProductDependencies = ( - 22B6A923292D815A00F08C7E /* CourseView */, - 22B6A925292D815A00F08C7E /* Dashboard */, - D59E86A329ACC6EB0012C378 /* CourseRegistration */, - D515291429AD2E83002CAD2F /* Navigation */, - D54F685829C467E300F05A43 /* Notifications */, - D5B9225029DB0B2100C73192 /* Messages */, - D5DC36622A0434BB00DA32AF /* APIClient */, - D5DC36642A0434BB00DA32AF /* Account */, - D5DC36662A0434BB00DA32AF /* ArtemisMarkdown */, - D5DC36682A0434BB00DA32AF /* Common */, - D5DC366A2A0434BB00DA32AF /* DesignLibrary */, - D5DC366C2A0434BB00DA32AF /* Login */, - D5DC366E2A0434BB00DA32AF /* ProfileInfo */, - D5DC36702A0434BB00DA32AF /* PushNotifications */, - D5DC36722A0434BC00DA32AF /* SharedModels */, - D5DC36742A0434BC00DA32AF /* UserStore */, + A166A2582B0381F000AB6119 /* ArtemisKit */, ); productName = iosApp; productReference = 7555FF7B242A565900829871 /* Artemis.app */; @@ -214,8 +132,9 @@ 7555FF73242A565900829871 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1130; - LastUpgradeCheck = 1130; + LastUpgradeCheck = 1500; ORGANIZATIONNAME = orgName; TargetAttributes = { 7555FF7A242A565900829871 = { @@ -233,7 +152,6 @@ ); mainGroup = 7555FF72242A565900829871; packageReferences = ( - D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */, ); productRefGroup = 7555FF7C242A565900829871 /* Products */; projectDirPath = ""; @@ -250,7 +168,6 @@ buildActionMask = 2147483647; files = ( 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, - 22B12972290048A800C4910E /* Localizable.strings in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -282,11 +199,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, - 220409A728D4EF1A00204B1C /* RootView.swift in Sources */, - D54B8BF7297082C400BB1AA3 /* RootViewModel.swift in Sources */, - D5B8C106299E73F100835976 /* AppDelegate.swift in Sources */, - 1C098983990545800E8F932E /* readme.md in Sources */, + 2152FB042600AC8F00CF470E /* ArtemisApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -297,6 +210,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -331,6 +245,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -345,7 +260,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -360,6 +275,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -394,6 +310,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -402,7 +319,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ""; @@ -420,13 +337,13 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Artemis.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Artemis/Preview Content\""; DEVELOPMENT_TEAM = 2J3C6P6X3N; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = ""; - INFOPLIST_FILE = iosApp/Supporting/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + INFOPLIST_FILE = Artemis/Supporting/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -446,13 +363,13 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Artemis.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Artemis/Preview Content\""; DEVELOPMENT_TEAM = 2J3C6P6X3N; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = ""; - INFOPLIST_FILE = iosApp/Supporting/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + INFOPLIST_FILE = Artemis/Supporting/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -488,91 +405,10 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ls1intum/artemis-ios-core-modules"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ - 22B6A923292D815A00F08C7E /* CourseView */ = { - isa = XCSwiftPackageProductDependency; - productName = CourseView; - }; - 22B6A925292D815A00F08C7E /* Dashboard */ = { - isa = XCSwiftPackageProductDependency; - productName = Dashboard; - }; - D515291429AD2E83002CAD2F /* Navigation */ = { - isa = XCSwiftPackageProductDependency; - productName = Navigation; - }; - D54F685829C467E300F05A43 /* Notifications */ = { - isa = XCSwiftPackageProductDependency; - productName = Notifications; - }; - D59E86A329ACC6EB0012C378 /* CourseRegistration */ = { - isa = XCSwiftPackageProductDependency; - productName = CourseRegistration; - }; - D5B9225029DB0B2100C73192 /* Messages */ = { - isa = XCSwiftPackageProductDependency; - productName = Messages; - }; - D5DC36622A0434BB00DA32AF /* APIClient */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = APIClient; - }; - D5DC36642A0434BB00DA32AF /* Account */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = Account; - }; - D5DC36662A0434BB00DA32AF /* ArtemisMarkdown */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = ArtemisMarkdown; - }; - D5DC36682A0434BB00DA32AF /* Common */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = Common; - }; - D5DC366A2A0434BB00DA32AF /* DesignLibrary */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = DesignLibrary; - }; - D5DC366C2A0434BB00DA32AF /* Login */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = Login; - }; - D5DC366E2A0434BB00DA32AF /* ProfileInfo */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = ProfileInfo; - }; - D5DC36702A0434BB00DA32AF /* PushNotifications */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = PushNotifications; - }; - D5DC36722A0434BC00DA32AF /* SharedModels */ = { - isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = SharedModels; - }; - D5DC36742A0434BC00DA32AF /* UserStore */ = { + A166A2582B0381F000AB6119 /* ArtemisKit */ = { isa = XCSwiftPackageProductDependency; - package = D5DC36612A0434BB00DA32AF /* XCRemoteSwiftPackageReference "artemis-ios-core-modules" */; - productName = UserStore; + productName = ArtemisKit; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5f582c0e..d614fa61 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,21 @@ { "pins" : [ + { + "identity" : "apollon-ios-module", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ls1intum/apollon-ios-module", + "state" : { + "revision" : "1690e711415330b28e836cd8035e1805c0a4e479", + "version" : "1.0.2" + } + }, { "identity" : "artemis-ios-core-modules", "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "aa5eacbcfcb432f4fffd616d129aed6d8e984cf3", - "version" : "2.3.8" + "revision" : "b5b5a7282691d27ea121aadc08b89369f3c8d566", + "version" : "9.0.0" } }, { @@ -23,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "32f641cf24fc7abc1c591a2025e9f2f572648b0f", - "version" : "1.7.2" + "revision" : "7892a123f7e8d0fe62f9f03728b17bbd4f94df5c", + "version" : "1.8.1" } }, { @@ -41,8 +50,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "c1f60c63f356d364f4284ba82961acbe7de79bcc", - "version" : "7.8.1" + "revision" : "3ec0ab0bca4feb56e8b33e289c9496e89059dd08", + "version" : "7.10.2" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", + "version" : "6.0.0" } }, { @@ -50,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mac-cain13/R.swift.git", "state" : { - "revision" : "0e4ec17f329136b712d0a96128597b8ff2f31bdc", - "version" : "7.3.2" + "revision" : "384eab88d1a0b98ac96f4819e50a4308ecd5359f", + "version" : "7.5.0" } }, { @@ -93,10 +111,10 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", - "version" : "1.2.2" + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" } }, { @@ -104,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "12b351a75201a8124c2f2e1f9fc6ef5cd812c0b9", - "version" : "2.1.0" + "revision" : "ae799d015a5374708f7b4c85f3294c05f2a564e2", + "version" : "2.3.0" } }, { @@ -113,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "59ed009d2c4a5a6b78f75a25679b6417ac040dcf", - "version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-07-04-a" + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" } }, { @@ -122,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint.git", "state" : { - "revision" : "9eaecbedce469a51bd8487effbd4ab46ec8384ae", - "version" : "0.52.4" + "revision" : "f17a4f9dfb6a6afb0408426354e4180daaf49cee", + "version" : "0.54.0" } }, { @@ -167,8 +185,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "cd466d6e8c5ffd2f2b61165d37b0646f09068e1e", - "version" : "2.9.0" + "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", + "version" : "2.9.2" } }, { diff --git a/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme b/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme index b3594dd1..8c99ab8b 100644 --- a/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme +++ b/Artemis.xcodeproj/xcshareddata/xcschemes/Artemis.xcscheme @@ -1,6 +1,6 @@ Bool { + public func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { registerForPushNotifications() return true } - func applicationDidEnterBackground(_ application: UIApplication) { - UIApplication.shared.applicationIconBadgeNumber = 0 + public func applicationDidEnterBackground(_ application: UIApplication) { + UNUserNotificationCenter.current().setBadgeCount(0) } private func registerForPushNotifications() { @@ -32,22 +35,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: Extension for Push Notifications extension AppDelegate: UNUserNotificationCenterDelegate { - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + public func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { Task { await PushNotificationServiceFactory.shared.register(deviceToken: String(deviceToken: deviceToken)) } log.info("Device Token: \(String(deviceToken: deviceToken))") } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { UserSession.shared.saveNotificationDeviceConfiguration(token: nil, encryptionKey: nil, skippedNotifications: true) log.error("Did Fail To Register For Remote Notifications With Error: \(error)") } // important to set the 'content_available' field, otherwise the method wont be called in the background - func application(_ application: UIApplication, - didReceiveRemoteNotification payload: [AnyHashable: Any], - fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + public func application( + _ application: UIApplication, + didReceiveRemoteNotification payload: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { log.debug(payload) defer { @@ -62,15 +70,19 @@ extension AppDelegate: UNUserNotificationCenterDelegate { PushNotificationHandler.handle(payload: payloadString, iv: initVector) } - func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { completionHandler([.banner, .badge, .sound]) } - func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) { + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { let userInfo = response.notification.request.content.userInfo guard let targetURL = PushNotificationResponseHandler.getTarget(userInfo: userInfo) else { log.error("Could not handle click on push notification!") @@ -81,7 +93,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { DeeplinkHandler.shared.handle(path: targetURL) // update app badge count - UIApplication.shared.applicationIconBadgeNumber += 1 + UNUserNotificationCenter.current().setBadgeCount(UIApplication.shared.applicationIconBadgeNumber + 1) // maybe add as param in handle above completionHandler() diff --git a/iosApp/Assets.xcassets/Artemis-Logo.imageset/Contents.json b/ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/Contents.json similarity index 100% rename from iosApp/Assets.xcassets/Artemis-Logo.imageset/Contents.json rename to ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/Contents.json diff --git a/iosApp/Assets.xcassets/Artemis-Logo.imageset/android-chrome-512x512.png b/ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/android-chrome-512x512.png similarity index 100% rename from iosApp/Assets.xcassets/Artemis-Logo.imageset/android-chrome-512x512.png rename to ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/android-chrome-512x512.png diff --git a/iosApp/Assets.xcassets/Artemis-Logo.imageset/favicon.svg b/ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/favicon.svg similarity index 100% rename from iosApp/Assets.xcassets/Artemis-Logo.imageset/favicon.svg rename to ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Artemis-Logo.imageset/favicon.svg diff --git a/feature/Messages/Sources/Messages/Resources/Assets.xcassets/Contents.json b/ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Contents.json similarity index 100% rename from feature/Messages/Sources/Messages/Resources/Assets.xcassets/Contents.json rename to ArtemisKit/Sources/ArtemisKit/Resources/Assets.xcassets/Contents.json diff --git a/iosApp/RootView.swift b/ArtemisKit/Sources/ArtemisKit/RootView.swift similarity index 96% rename from iosApp/RootView.swift rename to ArtemisKit/Sources/ArtemisKit/RootView.swift index 4179e4c3..86dea441 100644 --- a/iosApp/RootView.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootView.swift @@ -8,16 +8,18 @@ import PushNotifications import Common import Messages -struct RootView: View { +public struct RootView: View { @StateObject private var viewModel = RootViewModel() @StateObject private var navigationController = NavigationController() - var body: some View { + public init() {} + + public var body: some View { Group { if viewModel.isLoading { - Image("Artemis-Logo") + Image("Artemis-Logo", bundle: .module) .resizable() .scaledToFit() .frame(width: .extraLargeImage) @@ -25,7 +27,7 @@ struct RootView: View { if viewModel.isLoggedIn { if viewModel.didSetupNotifications { NavigationStack(path: $navigationController.path) { - CoursesOverviewView() + DashboardView() .navigationDestination(for: CoursePath.self) { coursePath in CourseView(courseId: coursePath.id) .id(coursePath.id) @@ -68,7 +70,7 @@ struct RootView: View { } } } - .onChange(of: navigationController.path) { _ in + .onChange(of: navigationController.path) { log.debug("NavigationController count: \(navigationController.path.count)") } .environmentObject(navigationController) diff --git a/iosApp/RootViewModel.swift b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift similarity index 50% rename from iosApp/RootViewModel.swift rename to ArtemisKit/Sources/ArtemisKit/RootViewModel.swift index edc76bd2..2d645a15 100644 --- a/iosApp/RootViewModel.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift @@ -6,44 +6,63 @@ // Copyright © 2023 orgName. All rights reserved. // -import Foundation import Combine -import UserStore +import Common +import Foundation +import PushNotifications import SwiftUI import SharedServices -import PushNotifications -import Common +import UserStore @MainActor class RootViewModel: ObservableObject { @Published var isLoading = true - @Published var isLoggedIn = false @Published var didSetupNotifications = false - private var cancellables: Set = Set() + private let userSession: UserSession + private let accountService: AccountService + + private var cancellable: Set = Set() - init() { - UserSession.shared.objectWillChange.sink { + init( + userSession: UserSession = .shared, + accountService: AccountService = AccountServiceFactory.shared + ) { + self.userSession = userSession + self.accountService = accountService + + start() + } +} + +private extension RootViewModel { + func start() { + userSession.objectWillChange.sink { DispatchQueue.main.async { [weak self] in - if !(self?.isLoggedIn ?? false) && UserSession.shared.isLoggedIn { - self?.updateDeviceToken() + guard let self else { + return } - self?.isLoggedIn = UserSession.shared.isLoggedIn - self?.didSetupNotifications = UserSession.shared.getCurrentNotificationDeviceConfiguration() != nil + + if !self.isLoggedIn && self.userSession.isLoggedIn { + self.updateDeviceToken() + } + self.isLoggedIn = self.userSession.isLoggedIn + self.didSetupNotifications = self.userSession.getCurrentNotificationDeviceConfiguration() != nil } - }.store(in: &cancellables) + } + .store(in: &cancellable) Task(priority: .high) { - let user = await AccountServiceFactory.shared.getAccount() + let user = await accountService.getAccount() switch user { case .loading, .failure: - UserSession.shared.setTokenExpired(expired: false) + userSession.setTokenExpired(expired: false) case .done: - isLoggedIn = UserSession.shared.isLoggedIn - didSetupNotifications = UserSession.shared.getCurrentNotificationDeviceConfiguration() != nil + isLoggedIn = userSession.isLoggedIn + didSetupNotifications = userSession.getCurrentNotificationDeviceConfiguration() != nil } isLoading = false } @@ -51,10 +70,12 @@ class RootViewModel: ObservableObject { updateDeviceToken() } - private func updateDeviceToken() { - if let notificationConfig = UserSession.shared.getCurrentNotificationDeviceConfiguration(), + func updateDeviceToken() { + if let notificationConfig = userSession.getCurrentNotificationDeviceConfiguration(), !notificationConfig.skippedNotifications { - UserSession.shared.notificationSetupError = nil + + userSession.notificationSetupError = nil + Task { do { let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .badge, .alert]) diff --git a/feature/CourseRegistration/Sources/CourseRegistration/CourseRegistrationView.swift b/ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift similarity index 100% rename from feature/CourseRegistration/Sources/CourseRegistration/CourseRegistrationView.swift rename to ArtemisKit/Sources/CourseRegistration/CourseRegistrationView.swift diff --git a/feature/CourseRegistration/Sources/CourseRegistration/CourseRegistrationViewModel.swift b/ArtemisKit/Sources/CourseRegistration/CourseRegistrationViewModel.swift similarity index 100% rename from feature/CourseRegistration/Sources/CourseRegistration/CourseRegistrationViewModel.swift rename to ArtemisKit/Sources/CourseRegistration/CourseRegistrationViewModel.swift diff --git a/feature/CourseRegistration/Sources/CourseRegistration/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseRegistration/Resources/en.lproj/Localizable.strings similarity index 100% rename from feature/CourseRegistration/Sources/CourseRegistration/Resources/en.lproj/Localizable.strings rename to ArtemisKit/Sources/CourseRegistration/Resources/en.lproj/Localizable.strings diff --git a/feature/CourseRegistration/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationService.swift b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationService.swift similarity index 100% rename from feature/CourseRegistration/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationService.swift rename to ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationService.swift diff --git a/feature/CourseRegistration/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift similarity index 100% rename from feature/CourseRegistration/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift rename to ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift diff --git a/feature/CourseView/Sources/CourseView/Config.swift b/ArtemisKit/Sources/CourseView/Config.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/Config.swift rename to ArtemisKit/Sources/CourseView/Config.swift diff --git a/ArtemisKit/Sources/CourseView/CourseView.swift b/ArtemisKit/Sources/CourseView/CourseView.swift new file mode 100644 index 00000000..f739a2d6 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/CourseView.swift @@ -0,0 +1,79 @@ +import SwiftUI +import Common +import SharedModels +import Navigation +import Messages +import DesignLibrary + +public struct CourseView: View { + + @StateObject private var viewModel: CourseViewModel + @StateObject private var messagesPreferences = MessagesPreferences() + + @EnvironmentObject private var navigationController: NavigationController + + @State private var showNewMessageDialog = false + @State private var searchText = "" + + private let courseId: Int + + public init(courseId: Int) { + self.courseId = courseId + self._viewModel = StateObject(wrappedValue: CourseViewModel(courseId: courseId)) + } + + public var body: some View { + TabView(selection: $navigationController.courseTab) { + ExerciseListView(viewModel: viewModel, searchText: $searchText) + .tabItem { + Label(R.string.localizable.exercisesTabLabel(), systemImage: "list.bullet.clipboard.fill") + } + .tag(TabIdentifier.exercise) + + LectureListView(viewModel: viewModel, searchText: $searchText) + .tabItem { + Label(R.string.localizable.lectureTabLabel(), systemImage: "character.book.closed.fill") + } + .tag(TabIdentifier.lecture) + + if viewModel.isMessagesVisible { + Group { + if let course = viewModel.course.value { + MessagesTabView(course: course, searchText: $searchText) + .environmentObject(messagesPreferences) + } else { + Text("Loading...") + } + } + .tabItem { + Label(R.string.localizable.messagesTabLabel(), systemImage: "bubble.right.fill") + } + .tag(TabIdentifier.communication) + } + } + .navigationTitle(viewModel.course.value?.title ?? R.string.localizable.loading()) + .navigationBarTitleDisplayMode(.inline) + .modifier(SearchableIf(condition: navigationController.courseTab != .communication || messagesPreferences.isSearchable, + text: $searchText)) + .onChange(of: navigationController.courseTab) { + searchText = "" + } + } +} + +/// `SearchableIf` modifies a view to be searchable if the condition is true. +/// +/// It appears, the `.searchable` modifier cannot be deeper in the hierarchy, i.e., further from the enclosing `NavigationStack`. +private struct SearchableIf: ViewModifier { + let condition: Bool + let text: Binding + + func body(content: Content) -> some View { + if condition { + content + .searchable(text: text) + } else { + content + } + } +} diff --git a/feature/CourseView/Sources/CourseView/CourseViewModel.swift b/ArtemisKit/Sources/CourseView/CourseViewModel.swift similarity index 71% rename from feature/CourseView/Sources/CourseView/CourseViewModel.swift rename to ArtemisKit/Sources/CourseView/CourseViewModel.swift index 782ad34b..46f9fdac 100644 --- a/feature/CourseView/Sources/CourseView/CourseViewModel.swift +++ b/ArtemisKit/Sources/CourseView/CourseViewModel.swift @@ -8,6 +8,14 @@ class CourseViewModel: ObservableObject { @Published var course: DataState = DataState.loading + var isMessagesVisible: Bool { + if let configuration = course.value?.courseInformationSharingConfiguration { + return configuration == .communicationAndMessaging || configuration == .messagingOnly + } else { + return true + } + } + init(courseId: Int) { Task { await loadCourse(id: courseId) diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift new file mode 100644 index 00000000..4c6b14a9 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift @@ -0,0 +1,336 @@ +// +// ModelingExerciseViewModel.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import ApollonShared +import Common +import Foundation +import SwiftUI +import SharedModels + +class ModelingExerciseViewModel: BaseViewModel { + @Published var submission: BaseSubmission? + @Published var umlModel: UMLModel? + @Published var loading = false + @Published var diagramTypeUnsupported = false + + @Published var result: Result? + @Published var referencedFeedbacks: [Feedback] = [] + @Published var unreferencedFeedbacks: [Feedback] = [] + @Published var highlights: [UMLHighlight] = [] + @Published var selectedItem: SelectableUMLItem? + @Published var selectedFeedbackId: Int? + @Published var showFeedback = false + @Published var diagramOffset = CGPoint(x: 15, y: 15) + private var symbolSize = 30.0 + + var exercise: Exercise + var participationId: Int + var problemStatementURL: URLRequest? + + init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest? = nil) { + self.exercise = exercise + self.participationId = participationId + self.problemStatementURL = problemStatementURL + } + + func fetchSubmission() async { + guard submission == nil else { + return + } + + isLoading = true + defer { + isLoading = false + } + + let exerciseService = ExerciseSubmissionServiceFactory.service(for: exercise) + + do { + let response = try await exerciseService.getLatestSubmission(participationId: participationId) + self.submission = response.baseSubmission + if let result = response.baseSubmission.results?.first, let result { + self.result = result + } + } catch { + log.error(String(describing: error)) + } + } + + func setupUMLModel() { + guard let modelingSubmission = self.submission as? ModelingSubmission else { + log.error("Could not get modeling submission") + return + } + + do { + if let modelData = modelingSubmission.model?.data(using: .utf8) { + umlModel = try JSONDecoder().decode(UMLModel.self, from: modelData) + guard let type = umlModel?.type, !UMLDiagramType.isDiagramTypeUnsupported(diagramType: type) else { + log.error("This diagram type is not yet supported") + diagramTypeUnsupported = true + return + } + if let feedbacks = result?.feedbacks { + for feedback in feedbacks { + if feedback.reference != nil { + referencedFeedbacks.append(feedback) + } else { + unreferencedFeedbacks.append(feedback) + } + } + if !referencedFeedbacks.isEmpty { + self.setupHighlights(basedOn: referencedFeedbacks) + } + } + } else { + guard let modelingExercise = exercise.baseExercise as? ModelingExercise, + let type = modelingExercise.diagramType, + let umlDiagramType = ApollonShared.UMLDiagramType(rawValue: type.rawValue), + !UMLDiagramType.isDiagramTypeUnsupported(diagramType: umlDiagramType) else { + log.error("This diagram type is not yet supported") + diagramTypeUnsupported = true + return + } + umlModel = UMLModel(type: umlDiagramType) + } + } catch { + log.error("Could not parse UML string: \(error)") + } + } + + func submitSubmission() async { + guard var submitSubmission = submission as? ModelingSubmission, let umlModel else { + return + } + + let exerciseService = ExerciseSubmissionServiceFactory.service(for: exercise) + + do { + let jsonData = try JSONEncoder().encode(umlModel) + if let jsonString = String(data: jsonData, encoding: .utf8) { + submitSubmission.model = jsonString + } + try await exerciseService.updateSubmission(exerciseId: exercise.id, submission: submitSubmission) + } catch { + log.error(String(describing: error)) + } + } + + func getItemNameById(itemId: String) -> String? { + if let element = getElementById(elementId: itemId) { + return element.name + } else if let relationship = getRelationshipById(relationshipId: itemId) { + return relationship.name + } else { + return nil + } + } + + func getElementById(elementId: String) -> UMLElement? { + if let element = umlModel?.elements?.first(where: { $0.value.id == elementId }) { + return element.value + } + return nil + } + + func getRelationshipById(relationshipId: String) -> UMLRelationship? { + if let relationship = umlModel?.relationships?.first(where: { $0.value.id == relationshipId }) { + return relationship.value + } + return nil + } +} + +extension ModelingExerciseViewModel { + func setupHighlights(basedOn feedbacks: [Feedback]) { + guard umlModel?.elements != nil else { + log.error("Could not find elements in the model when attempting to set up highlights") + return + } + + for feedback in feedbacks { + guard let referencedItemId = feedback.reference?.components(separatedBy: ":")[1], + let referencedItem = findSelectableItem(byId: referencedItemId), + let elementRect = referencedItem.boundsAsCGRect, + let badgeLocation = referencedItem.badgeLocation else { + log.error("Could not create a highlight for the following referenced feedback: \(feedback)") + continue + } + let highlightPath = Path(elementRect) + + let newHighlight = UMLHighlight(assessmentFeedbackId: feedback.id ?? 0, + symbol: UMLBadgeSymbol.symbol(forCredits: feedback.credits ?? 0.0), + elementBounds: elementRect, + path: highlightPath, + badgeLocation: badgeLocation) + highlights.append(newHighlight) + } + } + + func renderHighlights(_ context: inout GraphicsContext, size: CGSize) { + let context = UMLGraphicsContext(context, offset: diagramOffset) + + // Highlight selected element if there is one + if let selectedItem, + let highlightPath = selectedItem.highlightPath { + if type(of: selectedItem) == UMLRelationship.self { + context.fill(highlightPath, + with: .color(Color.blue.opacity(0.5))) + } else { + context.stroke(highlightPath, + with: .color(Color.blue.opacity(0.5)), + style: .init(lineWidth: 5) + ) + } + } + + // Highlight all elements associated with a feedback + for highlight in highlights { + let badgeSymbol = highlight.symbol + let badgeCircleSideLength = symbolSize + let badgeCircleX: CGFloat + let badgeCircleY: CGFloat + + // Determine badge coordinates + badgeCircleX = highlight.badgeLocation.x - badgeCircleSideLength / 2 + badgeCircleY = highlight.badgeLocation.y - badgeCircleSideLength / 2 + + guard let resolvedBadgeSymbol = context.resolveSymbol(id: badgeSymbol) else { + log.warning("Could not resolve the highlight badge for: \(highlight)") + continue + } + + let badgeRect = CGRect(x: badgeCircleX, + y: badgeCircleY, + width: badgeCircleSideLength, + height: badgeCircleSideLength) + + let badgeCircle = Path(ellipseIn: badgeRect) + + context.fill(badgeCircle, with: .color(Color(UIColor.systemGray5).opacity(0.85))) + context.draw(resolvedBadgeSymbol, in: badgeRect.insetBy(dx: 6, dy: 6)) + } + } + + func selectItem(at point: CGPoint) { + self.selectedItem = self.getSelectableItem(at: point) + + if let selectedItem { + if let matchingHighlight = highlights.first(where: { $0.elementBounds == selectedItem.boundsAsCGRect }) { + self.selectedFeedbackId = matchingHighlight.assessmentFeedbackId + self.showFeedback = true + } else { + self.selectedFeedbackId = nil + self.showFeedback = false + } + } + } + + private func getSelectableItem(at point: CGPoint) -> SelectableUMLItem? { + let point = CGPoint(x: point.x - diagramOffset.x, + y: point.y - diagramOffset.y) + + // Check for UMLRelationship + if let relationship = umlModel?.relationships?.first(where: { $0.value.boundsContains(point: point) }) { + return relationship.value + } + // Check for UMLElement + if let element = umlModel?.elements?.first(where: { $0.value.boundsContains(point: point) }) { + if let children = element.value.children { + for child in children where child.boundsContains(point: point) { + return child + } + return element.value + } else { + return element.value + } + } + // Return nil if nothing found + return nil + } + + private func findSelectableItem(byId id: String) -> SelectableUMLItem? { + var selectableItem: SelectableUMLItem? + + if let elements = umlModel?.elements, + let foundElement = elements.values.first(where: { $0.id == id }) { + selectableItem = foundElement + } else if let relationships = umlModel?.relationships, + let foundRelationship = relationships.values.first(where: { $0.id == id }) { + selectableItem = foundRelationship + } + + return selectableItem + } + + func getFeedback(byId id: Int) -> Feedback? { + referencedFeedbacks.first(where: { $0.id == id }) + } + + func getBackgroundColor(feedback: Feedback) -> Color { + if let credits = feedback.credits { + if credits > 0.0 { + return .green + } + if credits < 0.0 { + return .red + } + } + return .primary + } + + // Generates all possible symbol views that can be drawn on the canvas used for rendering highlights + @ViewBuilder + func generatePossibleSymbols() -> some View { + // Positive referenced feedback + Image(UMLBadgeSymbol.checkmark.imageName, bundle: .module) + .tag(UMLBadgeSymbol.checkmark) + + // Negative referenced feedback + Image(UMLBadgeSymbol.cross.imageName, bundle: .module) + .tag(UMLBadgeSymbol.cross) + + // Neutral referenced feedback + Image(UMLBadgeSymbol.exclamation.imageName, bundle: .module) + .tag(UMLBadgeSymbol.exclamation) + } +} + +struct UMLHighlight { + var assessmentFeedbackId: Int + var symbol: UMLBadgeSymbol + var elementBounds: CGRect + var path: Path + var badgeLocation: CGPoint +} + +enum UMLBadgeSymbol { + case checkmark + case cross + case exclamation + + var imageName: String { + switch self { + case .checkmark: + return "checkmark-badge" + case .cross: + return "xmark-badge" + case .exclamation: + return "exclamation-badge" + } + } + + static func symbol(forCredits credits: Double) -> Self { + if credits < 0.0 { + return Self.cross + } else if credits > 0.0 { + return Self.checkmark + } else { + return Self.exclamation + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift new file mode 100644 index 00000000..dd0a5cc7 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift @@ -0,0 +1,119 @@ +// +// ModelingExerciseViewModel.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import SwiftUI +import ApollonShared +import ApollonEdit +import SharedModels +import DesignLibrary + +struct EditModelingExerciseView: View { + @StateObject var modelingViewModel: ModelingExerciseViewModel + + init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest) { + self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, + participationId: participationId, + problemStatementURL: problemStatementURL)) + } + + var body: some View { + ZStack { + if !modelingViewModel.diagramTypeUnsupported { + if let model = modelingViewModel.umlModel, let type = model.type { + ApollonEdit(umlModel: Binding( + get: { modelingViewModel.umlModel ?? UMLModel() }, + set: { modelingViewModel.umlModel = $0 }), + diagramType: type, + fontSize: 14.0, + themeColor: Color.Artemis.artemisBlue, + diagramOffset: CGPoint(x: 0, y: 0), + isGridBackground: true) + } + } else { + ArtemisHintBox(text: R.string.localizable.diagramTypeNotSupported(), hintType: .warning) + .padding(.horizontal, .l) + } + } + .task { + await modelingViewModel.fetchSubmission() + modelingViewModel.setupUMLModel() + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if !modelingViewModel.diagramTypeUnsupported { + HStack { + ProblemStatementButton(modelingViewModel: modelingViewModel) + SubmitButton(modelingViewModel: modelingViewModel) + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } +} + +struct SubmitButton: View { + @ObservedObject var modelingViewModel: ModelingExerciseViewModel + + var body: some View { + Button { + Task { + await modelingViewModel.submitSubmission() + } + } label: { + Text(R.string.localizable.submitSubmission()) + }.buttonStyle(ArtemisButton()) + } +} + +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()) + } + } + } + } + } + .padding(.m) + } + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift new file mode 100644 index 00000000..197093f3 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseResultView.swift @@ -0,0 +1,246 @@ +// +// ModelingExerciseViewModel.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import SwiftUI +import ApollonShared +import ApollonView +import SharedModels +import DesignLibrary + +struct ViewModelingExerciseResultView: View { + @StateObject var modelingViewModel: ModelingExerciseViewModel + @State var isStatusViewClicked = false + + init(exercise: Exercise, participationId: Int) { + self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, + participationId: participationId)) + } + + var body: some View { + ZStack { + if !modelingViewModel.diagramTypeUnsupported { + if let model = modelingViewModel.umlModel, let type = model.type { + ApollonView(umlModel: model, + diagramType: type, + fontSize: 14.0, + themeColor: Color.Artemis.artemisBlue, + diagramOffset: modelingViewModel.diagramOffset, + isGridBackground: true) { + Canvas(rendersAsynchronously: true) { context, size in + modelingViewModel.renderHighlights(&context, size: size) + } symbols: { + modelingViewModel.generatePossibleSymbols() + } + .onTapGesture { tapLocation in + modelingViewModel.selectItem(at: tapLocation) + } + } + } + FeedbackViewPopOver(modelingViewModel: modelingViewModel) + } else { + ArtemisHintBox(text: R.string.localizable.diagramTypeNotSupported(), hintType: .warning) + .padding(.horizontal, .l) + } + } + .task { + await modelingViewModel.fetchSubmission() + modelingViewModel.setupUMLModel() + } + .toolbar { + ToolbarItemGroup(placement: .principal) { + SubmissionResultStatusView(exercise: modelingViewModel.exercise) + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + AssessmentViewButton(modelingViewModel: modelingViewModel, isStatusViewClicked: $isStatusViewClicked) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } +} + +private struct FeedbackViewPopOver: View { + @ObservedObject var modelingViewModel: ModelingExerciseViewModel + + var body: some View { + if modelingViewModel.showFeedback, + modelingViewModel.selectedItem != nil, + let feedbackId = modelingViewModel.selectedFeedbackId, + let feedback = modelingViewModel.getFeedback(byId: feedbackId) { + VStack { + Spacer() + VStack(alignment: .leading) { + HStack { + Text(R.string.localizable.modelingFeedbackElement()) + .bold() + Spacer() + Text(R.string.localizable.modelingFeedbackPoints()) + .bold() + } + Divider() + HStack { + if let reference = feedback.reference { + Text(reference.components(separatedBy: ":")[0]) + if let name = modelingViewModel.getItemNameById(itemId: reference.components(separatedBy: ":")[1]) { + Text(name) + .foregroundColor(Color.Artemis.artemisLightBlue) + } + } + Spacer() + if let points = feedback.credits { + Text(String(points)) + } + } + if let text = feedback.text { + HStack(alignment: .top) { + Text(R.string.localizable.modelingFeedback()) + .bold() + Text(text) + .multilineTextAlignment(.leading) + } + .foregroundColor(modelingViewModel.getBackgroundColor(feedback: feedback)) + } + } + .padding(.m) + .frame(maxWidth: .infinity, alignment: .center) + .background { + RoundedRectangle(cornerRadius: 5) + .foregroundColor(Color.Artemis.modalCardBackgroundColor) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 15) + } + } +} + +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 + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: .s) { + Button { + isStatusViewClicked = false + } label: { + Text(R.string.localizable.close()) + } + .padding(.top, .l) + + Text(R.string.localizable.modelingAssessment()) + .font(.title) + .bold() + .padding(.vertical, .l) + + HStack { + Text(R.string.localizable.modelingFeedbackElement()) + .bold() + Spacer() + Text(R.string.localizable.modelingFeedbackPoints()) + .bold() + } + + Divider() + + ForEach(modelingViewModel.referencedFeedbacks) { feedback in + HStack { + if let reference = feedback.reference { + Text(reference.components(separatedBy: ":")[0]) + if let name = modelingViewModel.getItemNameById(itemId: reference.components(separatedBy: ":")[1]) { + Text(name) + .foregroundColor(Color.Artemis.artemisLightBlue) + } + } + Spacer() + if let points = feedback.credits { + Text(String(points)) + } + } + if let text = feedback.text { + HStack(alignment: .top) { + Text(R.string.localizable.modelingFeedback()) + .bold() + Text(text) + .multilineTextAlignment(.leading) + } + .foregroundColor(modelingViewModel.getBackgroundColor(feedback: feedback)) + } + Divider() + } + + if !modelingViewModel.unreferencedFeedbacks.isEmpty { + VStack(alignment: .leading) { + Text("\(Image(systemName: "ellipsis.message")) \(R.string.localizable.modelingAdditionalFeedback())") + .font(.headline) + .bold() + .padding(.m) + + ForEach(modelingViewModel.unreferencedFeedbacks) { feedback in + let color = modelingViewModel.getBackgroundColor(feedback: feedback) + HStack(alignment: .top) { + if let points = feedback.credits { + Text("\(String(points)) \(R.string.localizable.modelingFeedbackPoints())") + .bold() + .foregroundColor(color) + } + if let text = feedback.detailText { + Text(text) + .multilineTextAlignment(.leading) + .foregroundColor(color) + } + } + .padding(.m) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 5) + .stroke(color, lineWidth: 1) + } + .background { + RoundedRectangle(cornerRadius: 5) + .foregroundColor(color.opacity(0.1)) + } + .padding(.m) + } + } + .background { + RoundedRectangle(cornerRadius: 5) + .foregroundColor(Color.Artemis.modalCardBackgroundColor) + } + .padding(.vertical, .l) + } + } + .padding(.horizontal, .m) + } + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseView.swift new file mode 100644 index 00000000..c0227bf9 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/ViewModelingExerciseView.swift @@ -0,0 +1,46 @@ +// +// ModelingExerciseViewModel.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import SwiftUI +import ApollonShared +import ApollonView +import SharedModels +import DesignLibrary + +struct ViewModelingExerciseView: View { + @StateObject var modelingViewModel: ModelingExerciseViewModel + + init(exercise: Exercise, participationId: Int) { + self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, + participationId: participationId)) + } + + var body: some View { + ZStack { + if !modelingViewModel.diagramTypeUnsupported { + if let model = modelingViewModel.umlModel, let type = model.type { + ApollonView(umlModel: model, + diagramType: type, + fontSize: 14.0, + themeColor: Color.Artemis.artemisBlue, + diagramOffset: CGPoint(x: 0, y: 0), + isGridBackground: true) {} + } + } else { + ArtemisHintBox(text: R.string.localizable.diagramTypeNotSupported(), hintType: .warning) + .padding(.horizontal, .l) + } + } + .task { + await modelingViewModel.fetchSubmission() + modelingViewModel.setupUMLModel() + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .navigationTitle(R.string.localizable.viewSubmissionTitle()) + } +} diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift new file mode 100644 index 00000000..96d45a68 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -0,0 +1,384 @@ +// +// ExerciseDetailView.swift +// +// +// Created by Sven Andabaka on 23.03.23. +// + +import SwiftUI +import SharedModels +import UserStore +import DesignLibrary +import Common +import SharedServices +import Navigation + +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 + + 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, .modeling, .programming, .text: + return true + default: + return false + } + } + + private var isExerciseParticipationAvailable: Bool { + switch exercise.value { + case .modeling: + return true + default: + return false + } + } + + public var body: some View { + DataStateView(data: $exercise, retryHandler: { await loadExercise() }) { 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 != .includedCompletly { + 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) + .frame(height: webViewHeight) + .loadingIndicator(isLoading: $isWebViewLoading) + } + } + .toolbar { + ToolbarItem(placement: .principal) { + HStack(spacing: .l) { + exercise.image + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundColor(Color.Artemis.primaryLabel) + .frame(width: .smallImage) + Text(exercise.baseExercise.title ?? "") + .font(.headline) + } + } + } + } + .task { + await loadExercise() + } + } + + private func loadExercise() async { + if let exercise = exercise.value { + setParticipationAndResultId(from: exercise) + } else { + self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) + if let exercise = self.exercise.value { + setParticipationAndResultId(from: exercise) + } + } + } + + private func setParticipationAndResultId(from exercise: Exercise) { + isWebViewLoading = true + + 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 + } + + urlRequest = URLRequest(url: URL(string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", relativeTo: UserSession.shared.institution?.baseURL)!) + } +} + +private struct ExerciseDetailCell: View { + let descriptionText: String + @ViewBuilder let content: Content + + var body: some View { + HStack { + Text(descriptionText) + Spacer() + content + } + .frame(height: 25, alignment: .center) + .padding(.s) + } +} + +private struct StartExerciseButton: View { + var exercise: Exercise + @Binding var participationId: Int? + + var body: some View { + Button { + Task { + let exerciseService = ExerciseSubmissionServiceFactory.service(for: exercise) + do { + let response = try await exerciseService.startParticipation(exerciseId: exercise.id) + participationId = response.baseParticipation.id + } catch { + log.error(String(describing: error)) + } + } + } label: { + Text(R.string.localizable.startExercise()) + } + .buttonStyle(ArtemisButton()) + } +} + +private struct OpenExerciseButton: View { + var exercise: Exercise + var participationId: Int + var problemStatementURL: URLRequest + + var body: some View { + switch exercise { + case .modeling: + NavigationLink(destination: EditModelingExerciseView(exercise: exercise, + participationId: participationId, + problemStatementURL: problemStatementURL)) { + Text(R.string.localizable.openModelingEditor()) + }.buttonStyle(ArtemisButton()) + default: + ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) + } + } +} + +private struct ViewExerciseSubmissionButton: View { + var exercise: Exercise + var participationId: Int + + var body: some View { + switch exercise { + case .modeling: + NavigationLink(destination: ViewModelingExerciseView(exercise: exercise, + participationId: participationId)) { + Text(R.string.localizable.viewSubmission()) + }.buttonStyle(ArtemisButton()) + default: + ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) + } + } +} + +private struct ViewExerciseResultButton: View { + var exercise: Exercise + var participationId: Int + + var body: some View { + switch exercise { + case .modeling: + NavigationLink(destination: ViewModelingExerciseResultView(exercise: exercise, + participationId: participationId)) { + Text(R.string.localizable.viewResult()) + }.buttonStyle(ArtemisButton()) + default: + ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) + } + } +} + +private struct FeedbackView: View { + @Environment(\.dismiss) var dismiss + @State private var webViewHeight = CGFloat.s + @State private var urlRequest: URLRequest + @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)!)) + } + + var body: some View { + NavigationView { + ArtemisWebView(urlRequest: $urlRequest, isLoading: $isWebViewLoading) + .loadingIndicator(isLoading: $isWebViewLoading) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(R.string.localizable.close()) { + dismiss() + } + } + } + } + } +} diff --git a/feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift similarity index 99% rename from feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseListView.swift rename to ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index a067ca93..9ffabd1c 100644 --- a/feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -68,7 +68,7 @@ struct ExerciseListView: View { } } .listStyle(PlainListStyle()) - .onChange(of: weeklyExercises) { newValue in + .onChange(of: weeklyExercises) { _, newValue in withAnimation { if let id = newValue.first(where: { $0.exercises.first?.baseExercise.dueDate ?? .tomorrow > .now })?.id { value.scrollTo(id, anchor: .top) diff --git a/feature/CourseView/Sources/CourseView/ExerciseTab/SubmissionResultStatusView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/SubmissionResultStatusView.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/ExerciseTab/SubmissionResultStatusView.swift rename to ArtemisKit/Sources/CourseView/ExerciseTab/SubmissionResultStatusView.swift diff --git a/feature/CourseView/Sources/CourseView/ExerciseTab/SubmissionResultView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/SubmissionResultView.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/ExerciseTab/SubmissionResultView.swift rename to ArtemisKit/Sources/CourseView/ExerciseTab/SubmissionResultView.swift diff --git a/feature/CourseView/Sources/CourseView/LectureTab/LectureAttachmentSheet.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureAttachmentSheet.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/LectureTab/LectureAttachmentSheet.swift rename to ArtemisKit/Sources/CourseView/LectureTab/LectureAttachmentSheet.swift diff --git a/feature/CourseView/Sources/CourseView/LectureTab/LectureDetailView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift similarity index 98% rename from feature/CourseView/Sources/CourseView/LectureTab/LectureDetailView.swift rename to ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift index eaf1dc0d..e13057e9 100644 --- a/feature/CourseView/Sources/CourseView/LectureTab/LectureDetailView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailView.swift @@ -134,7 +134,7 @@ private struct LectureUnitCell: View { let course = viewModel.course.value { ExerciseListCell(course: course, exercise: exercise) } else { - Text(R.string.localizable.exercisecouldNotBeLoaded()) + Text(R.string.localizable.exerciseCouldNotBeLoaded()) .artemisStyleCard() } case .unknown: @@ -317,7 +317,7 @@ struct VideoUnitSheetContent: View { Link(R.string.localizable.openVideo(), destination: url) .buttonStyle(ArtemisButton()) } else { - Text(R.string.localizable.videoCanNotBeLoaded()) + Text(R.string.localizable.videoCouldNotBeLoaded()) .foregroundColor(.red) } }.padding(.l) diff --git a/feature/CourseView/Sources/CourseView/LectureTab/LectureDetailViewModel.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/LectureTab/LectureDetailViewModel.swift rename to ArtemisKit/Sources/CourseView/LectureTab/LectureDetailViewModel.swift diff --git a/feature/CourseView/Sources/CourseView/LectureTab/LectureListView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift similarity index 58% rename from feature/CourseView/Sources/CourseView/LectureTab/LectureListView.swift rename to ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift index e66e205f..0b44bd2c 100644 --- a/feature/CourseView/Sources/CourseView/LectureTab/LectureListView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift @@ -5,10 +5,10 @@ // Created by Sven Andabaka on 27.04.23. // -import SwiftUI -import SharedModels -import Navigation import DesignLibrary +import Navigation +import SharedModels +import SwiftUI struct LectureListView: View { @@ -20,32 +20,33 @@ struct LectureListView: View { if searchText.isEmpty { return [] } - return (viewModel.course.value?.lectures ?? []).filter { ($0.title ?? "").lowercased().contains(searchText.lowercased()) } + return (viewModel.course.value?.lectures ?? []).filter { + ($0.title ?? "").lowercased().contains(searchText.lowercased()) + } } private var weeklyLectures: [WeeklyLecture] { - var groupedDates = [WeeklyLectureId: [Lecture]]() - - viewModel.course.value?.lectures?.forEach { lecture in + let groupedDates = Dictionary(grouping: viewModel.course.value?.lectures ?? []) { lecture in var week: Int? var year: Int? if let dueDate = lecture.startDate { week = Calendar.current.component(.weekOfYear, from: dueDate) year = Calendar.current.component(.year, from: dueDate) } - - let weeklyLectureId = WeeklyLectureId(week: week, year: year) - - if groupedDates[weeklyLectureId] == nil { - groupedDates[weeklyLectureId] = [lecture] - } else { - groupedDates[weeklyLectureId]?.append(lecture) - } + return WeeklyLectureId(week: week, year: year) } - return groupedDates.map { week in - WeeklyLecture(id: week.key, lectures: week.value.sorted(by: { $0.title?.lowercased() ?? "" < $1.title?.lowercased() ?? "" })) - }.sorted(by: { $0.id.startOfWeek ?? .distantFuture < $1.id.startOfWeek ?? .distantFuture }) + let weeklyLectures = groupedDates + .map { week in + let lectures = week.value.sorted { + $0.startDate ?? .now < $1.startDate ?? .now + } + return WeeklyLecture(id: week.key, lectures: lectures) + } + .sorted { + $0.id.startOfWeek ?? .distantFuture < $1.id.startOfWeek ?? .distantFuture + } + return weeklyLectures } var body: some View { @@ -54,43 +55,45 @@ struct LectureListView: View { if searchText.isEmpty { ForEach(weeklyLectures) { weeklyLecture in if let course = viewModel.course.value { - LectureListSection(course: course, weeklyLecture: weeklyLecture) + LectureListSectionView(course: course, weeklyLecture: weeklyLecture) } } } else { if searchResults.isEmpty { - Text("There is no result for your search.") - .padding(.l) + ContentUnavailableView.search(text: searchText) .listRowSeparator(.hidden) } else { ForEach(searchResults) { lecture in if let course = viewModel.course.value { - LectureListCell(course: course, lecture: lecture) + LectureListCellView(course: course, lecture: lecture) } } } } } - .listStyle(PlainListStyle()) - .onChange(of: weeklyLectures) { newValue in - withAnimation { - if let id = newValue.first(where: { $0.lectures.first?.startDate ?? .tomorrow > .now })?.id { - value.scrollTo(id, anchor: .top) - } + .listStyle(.plain) + .onChange(of: weeklyLectures) { _, newValue in + withAnimation { + let lecture = newValue.first { + $0.lectures.first?.startDate ?? .tomorrow > .now + } + if let id = lecture?.id { + value.scrollTo(id, anchor: .top) } } + } } } } -struct LectureListSection: View { +private struct LectureListSectionView: View { private let course: Course private let weeklyLecture: WeeklyLecture @State private var isExpanded: Bool - fileprivate init(course: Course, weeklyLecture: WeeklyLecture) { + init(course: Course, weeklyLecture: WeeklyLecture) { self.course = course self.weeklyLecture = weeklyLecture @@ -102,20 +105,23 @@ struct LectureListSection: View { } var body: some View { - DisclosureGroup("\(weeklyLecture.id.description) (Exercises: \(weeklyLecture.lectures.count))", - isExpanded: $isExpanded) { + DisclosureGroup( + R.string.localizable.lecturesGroupTitle(weeklyLecture.id.description, weeklyLecture.lectures.count), + isExpanded: $isExpanded + ) { LazyVStack(spacing: .m) { ForEach(weeklyLecture.lectures, id: \.id) { lecture in - LectureListCell(course: course, lecture: lecture) + LectureListCellView(course: course, lecture: lecture) } - }.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: .l)) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: .l)) } - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: .m, leading: .l, bottom: .m, trailing: .l)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: .m, leading: .l, bottom: .m, trailing: .l)) } } -struct LectureListCell: View { +private struct LectureListCellView: View { @EnvironmentObject var navigationController: NavigationController @@ -142,22 +148,31 @@ struct LectureListCell: View { if let startDate = lecture.startDate { Text("\(startDate.dateOnly) (\(startDate.relative ?? "?"))") } else { - Text("No due date") + Text(R.string.localizable.noDueDate()) } } - .frame(maxWidth: .infinity) - .padding(.l) - .cardModifier(backgroundColor: Color.Artemis.exerciseCardBackgroundColor, - hasBorder: true, - borderColor: Color.Artemis.artemisBlue, - cornerRadius: 2) - .onTapGesture { - navigationController.path.append(LecturePath(lecture: lecture, coursePath: CoursePath(course: course))) - } + .frame(maxWidth: .infinity) + .padding(.l) + .cardModifier( + backgroundColor: Color.Artemis.exerciseCardBackgroundColor, + hasBorder: true, + borderColor: Color.Artemis.artemisBlue, + cornerRadius: 2 + ) + .onTapGesture { + navigationController.path.append(LecturePath(lecture: lecture, coursePath: CoursePath(course: course))) + } } } -private struct WeeklyLectureId: Identifiable, Hashable { +// MARK: - WeeklyLecture + +private struct WeeklyLecture: Identifiable, Hashable { + let id: WeeklyLectureId + var lectures: [Lecture] +} + +private struct WeeklyLectureId: Hashable, Identifiable { let week: Int? let year: Int? @@ -170,12 +185,16 @@ private struct WeeklyLectureId: Identifiable, Hashable { } var description: String { - guard let startOfWeek, let endOfWeek else { return "No date associated" } + guard let startOfWeek, let endOfWeek else { + return R.string.localizable.noDateAssociated() + } return "\(startOfWeek.dateOnly) - \(endOfWeek.dateOnly)" } var startOfWeek: Date? { - guard let week, let year else { return nil } + guard let week, let year else { + return nil + } var dateComponents = DateComponents() dateComponents.yearForWeekOfYear = year @@ -185,12 +204,9 @@ private struct WeeklyLectureId: Identifiable, Hashable { } var endOfWeek: Date? { - guard let startOfWeek else { return nil } + guard let startOfWeek else { + return nil + } return Calendar.current.date(byAdding: .day, value: 6, to: startOfWeek) } } - -private struct WeeklyLecture: Identifiable, Hashable { - let id: WeeklyLectureId - var lectures: [Lecture] -} diff --git a/iosApp/Assets.xcassets/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/Contents.json similarity index 100% rename from iosApp/Assets.xcassets/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/circle-check-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/circle-check-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/circle-check-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-check-solid.imageset/circle-check-solid.svg diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/circle-exclamation-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/circle-exclamation-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/circle-exclamation-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-exclamation-solid.imageset/circle-exclamation-solid.svg diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/circle-notch-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/circle-notch-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/circle-notch-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-notch-solid.imageset/circle-notch-solid.svg diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/circle-question-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/circle-question-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/circle-question-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-question-solid.imageset/circle-question-solid.svg diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/circle-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/circle-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/circle-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-solid.imageset/circle-solid.svg diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/Contents.json similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/Contents.json rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/Contents.json diff --git a/feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/circle-xmark-solid.svg b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/circle-xmark-solid.svg similarity index 100% rename from feature/CourseView/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/circle-xmark-solid.svg rename to ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/circle-xmark-solid.imageset/circle-xmark-solid.svg diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/Contents.json new file mode 100644 index 00000000..6aca7d89 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "badge_checkmark_light.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "badge_checkmark_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_dark.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..76234012af7864f85e4a82e1af4b2b82791d0983 GIT binary patch literal 8016 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 Od26U>E8`TcLjDi&=6NCj literal 0 HcmV?d00001 diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_light.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/checkmark-badge.imageset/badge_checkmark_light.png new file mode 100644 index 0000000000000000000000000000000000000000..1c4924e601daf7ac8c2db9d693911db704d0d15f GIT binary patch literal 9586 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 literal 0 HcmV?d00001 diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/Contents.json new file mode 100644 index 00000000..4ea11c4d --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "badge_exclamation_light.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "badge_exclamation_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_dark.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1e15d6bf9a6e38426c67f20353a90b9044d138fb GIT binary patch literal 6471 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 L!Z2U|Y|uXdqpqcL literal 0 HcmV?d00001 diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_light.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/exclamation-badge.imageset/badge_exclamation_light.png new file mode 100644 index 0000000000000000000000000000000000000000..0793586fc17b6ea3e001f9eeb9d39863bc3c3d11 GIT binary patch literal 6224 zcmcIo2{@E{+n+HZV~waRnTA9evzQq(6E&7>S>}~abyPfN24k7g%-EI2K^xjATb3+Q z){sa@g%%-FO32a_M}*L!ypI;|Ip5WJ&-uRhd*`}lp6B^L_y1mg_wT;{_ch08b{5ib zWjF)^k+!lla{#ZWz;EnwG4Qu1Nw5RFNcdXr;Xxph3X5MTBq?PzxHiOc+|A!@YeS)P zeDnYY$CarU=;I4YLm-A*1APIy7n6^0WxBK2M#!-nwMYbuVT9a8u*KN=nle3Dmcd-+ zj$k`SdaxIr%s_74f-nrEfC_w=d;k&XF)3 zOGB7)xJ(2=kBFjU@fd^wSr1FZ;0ah=1P+5WKw}7K3=xIFQZOV64vYBqL4ww}3^$5{ znfW(c;FA&3gU|P+pwR&V0eS)YdK|7h8cQaV(HI;WheLr9C|(eo4+NsvJdN)f%$Phn zm*vZ6aoC7Ojesl1k8gwoJ^dPjkMELNHt!ouAYkY~z!#0x!z_mMMUX*XlJoWBdVev_ zpre`IOdlqj&jV$#OR~Nm96pEV!TA%_ORxW60EE`ocFD#Meev;GGQs0-^9RHD2FMSk zd5%H8Otb@&$MNIRncMt9H#NRT!{a+J|Apt@*ba(++3ZU(3mOK!)BpXt zm8mI>%W-3QgD-dv7A6R*ZKgylnMg$8^srytwY8;Kv3Y!eO=nt}86m+~^;j$h1%t=o z^ck)Iia^pgK;dyreH4j^C!rX4S0bH2!r(AO()ayl9J=2ke-`(DEhPqr4(j-qdki9; zK?Fe5NOTeoMI_^0QDmGe9fct<=_G(oCo{41@7nCSEU=mZ@84@(^ojv$B)MXlL=4ja z#lRBXP&qcAuEf$ZiAFbx3AV$FP^A;pr#19KeotqONA4}81xW+A>Xj{?va zE6NB-Ut|N5f&8}2`T-t)%ksDV0Uk_H>AzvgSL-~E8$STxGL79q?EaF4=s$Iy!}e!# zf9Jk0zy-iY#^myhkZxR#4`PWi2tEe^`1*RY=)hv&Xn!{2TeDb774lE*@BsgR4-whT zOvtUZJy`rz`)azE;4ak6|exWBI~|EekUr z)icT)j>mf`vd-7t|M^b&_HmVd*ugrvaXlB6R@kyS)p4Y+OoFJ=;VRK4c%8xF6)s~t zKflw)z(adFA{C7q@igkp#MrIm#RgO)H$N!tBSDbA413RMd& zg3T!E+eo2i@y(YDBvih2Qk5|ZXsvpa87D%t44s?-Fk&YK67+T|44y)gYq`EZ567h3UKmfQ z7H@8r^s?3p&brX0oSY!MrEb?}X}qxURR&bH&ZURWt+~HJeNOTG>rJCIRg$ykL~~1r z*7X&-K?-$$BF`2n%ob`2H!m9==HwHm{5L;5KWsQZz>D+p+&TX9Vanb)N6+JpC-+_4 zE!pIE&nsNmEGIp<_mJV1=GnXJMV|D_cgut}i*!`BRMdX#x5;SOx^?qCYDI--Y57Ha z&GZLn=jSGOMYo%<#I;|9uA?PQmMX%BAO<;?o+<~2o?i$ClB{o{sB@4;k&d9py~;r> zyHsaQp=7@lRZSw&y3QuU{EiQbs;2$%wd7sTx=lNnsyfH?1sQ^9jl{jV+XQbx5ACa8 zOO{iy&X-GF*Yzt)oRNn=fjmh~j~30H%(}a7#d7>9v)?-I)I^oRR+f>FGE=iS>+SjL zR!T}ts9xkJUxj2t5WC0F7wA%1ZPY@W zT%{bp)sYfj^$z(0vx}9=UiFXPnJK&$oIzN=^|iPns(lxj=OVEZD@^28ceVozWZR1O z$(RB_`~0gXZ@F>)rCVs8HVM(aCpxJWW3*hcYf9&awQVA#$0_ez6J#=r^EdhEXgt{> zhg_j<35`9q-08k$t;AWA^!}OfQw|zFNbD!GN-Iq- zapc^+>zb*XyjQ0MD`+W~W7HPXCR9gYd!)<69QHx$r(tXQVCypkWet8=9|Vut^*v3n z^?fihpx30dqunMhN|L82##_5lE6Qx3rf}fdxy}xlS)iV|eP_%QX?Q@kYi4oT2}r1Y zcPS;(>h*Q>`&)OuIQ9APAX1!LoJWXAxPk<*6%@Fl!VWwz8qtiDGAv zp9_o%74ifklC&iJDY^G!c0N+)K?O?bGBQ;^S9@3-e|F_{k~y+1eLsQ+=JYzU2P%1- zDXn>N%QZ-LV}xMj{xkVw5A-i)M|bNmYZQ0B2MUh7HDBpwn*c?A z4tuz-x;$D@tL$fma~q{KMGI9HSVn~_J0r69&&g44JTT4aueT)B?e(MF66xrY9kpH) zvZD(QR1*NTjmL}Sd*ma7Vvlx-KE5uWe9SJR%&|Tj0VdCy)nR3n1DOikM13#&TbY;kT^0B8mrR~gkcL!-hx#!LPJphQ~19AEHCj;A_ zU%v&T(pOlU@4wr0FL1$rff-3{p$h;+`lkv8u; zK8(GprE3c@u5Kse0oSWa2{~a>qLyB^M2$AA;+A;0z1O?dyeG}cUvhEn1~S7^R>p^1xC%Q+~I_p&^Nk`j~*F(-c_FFm&=lx^5CoYR0Jf5PjikZ5%|ki zmq4<&uk}nQRm$7cY;WH@ak5bly9=(p6L#4z@^Q@YO}W`rMDl^aM%uZ#8K3@6+sfRS zh}vcBU#2$X*us|gjaw%fvzoV`T_0Op)pVk0RiaLZZ?=nRQDbaOkUwV>pr_S7{v=c$ zuXd|T%LU*@a(DvZNiCN>@lfzJ~AM8fDu!=GT#qUN}~Cj>%vpkLL)v0lpc zn?V0G#7Phei>-_bQWljtY5UODN9F$2yk3LgPkV0@Y?2UX$bnkFofNSUY~D4=EXYvOmJ5G4 zydkMs}+=} zN8?4*RSxs9xh^=pSf~I&CsNW1##_(2lRpKYb|6rXnj7@PAIR4)ySeAy%@B5<{fyMv z56IHTvX{CliO}>SSlHA4UWkQr(t!G*Dfsqymu!t2nFlrT*_ZoMM`5AwZab}hH~Hej z>xJXR%^LB|AGB8Twmg$bb+>Dos94}enGouDg=th}80@Y(31E~g~4VSxXN zAT_b~6!}Lj=Q2%#L2sLWdo3w+yT7-{_)+QI-!6Z)RZ6cYD$`_SRd3l7P;xby0Uef0 zWY-i2rH@oq8%s|GRQMRAT?LJ#cpe+6nBp3FhCQK{GWYGeadLn0qHT7~xM1{?5xi59FUA_8r zHur+2-6bi%Z&Q7doDEv(^fv5G6}U~sF#OhS4LOgILYam zV^{6H_0ZwMmC;RSBW{oibvwImW*@N_tBuc?9*>62WceM4VP7s1Puf1ISTi{#nyOlB zQX%ZWabYbh+>3gMB z@0m_I#1_ztbjL@wi{_m73`FYwQazSq7*La$=0x-#pa9!{g`XR9uY1XIc5J^!yitcf z`pji@>Ai0E`tT%TW`Rs~ZHC$z8?|5cnZ~wi69ERVB4X*-9$vKX$Rp@krN=S}aARfY zbm6VNF|JK`8A)2%Y+Zwxjkt0STcy=>cX)!BmU5k{SgMBVCntlTHeKpZ`Lc|miVX|m Yg;kxV#DGt~F8=3lwaw0~(8MMDpY2{92><{9 literal 0 HcmV?d00001 diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/Contents.json b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/Contents.json new file mode 100644 index 00000000..39be23e9 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "badge_xmark_light.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "badge_xmark_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_dark.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..19501e71e76f7ccbecbacab4d191a4d4dd867902 GIT binary patch literal 5913 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 literal 0 HcmV?d00001 diff --git a/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_light.png b/ArtemisKit/Sources/CourseView/Resources/Assets.xcassets/modeling/xmark-badge.imageset/badge_xmark_light.png new file mode 100644 index 0000000000000000000000000000000000000000..6dd8b3ed0eb36b2ee5c9080c6cafa354b182dc8a GIT binary patch literal 5376 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 Participation + + func getLatestSubmission(participationId: Int) async throws -> Submission + + func createSubmission(exerciseId: Int, submission: BaseSubmission) async throws + + func updateSubmission(exerciseId: Int, submission: BaseSubmission) async throws +} + +// TODO: Add ExerciseSubmission for all other exercise types +enum ExerciseSubmissionServiceFactory { + static func service(for exercise: Exercise) -> ExerciseSubmissionService { + switch exercise { + case .modeling: + return ModelingExerciseSubmissionServiceImpl() + default: + return UnknownExerciseSubmissionServiceImpl() + } + } +} diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/ModelingExerciseSubmissionServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ModelingExerciseSubmissionServiceImpl.swift new file mode 100644 index 00000000..e1ff44fb --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/ModelingExerciseSubmissionServiceImpl.swift @@ -0,0 +1,101 @@ +// +// ModelingExerciseSubmissionServiceImpl.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import APIClient +import SharedModels + +class ModelingExerciseSubmissionServiceImpl: 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 + } + + struct GetLatestSubmissionRequest: APIRequest { + typealias Response = Submission + + let participationId: Int + + var method: HTTPMethod { + .get + } + + var resourceName: String { + "api/participations/\(participationId)/latest-modeling-submission" + } + } + + func getLatestSubmission(participationId: Int) async throws -> Submission { + try await client.sendRequest(GetLatestSubmissionRequest(participationId: participationId)).get().0 + } + + struct CreateSubmissionRequest: APIRequest { + typealias Response = Submission + + let exerciseId: Int + let modelingDTO: ModelingSubmission + + var method: HTTPMethod { + .post + } + + var body: Encodable? { + modelingDTO + } + + var resourceName: String { + "api/exercises/\(exerciseId)/modeling-submissions" + } + } + + func createSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + guard let modelingDTO = submission as? ModelingSubmission else { + return + } + _ = try await client.sendRequest(CreateSubmissionRequest(exerciseId: exerciseId, modelingDTO: modelingDTO)).get() + } + + struct PutSubmissionRequest: APIRequest { + typealias Response = Submission + + let exerciseId: Int + let modelingDTO: ModelingSubmission + + var method: HTTPMethod { + .put + } + + var body: Encodable? { + modelingDTO + } + + var resourceName: String { + "api/exercises/\(exerciseId)/modeling-submissions" + } + } + + func updateSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + guard let modelingDTO = submission as? ModelingSubmission else { + return + } + _ = try await client.sendRequest(PutSubmissionRequest(exerciseId: exerciseId, modelingDTO: modelingDTO)).get() + } +} diff --git a/ArtemisKit/Sources/CourseView/Services/ExerciseService/UnknownExerciseSubmissionServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/ExerciseService/UnknownExerciseSubmissionServiceImpl.swift new file mode 100644 index 00000000..1808578c --- /dev/null +++ b/ArtemisKit/Sources/CourseView/Services/ExerciseService/UnknownExerciseSubmissionServiceImpl.swift @@ -0,0 +1,29 @@ +// +// UnknownExerciseSubmissionServiceImpl.swift +// +// +// Created by Alexander Görtzen on 21.11.23. +// + +import Common +import SharedModels + +class UnknownExerciseSubmissionServiceImpl: ExerciseSubmissionService { + typealias SubmissionType = UnknownSubmission + + func startParticipation(exerciseId: Int) async throws -> Participation { + throw UserFacingError(title: "Not supported") + } + + func getLatestSubmission(participationId: Int) async throws -> Submission { + throw UserFacingError(title: "Not supported") + } + + func createSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + throw UserFacingError(title: "Not supported") + } + + func updateSubmission(exerciseId: Int, submission: BaseSubmission) async throws { + throw UserFacingError(title: "Not supported") + } +} diff --git a/feature/CourseView/Sources/CourseView/Services/LectureService/LectureService.swift b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/Services/LectureService/LectureService.swift rename to ArtemisKit/Sources/CourseView/Services/LectureService/LectureService.swift diff --git a/feature/CourseView/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift b/ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift similarity index 100% rename from feature/CourseView/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift rename to ArtemisKit/Sources/CourseView/Services/LectureService/LectureServiceImpl.swift diff --git a/ArtemisKit/Sources/Dashboard/CourseGridView.swift b/ArtemisKit/Sources/Dashboard/CourseGridView.swift new file mode 100644 index 00000000..50cb3e7e --- /dev/null +++ b/ArtemisKit/Sources/Dashboard/CourseGridView.swift @@ -0,0 +1,168 @@ +// +// CourseGridView.swift +// +// +// Created by Nityananda Zbil on 29.11.23. +// + +import CourseRegistration +import DesignLibrary +import Navigation +import SharedModels +import SwiftUI + +struct CourseGridView: View { + + private static let layout = [GridItem(.adaptive(minimum: 400, maximum: .infinity), spacing: .l, alignment: .center)] + + @ObservedObject var viewModel: DashboardViewModel + @State private var isCourseRegistrationPresented = false + + var body: some View { + DataStateView(data: $viewModel.coursesForDashboard) { + await viewModel.loadCourses() + } content: { coursesForDashboard in + ScrollView { + LazyVGrid(columns: Self.layout, spacing: .l) { + ForEach(coursesForDashboard.courses ?? [], content: CourseGridCellView.init(courseForDashboard:)) + } + .padding(.horizontal, .l) + + HStack { + Spacer() + Button(R.string.localizable.dashboardRegisterForCourseButton()) { + isCourseRegistrationPresented = true + } + .buttonStyle(ArtemisButton()) + Spacer() + } + } + .refreshable { + await viewModel.loadCourses() + } + } + .sheet(isPresented: $isCourseRegistrationPresented) { + CourseRegistrationView { + isCourseRegistrationPresented = false + viewModel.coursesForDashboard = .loading + Task { + await viewModel.loadCourses() + } + } + } + .task { + await viewModel.loadCourses() + } + } +} + +private struct CourseGridCellView: View { + + @EnvironmentObject private var navigationController: NavigationController + + let courseForDashboard: CourseForDashboardDTO + + var nextExercise: Exercise? { + // filters out every already successful (100%) exercise, only exercises left that still need work + let exercisesWithOpenTasks = courseForDashboard.course.upcomingExercises.filter { exercise in + guard let participation = exercise.baseExercise.studentParticipations?.first, + let submission = participation.baseParticipation.submissions?.first, + let result = submission.baseSubmission.results?.first else { + return false + } + return !(result?.successful ?? false) + } + return exercisesWithOpenTasks.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + statistics + footer + } + .cardModifier(backgroundColor: .clear, hasBorder: true) + .onTapGesture { + navigationController.path.append(CoursePath(course: courseForDashboard.course)) + } + } +} + +private extension CourseGridCellView { + 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() + } + } + .frame(height: .extraLargeImage) + .padding([.leading, .vertical], .m) + VStack(alignment: .leading, spacing: 0) { + Text(courseForDashboard.course.title ?? "") + .font(.custom("SF Pro", size: 21, relativeTo: .title)) + .lineLimit(2) + Text(R.string.localizable.dashboardExercisesLabel(courseForDashboard.course.exercises?.count ?? 0)) + Text(R.string.localizable.dashboardLecturesLabel(courseForDashboard.course.lectures?.count ?? 0)) + } + .foregroundStyle(.white) + .padding(.m) + Spacer() + } + .frame(maxWidth: .infinity) + .background(courseForDashboard.course.courseColor) + } + + var statistics: some View { + HStack { + Spacer() + if let totalScore = courseForDashboard.totalScores { + ProgressBar( + value: Int(totalScore.studentScores.absoluteScore), + total: Int(totalScore.reachablePoints)) + .frame(height: 120) + .padding(.vertical, .l) + } else { + Text(R.string.localizable.dashboardNoStatisticsAvailable()) + } + Spacer() + } + .padding(.vertical, .m) + } + + var footer: some View { + HStack { + if let nextExercise, + let nextExerciseTitle = nextExercise.baseExercise.title { + HStack { + Text(R.string.localizable.dashboardNextExerciseLabel()) + .padding(.trailing, .m) + nextExercise.image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: .extraSmallImage) + Text(nextExerciseTitle) + .bold() + .lineLimit(1) + } + .padding(.l) + } else { + Text(R.string.localizable.dashboardNoExercisePlannedLabel()) + .padding(.l) + } + Spacer() + } + .frame(maxWidth: .infinity) + .background(Color.Artemis.dashboardCardBackgroundColor) + .foregroundColor(Color.Artemis.secondaryLabel) + } +} diff --git a/ArtemisKit/Sources/Dashboard/DashboardView.swift b/ArtemisKit/Sources/Dashboard/DashboardView.swift new file mode 100644 index 00000000..b4bf96bf --- /dev/null +++ b/ArtemisKit/Sources/Dashboard/DashboardView.swift @@ -0,0 +1,34 @@ +import Account +import Common +import CourseRegistration +import CourseView +import DesignLibrary +import Navigation +import Notifications +import SharedModels +import SwiftUI + +/// Display the course grid. +public struct DashboardView: View { + + @StateObject private var viewModel = DashboardViewModel() + + public init() {} + + public var body: some View { + CourseGridView(viewModel: viewModel) + .navigationTitle(Text(R.string.localizable.dashboardTitle())) + .navigationBarBackButtonHidden() + .accountMenu(error: Binding( + get: { + viewModel.error + }, set: { error in + if let error { + viewModel.presentError(userFacingError: error) + } + }) + ) + .notificationToolbar() + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } +} diff --git a/ArtemisKit/Sources/Dashboard/DashboardViewModel.swift b/ArtemisKit/Sources/Dashboard/DashboardViewModel.swift new file mode 100644 index 00000000..04789cd3 --- /dev/null +++ b/ArtemisKit/Sources/Dashboard/DashboardViewModel.swift @@ -0,0 +1,21 @@ +import Common +import Foundation +import SharedModels +import SharedServices + +class DashboardViewModel: BaseViewModel { + + @Published var coursesForDashboard: DataState = DataState.loading + + override init() { + super.init() + + Task { + await loadCourses() + } + } + + func loadCourses() async { + coursesForDashboard = await CourseServiceFactory.shared.getCourses() + } +} diff --git a/ArtemisKit/Sources/Dashboard/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Dashboard/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..575e01ac --- /dev/null +++ b/ArtemisKit/Sources/Dashboard/Resources/en.lproj/Localizable.strings @@ -0,0 +1,7 @@ +"dashboardExercisesLabel" = "Exercises: %d"; +"dashboardLecturesLabel" = "Lectures: %d"; +"dashboardRegisterForCourseButton" = "Course Enrollment"; +"dashboardTitle" = "Courses"; +"dashboardNextExerciseLabel" = "Next Exercise:"; +"dashboardNoExercisePlannedLabel" = "No exercise planned"; +"dashboardNoStatisticsAvailable" = "No statistics available"; diff --git a/feature/Messages/Sources/Messages/Models/ConversationWebsocketDTO.swift b/ArtemisKit/Sources/Messages/Models/ConversationWebsocketDTO.swift similarity index 72% rename from feature/Messages/Sources/Messages/Models/ConversationWebsocketDTO.swift rename to ArtemisKit/Sources/Messages/Models/ConversationWebsocketDTO.swift index f94a15cc..d8c662c9 100644 --- a/feature/Messages/Sources/Messages/Models/ConversationWebsocketDTO.swift +++ b/ArtemisKit/Sources/Messages/Models/ConversationWebsocketDTO.swift @@ -5,10 +5,9 @@ // Created by Sven Andabaka on 11.05.23. // -import Foundation import SharedModels -enum MetisPostAction: String, RawRepresentable, Codable { +enum MetisCrudAction: String, RawRepresentable, Codable { case create = "CREATE" case update = "UPDATE" case delete = "DELETE" @@ -17,5 +16,5 @@ enum MetisPostAction: String, RawRepresentable, Codable { struct ConversationWebsocketDTO: Codable { let conversation: Conversation - let metisCrudAction: MetisPostAction + let action: MetisCrudAction } diff --git a/feature/Messages/Sources/Messages/Models/MessageWebsocketDTO.swift b/ArtemisKit/Sources/Messages/Models/MessageWebsocketDTO.swift similarity index 52% rename from feature/Messages/Sources/Messages/Models/MessageWebsocketDTO.swift rename to ArtemisKit/Sources/Messages/Models/MessageWebsocketDTO.swift index d45d4398..1cadfa5d 100644 --- a/feature/Messages/Sources/Messages/Models/MessageWebsocketDTO.swift +++ b/ArtemisKit/Sources/Messages/Models/MessageWebsocketDTO.swift @@ -5,10 +5,13 @@ // Created by Sven Andabaka on 11.05.23. // -import Foundation import SharedModels struct MessageWebsocketDTO: Codable { let post: Message - let action: MetisPostAction + let action: MetisCrudAction + let notification: Notification? } + +// Used in the web client's notification service. +struct Notification: Codable {} diff --git a/ArtemisKit/Sources/Messages/Models/ResponsibleUserDTO.swift b/ArtemisKit/Sources/Messages/Models/ResponsibleUserDTO.swift new file mode 100644 index 00000000..695278ee --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/ResponsibleUserDTO.swift @@ -0,0 +1,11 @@ +// +// ResponsibleUserDTO.swift +// +// +// Created by Nityananda Zbil on 28.09.23. +// + +struct ResponsibleUserDTO: Codable { + let email: String + let name: String +} diff --git a/ArtemisKit/Sources/Messages/Networking/WebSocketTopic.swift b/ArtemisKit/Sources/Messages/Networking/WebSocketTopic.swift new file mode 100644 index 00000000..7e077def --- /dev/null +++ b/ArtemisKit/Sources/Messages/Networking/WebSocketTopic.swift @@ -0,0 +1,31 @@ +// +// WebSocketTopic.swift +// +// +// Created by Nityananda Zbil on 09.02.24. +// + +enum WebSocketTopic { + /// Makes a topic for notifications through course-wide channels. + /// + /// E.g., notifications for writing in a channel. + static func makeChannelNotifications(courseId: Int) -> String { + "/topic/metis/courses/\(courseId)" + } + + /// Makes a topic for conversation notifications of a user. + /// + /// E.g., notifications for writing in a group chat. + static func makeConversationNotifications(userId: Int64) -> String { + "/topic/user/\(userId)/notifications/conversations" + } + + // MARK: - User space + + /// Makes a topic for membership notifications of a user in a course. + /// + /// E.g., notifications for starting a group chat. + static func makeConversationMembershipNotifications(courseId: Int, userId: Int64) -> String { + "/user/topic/metis/courses/\(courseId)/conversations/user/\(userId)" + } +} diff --git a/ArtemisKit/Sources/Messages/Resources/Assets.xcassets/Contents.json b/ArtemisKit/Sources/Messages/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/feature/Messages/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/Contents.json b/ArtemisKit/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/Contents.json similarity index 100% rename from feature/Messages/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/Contents.json rename to ArtemisKit/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/Contents.json diff --git a/feature/Messages/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/face-smile-regular.svg b/ArtemisKit/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/face-smile-regular.svg similarity index 100% rename from feature/Messages/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/face-smile-regular.svg rename to ArtemisKit/Sources/Messages/Resources/Assets.xcassets/face-smile.imageset/face-smile-regular.svg diff --git a/feature/Messages/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings similarity index 94% rename from feature/Messages/Sources/Messages/Resources/en.lproj/Localizable.strings rename to ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 8d76a8c1..ab17c4de 100644 --- a/feature/Messages/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -2,6 +2,8 @@ // MARK: MessagesTabView "archived" = "(Archived)"; +"codeOfConduct" = "Code of Conduct"; +"acceptCodeOfConductButtonLabel" = "Accept"; // MARK: SendMessageView "exercise" = "Exercise"; @@ -21,6 +23,8 @@ "hiddenSection" = "Hidden"; "favorite" = "Favorite"; "unfavorite" = "Unfavorite"; +"mute" = "Mute"; +"unmute" = "Unmute"; "hide" = "Hide"; "show" = "Show"; "channels" = "Channels"; @@ -44,7 +48,8 @@ "deletionErrorLabel" = "Could not delete message. Try again later."; // MARK: ConversationView -"noMessagesYet" = "There are no messages yet! Write the first message to kickstart this conversation."; +"noMessages" = "No Messages"; +"noMessagesDescription" = "Write the first message to kickstart this conversation."; "replyAction" = "%d reply"; "new" = "New"; @@ -115,3 +120,4 @@ "previous" = "Previous"; "next" = "Next"; "confirm" = "Confirm"; +"done" = "Done"; diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift new file mode 100644 index 00000000..912c138b --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductService.swift @@ -0,0 +1,35 @@ +// +// CodeOfConductService.swift +// +// +// Created by Nityananda Zbil on 26.10.23. +// + +import Common + +protocol CodeOfConductService { + + /** + * Perform a patch request to accept the code of conduct. + */ + func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse + + /** + * Perform a get request to check if the code of conduct is accepted. + */ + func getAgreement(for courseId: Int) async -> DataState + + /** + * Perform a get request for the responsible users. + */ + func getResponsibleUsers(for courseId: Int) async -> DataState<[ResponsibleUserDTO]> + + /** + * Perform a get request for the code of conduct template. + */ + func getTemplate() async -> DataState +} + +enum CodeOfConductServiceFactory { + static let shared: CodeOfConductService = CodeOfConductServiceImpl() +} diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift new file mode 100644 index 00000000..308935df --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceImpl.swift @@ -0,0 +1,89 @@ +// +// CodeOfConductServiceImpl.swift +// +// +// Created by Nityananda Zbil on 26.10.23. +// + +import APIClient +import Common + +class CodeOfConductServiceImpl: CodeOfConductService { + + private let client = APIClient() + + struct AcceptCodeOfConductRequest: APIRequest { + typealias Response = RawResponse + + let courseId: Int + + var method: HTTPMethod { .patch } + var resourceName: String { "api/courses/\(courseId)/code-of-conduct/agreement" } + } + + func acceptCodeOfConduct(for courseId: Int) async -> NetworkResponse { + let result = await client.sendRequest(AcceptCodeOfConductRequest(courseId: courseId)) + switch result { + case .success: + return .success + case .failure(let error): + return .failure(error: error) + } + } + + struct GetAgreementRequest: APIRequest { + typealias Response = Bool + + let courseId: Int + + var method: HTTPMethod { .get } + var resourceName: String { "api/courses/\(courseId)/code-of-conduct/agreement" } + } + + func getAgreement(for courseId: Int) async -> DataState { + let result = await client.sendRequest(GetAgreementRequest(courseId: courseId)) + switch result { + case .success(let (value, _)): + return .done(response: value) + case .failure(let error): + return .init(error: error) + } + } + + struct GetResponsibleUsersRequest: APIRequest { + typealias Response = [ResponsibleUserDTO] + + let courseId: Int + + var method: HTTPMethod { .get } + var resourceName: String { "api/courses/\(courseId)/code-of-conduct/responsible-users" } + } + + func getResponsibleUsers(for courseId: Int) async -> DataState<[ResponsibleUserDTO]> { + let result = await client.sendRequest(GetResponsibleUsersRequest(courseId: courseId)) + switch result { + case .success(let (users, _)): + return .done(response: users) + case .failure(let error): + return .init(error: error) + } + } + + struct GetTemplateRequest: APIRequest { + typealias Response = RawResponse + + var method: HTTPMethod { .get } + + var resourceName: String { "api/files/templates/code-of-conduct" } + } + + func getTemplate() async -> DataState { + let result = await client.sendRequest(GetTemplateRequest()) + switch result { + case .success(let (rawResponse, _)): + return .done(response: rawResponse.rawData) + case .failure(let error): + return .failure(error: .init(error: error)) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageService.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageService.swift new file mode 100644 index 00000000..a277b1ec --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageService.swift @@ -0,0 +1,25 @@ +// +// CodeOfConductStorageService.swift +// +// +// Created by Nityananda Zbil on 24.10.23. +// + +import Common + +protocol CodeOfConductStorageService { + + /** + * Accept the code of conduct's contents locally. + */ + func acceptCodeOfConduct(for courseId: Int, codeOfConduct: String) + + /** + * Get the agreement for the code of conduct's contents locally. + */ + func getAgreement(for courseId: Int, codeOfConduct: String) -> Bool +} + +enum CodeOfConductStorageServiceFactory { + static let shared: CodeOfConductStorageService = CodeOfConductStorageServiceImpl() +} diff --git a/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift new file mode 100644 index 00000000..8990951f --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/CodeOfConductStorageService/CodeOfConductStorageServiceImpl.swift @@ -0,0 +1,31 @@ +// +// CodeOfConductStorageServiceImpl.swift +// +// +// Created by Nityananda Zbil on 26.10.23. +// + +import CryptoKit +import Foundation +import UserStore + +struct CodeOfConductStorageServiceImpl: CodeOfConductStorageService { + + func acceptCodeOfConduct(for courseId: Int, codeOfConduct: String) { + guard let serverHost = UserSession.shared.institution?.baseURL?.absoluteString, + let data = codeOfConduct.data(using: .utf8) else { + return + } + let digest = Data(SHA256.hash(data: data)) + UserDefaults.standard.set(digest, forKey: "\(serverHost)|\(courseId)") + } + + func getAgreement(for courseId: Int, codeOfConduct: String) -> Bool { + guard let serverHost = UserSession.shared.institution?.baseURL?.absoluteString, + let data = codeOfConduct.data(using: .utf8) else { + return false + } + let digest = Data(SHA256.hash(data: data)) + return digest == UserDefaults.standard.data(forKey: "\(serverHost)|\(courseId)") + } +} diff --git a/feature/Messages/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift similarity index 90% rename from feature/Messages/Sources/Messages/Services/MessagesService/MessagesService.swift rename to ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index df198db0..33dedaaf 100644 --- a/feature/Messages/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -14,19 +14,24 @@ import UserStore protocol MessagesService { /** - * Perform a get request for all Conversations of a course to the server. + * Perform a get request for all conversations of a course to the server. */ func getConversations(for courseId: Int) async -> DataState<[Conversation]> /** - * Perform a hide/show post request for a specific Conversations of a specific course to the server. + * Perform an update favorite post request for a specific conversation of a specific course to the server. */ - func hideUnhideConversation(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse + func updateIsConversationFavorite(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse /** - * Perform a set favorite post request for a specific Conversations of a specific course to the server. + * Perform an update muted post request for a specific conversation of a specific course to the server. */ - func setIsFavoriteConversation(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse + func updateIsConversationMuted(for courseId: Int, and conversationId: Int64, isMuted: Bool) async -> NetworkResponse + + /** + * Perform an update hide/show post request for a specific conversation of a specific course to the server. + */ + func updateIsConversationHidden(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse /** * Perform a get request for Messages of a specific conversation in a specific course to the server. diff --git a/feature/Messages/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift similarity index 78% rename from feature/Messages/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift rename to ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index afc0500f..08d83331 100644 --- a/feature/Messages/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -34,65 +34,96 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(GetConversationsRequest(courseId: courseId)) switch result { - case .success((let conversations, _)): + case let .success((conversations, _)): return .done(response: conversations) - case .failure(let error): + case let .failure(error): return DataState(error: error) } } - struct HideUnhideConversationRequest: APIRequest { + struct UpdateIsConversationFavoriteRequest: APIRequest { typealias Response = RawResponse let courseId: Int let conversationId: Int64 - let isHidden: Bool + let isFavorite: Bool var method: HTTPMethod { return .post } var resourceName: String { - return "api/courses/\(courseId)/conversations/\(conversationId)/hidden?isHidden=\(isHidden)" + return "api/courses/\(courseId)/conversations/\(conversationId)/favorite?isFavorite=\(isFavorite)" } } - func hideUnhideConversation(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse { - let result = await client.sendRequest(HideUnhideConversationRequest(courseId: courseId, - conversationId: conversationId, - isHidden: isHidden)) + func updateIsConversationFavorite(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse { + let result = await client.sendRequest( + UpdateIsConversationFavoriteRequest(courseId: courseId, conversationId: conversationId, isFavorite: isFavorite) + ) + switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } - struct SetIsFavoriteConversationRequest: APIRequest { + struct UpdateIsConversationMutedRequest: APIRequest { typealias Response = RawResponse let courseId: Int let conversationId: Int64 - let isFavorite: Bool + let isMuted: Bool var method: HTTPMethod { return .post } var resourceName: String { - return "api/courses/\(courseId)/conversations/\(conversationId)/favorite?isFavorite=\(isFavorite)" + return "api/courses/\(courseId)/conversations/\(conversationId)/muted?isMuted=\(isMuted)" + } + } + + func updateIsConversationMuted(for courseId: Int, and conversationId: Int64, isMuted: Bool) async -> NetworkResponse { + let result = await client.sendRequest( + UpdateIsConversationMutedRequest(courseId: courseId, conversationId: conversationId, isMuted: isMuted) + ) + + switch result { + case .success: + return .success + case let .failure(error): + return .failure(error: error) + } + } + + struct UpdateIsConversationHiddenRequest: APIRequest { + typealias Response = RawResponse + + let courseId: Int + let conversationId: Int64 + let isHidden: Bool + + var method: HTTPMethod { + return .post + } + + var resourceName: String { + return "api/courses/\(courseId)/conversations/\(conversationId)/hidden?isHidden=\(isHidden)" } } - func setIsFavoriteConversation(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse { - let result = await client.sendRequest(SetIsFavoriteConversationRequest(courseId: courseId, - conversationId: conversationId, - isFavorite: isFavorite)) + func updateIsConversationHidden(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse { + let result = await client.sendRequest( + UpdateIsConversationHiddenRequest(courseId: courseId, conversationId: conversationId, isHidden: isHidden) + ) + switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -117,9 +148,9 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(GetMessagesRequest(courseId: courseId, conversationId: conversationId, size: size)) switch result { - case .success((let messages, _)): + case let .success((messages, _)): return .done(response: messages) - case .failure(let error): + case let .failure(error): return DataState(error: error) } } @@ -143,16 +174,14 @@ class MessagesServiceImpl: MessagesService { } func sendMessage(for courseId: Int, conversation: Conversation, content: String) async -> NetworkResponse { - let result = await client.sendRequest(SendMessageRequest(courseId: courseId, - visibleForStudents: true, - displayPriority: .noInformation, - conversation: conversation, - content: content)) + let result = await client.sendRequest( + SendMessageRequest(courseId: courseId, visibleForStudents: true, displayPriority: .noInformation, conversation: conversation, content: content) + ) switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -175,15 +204,14 @@ class MessagesServiceImpl: MessagesService { } func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse { - let result = await client.sendRequest(SendAnswerMessageRequest(resolvesPost: false, - content: content, - post: message, - courseId: courseId)) + let result = await client.sendRequest( + SendAnswerMessageRequest(resolvesPost: false, content: content, post: message, courseId: courseId) + ) switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -209,7 +237,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -235,7 +263,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -265,7 +293,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -295,7 +323,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -317,14 +345,14 @@ class MessagesServiceImpl: MessagesService { } func addReactionToAnswerMessage(for courseId: Int, answerMessage: AnswerMessage, emojiId: String) async -> NetworkResponse { - let result = await client.sendRequest(AddReactionToAnswerMessageRequest(emojiId: emojiId, - answerPost: answerMessage, - courseId: courseId)) + let result = await client.sendRequest( + AddReactionToAnswerMessageRequest(emojiId: emojiId, answerPost: answerMessage, courseId: courseId) + ) switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -346,14 +374,14 @@ class MessagesServiceImpl: MessagesService { } func addReactionToMessage(for courseId: Int, message: Message, emojiId: String) async -> NetworkResponse { - let result = await client.sendRequest(AddReactionToMessageRequest(emojiId: emojiId, - post: message, - courseId: courseId)) + let result = await client.sendRequest( + AddReactionToMessageRequest(emojiId: emojiId, post: message, courseId: courseId) + ) switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -379,7 +407,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -402,9 +430,9 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(GetChannelsOverviewRequest(courseId: courseId)) switch result { - case .success((let channels, _)): + case let .success((channels, _)): return .done(response: channels) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -435,7 +463,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -466,7 +494,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -497,7 +525,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -528,7 +556,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -553,16 +581,14 @@ class MessagesServiceImpl: MessagesService { } func createChannel(for courseId: Int, name: String, description: String?, isPrivate: Bool, isAnnouncement: Bool) async -> DataState { - let result = await client.sendRequest(CreateChannelRequest(courseId: courseId, - name: name, - description: description, - isPublic: !isPrivate, - isAnnouncementChannel: isAnnouncement)) + let result = await client.sendRequest( + CreateChannelRequest(courseId: courseId, name: name, description: description, isPublic: !isPrivate, isAnnouncementChannel: isAnnouncement) + ) switch result { - case .success((let channel, _)): + case let .success((channel, _)): return .done(response: channel) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -586,9 +612,9 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(SearchForUsersRequest(courseId: courseId, searchText: searchText)) switch result { - case .success((let users, _)): + case let .success((users, _)): return .done(response: users) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -616,9 +642,9 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(CreateGroupChatRequest(courseId: courseId, usernames: usernames)) switch result { - case .success((let groupChat, _)): + case let .success((groupChat, _)): return .done(response: groupChat) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -646,9 +672,9 @@ class MessagesServiceImpl: MessagesService { let result = await client.sendRequest(CreateOneToOneChatRequest(courseId: courseId, usernames: usernames)) switch result { - case .success((let oneToOneChat, _)): + case let .success((oneToOneChat, _)): return .done(response: oneToOneChat) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -671,15 +697,14 @@ class MessagesServiceImpl: MessagesService { } func getMembersOfConversation(for courseId: Int, conversationId: Int64, page: Int) async -> DataState<[ConversationUser]> { - let result = await client.sendRequest(GetMembersOfConversationRequest(courseId: courseId, - conversationId: conversationId, - searchText: nil, - page: page)) + let result = await client.sendRequest( + GetMembersOfConversationRequest(courseId: courseId, conversationId: conversationId, searchText: nil, page: page) + ) switch result { - case .success((let users, _)): + case let .success((users, _)): return .done(response: users) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } @@ -705,7 +730,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -731,7 +756,7 @@ class MessagesServiceImpl: MessagesService { switch result { case .success: return .success - case .failure(let error): + case let .failure(error): return .failure(error: error) } } @@ -758,20 +783,25 @@ class MessagesServiceImpl: MessagesService { } func editConversation(for courseId: Int, conversation: Conversation, newName: String?, newTopic: String?, newDescription: String?) async -> DataState { - guard let typePath = conversation.baseConversation.type.path else { return .failure(error: UserFacingError(title: R.string.localizable.unsupportedConversationType()))} + guard let typePath = conversation.baseConversation.type.path else { + return .failure(error: UserFacingError(title: R.string.localizable.unsupportedConversationType())) + } - let result = await client.sendRequest(RenameConversationRequest(courseId: courseId, - conversationId: conversation.id, - type: conversation.baseConversation.type, - typePath: typePath, - name: newName, - topic: newTopic, - description: newDescription)) + let result = await client.sendRequest( + RenameConversationRequest( + courseId: courseId, + conversationId: conversation.id, + type: conversation.baseConversation.type, + typePath: typePath, + name: newName, + topic: newTopic, + description: newDescription) + ) switch result { - case .success((let conversation, _)): + case let .success((conversation, _)): return .done(response: conversation) - case .failure(let error): + case let .failure(error): return .failure(error: UserFacingError(error: error)) } } diff --git a/feature/Messages/Sources/Messages/ViewModels/ConversationInfoSheetViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift similarity index 100% rename from feature/Messages/Sources/Messages/ViewModels/ConversationInfoSheetViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationInfoSheetViewModel.swift diff --git a/feature/Messages/Sources/Messages/ViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift similarity index 92% rename from feature/Messages/Sources/Messages/ViewModels/ConversationViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 90fcdd9f..8cceb13a 100644 --- a/feature/Messages/Sources/Messages/ViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -5,11 +5,12 @@ // Created by Sven Andabaka on 06.04.23. // +import APIClient import Foundation import Common import SharedModels -import APIClient import SharedServices +import UserStore // swiftlint:disable file_length @MainActor @@ -56,20 +57,8 @@ public class ConversationViewModel: BaseViewModel { subscribeToConversationTopic() } - private func subscribeToConversationTopic() { - let topic = "/user/topic/metis/courses/\(courseId)/conversations/\(conversationId)" - if ArtemisStompClient.shared.didSubscribeTopic(topic) { - return - } - websocketSubscriptionTask = Task { - let stream = ArtemisStompClient.shared.subscribe(to: topic) - - for await message in stream { - guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { continue } - - onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) - } - } + deinit { + websocketSubscriptionTask?.cancel() } func loadFurtherMessages() async { @@ -321,8 +310,12 @@ public class ConversationViewModel: BaseViewModel { return false } } +} - private func loadConversation() async { +// MARK: Start (initializer) + +private extension ConversationViewModel { + func loadConversation() async { let result = await MessagesServiceFactory.shared.getConversations(for: courseId) switch result { @@ -339,7 +332,7 @@ public class ConversationViewModel: BaseViewModel { } } - private func loadCourse() async { + func loadCourse() async { let result = await CourseServiceFactory.shared.getCourse(courseId: courseId) switch result { @@ -352,15 +345,40 @@ public class ConversationViewModel: BaseViewModel { } } - deinit { - websocketSubscriptionTask?.cancel() + func subscribeToConversationTopic() { + let topic: String + if conversation.value?.baseConversation.type == .channel { + topic = WebSocketTopic.makeChannelNotifications(courseId: courseId) + } else if let id = UserSession.shared.user?.id { + topic = WebSocketTopic.makeConversationNotifications(userId: id) + } else { + return + } + if ArtemisStompClient.shared.didSubscribeTopic(topic) { + return + } + websocketSubscriptionTask = Task { [weak self] in + let stream = ArtemisStompClient.shared.subscribe(to: topic) + + for await message in stream { + guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { + continue + } + + self?.onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) + } + } } } -// All functions to handle new conversation received socket -extension ConversationViewModel { +// MARK: Receive message - private func onMessageReceived(messageWebsocketDTO: MessageWebsocketDTO) { +private extension ConversationViewModel { + func onMessageReceived(messageWebsocketDTO: MessageWebsocketDTO) { + // Guard message corresponds to conversation + guard messageWebsocketDTO.post.conversation?.id == conversation.value?.id else { + return + } switch messageWebsocketDTO.action { case .create: handleNewMessage(messageWebsocketDTO.post) @@ -373,7 +391,7 @@ extension ConversationViewModel { } } - private func handleNewMessage(_ newMessage: Message) { + func handleNewMessage(_ newMessage: Message) { guard var dailyMessages = dailyMessages.value else { // messages not loaded yet return @@ -393,7 +411,7 @@ extension ConversationViewModel { self.dailyMessages = .done(response: dailyMessages) } - private func handleUpdateMessage(_ updatedMessage: Message) { + func handleUpdateMessage(_ updatedMessage: Message) { guard var dailyMessages = dailyMessages.value else { // messages not loaded yet return @@ -411,7 +429,7 @@ extension ConversationViewModel { self.dailyMessages = .done(response: dailyMessages) } - private func handleDeletedMessage(_ deletedMessage: Message) { + func handleDeletedMessage(_ deletedMessage: Message) { guard var dailyMessages = dailyMessages.value else { // messages not loaded yet return diff --git a/feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/BrowseChannelsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/BrowseChannelsViewModel.swift similarity index 100% rename from feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/BrowseChannelsViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/BrowseChannelsViewModel.swift diff --git a/feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChannelViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChannelViewModel.swift similarity index 100% rename from feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChannelViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChannelViewModel.swift diff --git a/feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChatViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChatViewModel.swift similarity index 100% rename from feature/Messages/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChatViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/CreateConversationViewModels/CreateChatViewModel.swift diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/CodeOfConductViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/CodeOfConductViewModel.swift new file mode 100644 index 00000000..bd405705 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/CodeOfConductViewModel.swift @@ -0,0 +1,48 @@ +// +// CodeOfConductViewModel.swift +// +// +// Created by Nityananda Zbil on 28.10.23. +// + +import Common +import SharedModels +import SwiftUI + +class CodeOfConductViewModel: BaseViewModel { + + let course: Course + let courseId: Int + + @Published var codeOfConduct: DataState = .loading + @Published var responsibleUsers: DataState<[ResponsibleUserDTO]> = .loading + + init(course: Course) { + self.course = course + self.courseId = course.id + + if let courseCodeOfConduct = course.courseInformationSharingMessagingCodeOfConduct, !courseCodeOfConduct.isEmpty { + codeOfConduct = .done(response: courseCodeOfConduct) + } + + super.init() + } + + func getCodeOfConductInformation() async { + isLoading = true + // Get code of conduct if not done + if case .loading = codeOfConduct { + codeOfConduct = await CodeOfConductServiceFactory.shared.getTemplate() + } + // Get responsible users + responsibleUsers = await CodeOfConductServiceFactory.shared.getResponsibleUsers(for: courseId) + isLoading = false + // Handle error + switch (codeOfConduct, responsibleUsers) { + case let (.failure(error), _), let (_, .failure(error)): + presentError(userFacingError: error) + default: + break + } + } +} diff --git a/feature/Messages/Sources/Messages/ViewModels/MessagesTabViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift similarity index 62% rename from feature/Messages/Sources/Messages/ViewModels/MessagesTabViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift index ed15f6ae..9a58a483 100644 --- a/feature/Messages/Sources/Messages/ViewModels/MessagesTabViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesAvailableViewModel.swift @@ -1,6 +1,6 @@ // // MessagesTabViewModel.swift -// +// // // Created by Sven Andabaka on 03.04.23. // @@ -12,7 +12,7 @@ import APIClient import UserStore @MainActor -class MessagesTabViewModel: BaseViewModel { +class MessagesAvailableViewModel: BaseViewModel { @Published var allConversations: DataState<[Conversation]> = .loading { didSet { @@ -22,8 +22,6 @@ class MessagesTabViewModel: BaseViewModel { @Published var favoriteConversations: DataState<[Conversation]> = .loading - @Published var hiddenConversations: DataState<[Conversation]> = .loading - @Published var channels: DataState<[Channel]> = .loading @Published var exercises: DataState<[Channel]> = .loading @Published var lectures: DataState<[Channel]> = .loading @@ -31,39 +29,56 @@ class MessagesTabViewModel: BaseViewModel { @Published var groupChats: DataState<[GroupChat]> = .loading @Published var oneToOneChats: DataState<[OneToOneChat]> = .loading - let courseId: Int + @Published var hiddenConversations: DataState<[Conversation]> = .loading + let course: Course + let courseId: Int - init(course: Course) { - self.courseId = course.id + private let messagesService: MessagesService + private let stompClient: ArtemisStompClient + private let userSession: UserSession + + init( + course: Course, + messagesService: MessagesService = MessagesServiceFactory.shared, + stompClient: ArtemisStompClient = ArtemisStompClient.shared, + userSession: UserSession = UserSession.shared + ) { self.course = course + self.courseId = course.id + + self.messagesService = messagesService + self.stompClient = stompClient + self.userSession = userSession super.init() } func subscribeToConversationMembershipTopic() async { - guard let userId = UserSession.shared.user?.id else { + guard let userId = userSession.user?.id else { log.debug("User could not be found. Subscribe to Conversation not possible") return } - let topic = "/user/topic/metis/courses/\(courseId)/conversations/user/\(userId)" - let stream = ArtemisStompClient.shared.subscribe(to: topic) + let topic = WebSocketTopic.makeConversationMembershipNotifications(courseId: courseId, userId: userId) + let stream = stompClient.subscribe(to: topic) for await message in stream { - guard let conversationWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: ConversationWebsocketDTO.self, message: message) else { continue } + guard let conversationWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: ConversationWebsocketDTO.self, message: message) else { + continue + } onConversationMembershipMessageReceived(conversationWebsocketDTO: conversationWebsocketDTO) } } func loadConversations() async { - let result = await MessagesServiceFactory.shared.getConversations(for: courseId) + let result = await messagesService.getConversations(for: courseId) allConversations = result } - func hideUnhideConversation(conversationId: Int64, isHidden: Bool) async { + func setIsConversationFavorite(conversationId: Int64, isFavorite: Bool) async { isLoading = true - let result = await MessagesServiceFactory.shared.hideUnhideConversation(for: courseId, and: conversationId, isHidden: isHidden) + let result = await messagesService.updateIsConversationFavorite(for: courseId, and: conversationId, isFavorite: isFavorite) switch result { case .notStarted, .loading: isLoading = false @@ -80,9 +95,28 @@ class MessagesTabViewModel: BaseViewModel { } } - func setIsFavoriteConversation(conversationId: Int64, isFavorite: Bool) async { + func setIsConversationMuted(conversationId: Int64, isMuted: Bool) async { isLoading = true - let result = await MessagesServiceFactory.shared.setIsFavoriteConversation(for: courseId, and: conversationId, isFavorite: isFavorite) + let result = await messagesService.updateIsConversationMuted(for: courseId, and: conversationId, isMuted: isMuted) + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + await loadConversations() + isLoading = false + case .failure(let error): + isLoading = false + if let error = error as? APIClientError { + presentError(userFacingError: UserFacingError(error: error)) + } else { + presentError(userFacingError: UserFacingError(title: error.localizedDescription)) + } + } + } + + func setConversationIsHidden(conversationId: Int64, isHidden: Bool) async { + isLoading = true + let result = await messagesService.updateIsConversationHidden(for: courseId, and: conversationId, isHidden: isHidden) switch result { case .notStarted, .loading: isLoading = false @@ -124,28 +158,52 @@ class MessagesTabViewModel: BaseViewModel { groupChats = .failure(error: error) oneToOneChats = .failure(error: error) case .done(let response): - hiddenConversations = .done(response: response.filter { $0.baseConversation.isHidden ?? false }) - - let notHiddenConversations = response.filter { !($0.baseConversation.isHidden ?? false) } + let notHiddenConversations = response.filter { + !($0.baseConversation.isHidden ?? false) + } - favoriteConversations = .done(response: notHiddenConversations.filter { $0.baseConversation.isFavorite ?? false }) + favoriteConversations = .done(response: notHiddenConversations + .filter { $0.baseConversation.isFavorite ?? false } + ) - let notHiddenNotFavoriteConversations = notHiddenConversations.filter { !($0.baseConversation.isFavorite ?? false) } + let notHiddenNotFavoriteConversations = notHiddenConversations.filter { + !($0.baseConversation.isFavorite ?? false) + } - channels = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? Channel }).filter({ ($0.subType ?? .general) == .general })) - exercises = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? Channel }).filter({ ($0.subType ?? .general) == .exercise })) - lectures = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? Channel }).filter({ ($0.subType ?? .general) == .lecture })) - exams = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? Channel }).filter({ ($0.subType ?? .general) == .exam })) - groupChats = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? GroupChat })) - oneToOneChats = .done(response: notHiddenNotFavoriteConversations.compactMap({ $0.baseConversation as? OneToOneChat })) + channels = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? Channel } + .filter { ($0.subType ?? .general) == .general } + ) + exercises = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? Channel } + .filter { ($0.subType ?? .general) == .exercise } + ) + lectures = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? Channel } + .filter { ($0.subType ?? .general) == .lecture } + ) + exams = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? Channel } + .filter { ($0.subType ?? .general) == .exam } + ) + groupChats = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? GroupChat } + ) + oneToOneChats = .done(response: notHiddenNotFavoriteConversations + .compactMap { $0.baseConversation as? OneToOneChat } + ) + hiddenConversations = .done(response: response + .filter { $0.baseConversation.isHidden ?? false } + ) } } } -// All functions to handle new conversation received socket -extension MessagesTabViewModel { - private func onConversationMembershipMessageReceived(conversationWebsocketDTO: ConversationWebsocketDTO) { - switch conversationWebsocketDTO.metisCrudAction { +// MARK: Functions to handle new conversation received socket + +private extension MessagesAvailableViewModel { + func onConversationMembershipMessageReceived(conversationWebsocketDTO: ConversationWebsocketDTO) { + switch conversationWebsocketDTO.action { case .create, .update: handleUpdateOrCreate(updatedOrNewConversation: conversationWebsocketDTO.conversation) case .delete: @@ -155,7 +213,7 @@ extension MessagesTabViewModel { } } - private func handleUpdateOrCreate(updatedOrNewConversation: Conversation) { + func handleUpdateOrCreate(updatedOrNewConversation: Conversation) { guard var conversations = allConversations.value else { // conversations not loaded yet return @@ -171,7 +229,7 @@ extension MessagesTabViewModel { allConversations = .done(response: conversations) } - private func handleDelete(deletedConversation: Conversation) { + func handleDelete(deletedConversation: Conversation) { guard var conversations = allConversations.value else { // conversations not loaded yet return @@ -180,7 +238,7 @@ extension MessagesTabViewModel { allConversations = .done(response: conversations) } - private func handleNewMessage(conversationWithNewMessage: Conversation) { + func handleNewMessage(conversationWithNewMessage: Conversation) { guard var conversations = allConversations.value else { // conversations not loaded yet return diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift new file mode 100644 index 00000000..87dcc2f2 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessagesTabViewModels/MessagesTabViewModel.swift @@ -0,0 +1,89 @@ +// +// MessagesTabViewModel.swift +// +// +// Created by Nityananda Zbil on 26.10.23. +// + +import APIClient +import Common +import Combine +import SharedModels + +@MainActor +class MessagesTabViewModel: BaseViewModel { + + let course: Course + let courseId: Int + + @Published var codeOfConduct: DataState = .loading + @Published var codeOfConductAgreement: DataState = .loading + + var isSearchable: Bool { + if let codeOfConduct = course.courseInformationSharingMessagingCodeOfConduct, !codeOfConduct.isEmpty, + let agreement = codeOfConductAgreement.value, agreement { + return true + } else { + return false + } + } + + init(course: Course) { + self.course = course + self.courseId = course.id + + super.init() + } + + func getCodeOfConductInformation() async { + isLoading = true + // Get code of conduct and agreement + if let remoteCodeOfConduct = course.courseInformationSharingMessagingCodeOfConduct, !remoteCodeOfConduct.isEmpty { + codeOfConduct = .done(response: remoteCodeOfConduct) + codeOfConductAgreement = await CodeOfConductServiceFactory.shared.getAgreement(for: courseId) + } else { + codeOfConduct = await CodeOfConductServiceFactory.shared.getTemplate() + codeOfConduct.value.map { codeOfConduct in + let agreement = CodeOfConductStorageServiceFactory.shared.getAgreement(for: courseId, codeOfConduct: codeOfConduct) + codeOfConductAgreement = .done(response: agreement) + } + } + isLoading = false + // Handle error + switch (codeOfConduct, codeOfConductAgreement) { + case let (.failure(error), _), let (_, .failure(error)): + presentError(userFacingError: error) + default: + break + } + } + + func acceptCodeOfConduct() async { + guard let codeOfConduct = codeOfConduct.value else { + return + } + isLoading = true + let result: NetworkResponse + if course.courseInformationSharingMessagingCodeOfConduct?.isEmpty ?? true { + CodeOfConductStorageServiceFactory.shared.acceptCodeOfConduct(for: courseId, codeOfConduct: codeOfConduct) + result = .success + } else { + result = await CodeOfConductServiceFactory.shared.acceptCodeOfConduct(for: courseId) + } + // Handle error + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + codeOfConductAgreement = .done(response: true) + isLoading = false + case .failure(let error): + isLoading = false + if let apiClientError = error as? APIClientError { + presentError(userFacingError: UserFacingError(error: apiClientError)) + } else { + presentError(userFacingError: UserFacingError(title: error.localizedDescription)) + } + } + } +} diff --git a/feature/Messages/Sources/Messages/Views/ConversationInfoSheetView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift similarity index 99% rename from feature/Messages/Sources/Messages/Views/ConversationInfoSheetView.swift rename to ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift index e7e35ea6..93f573c3 100644 --- a/feature/Messages/Sources/Messages/Views/ConversationInfoSheetView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift @@ -291,7 +291,6 @@ private struct InfoSection: View { if let creationDate = conversation.baseConversation.creationDate { Text(R.string.localizable.createdOnLabel(creationDate.mediumDateShortTime)) } - } } } diff --git a/feature/Messages/Sources/Messages/Views/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift similarity index 58% rename from feature/Messages/Sources/Messages/Views/ConversationView.swift rename to ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 510f1115..712d597d 100644 --- a/feature/Messages/Sources/Messages/Views/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -5,12 +5,12 @@ // Created by Sven Andabaka on 05.04.23. // -import SwiftUI -import SharedModels +import ArtemisMarkdown +import Common import DesignLibrary import Navigation -import Common -import ArtemisMarkdown +import SharedModels +import SwiftUI // swiftlint:disable:next identifier_name private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 @@ -21,7 +21,7 @@ public struct ConversationView: View { @StateObject private var viewModel: ConversationViewModel - @State private var showConversationInfoSheet = false + @State private var isConversationInfoSheetPresented = false public init(course: Course, conversation: Conversation) { _viewModel = StateObject(wrappedValue: ConversationViewModel(course: course, conversation: conversation)) @@ -53,78 +53,82 @@ public struct ConversationView: View { public var body: some View { VStack { - ScrollViewReader { value in - ScrollView { - PullToRefresh(coordinateSpaceName: "pullToRefresh") { - await viewModel.loadFurtherMessages() - } - VStack(alignment: .leading) { - DataStateView(data: $viewModel.dailyMessages, - retryHandler: { await viewModel.loadMessages() }) { dailyMessages in - if dailyMessages.isEmpty { - Text(R.string.localizable.noMessagesYet()) - .padding(.vertical, .xl) - .padding(.horizontal, .l) - } else { + DataStateView(data: $viewModel.dailyMessages) { + await viewModel.loadMessages() + } content: { dailyMessages in + if dailyMessages.isEmpty { + ContentUnavailableView( + R.string.localizable.noMessages(), + systemImage: "bubble.right", + description: Text(R.string.localizable.noMessagesDescription())) + } else { + ScrollViewReader { value in + ScrollView { + PullToRefresh(coordinateSpaceName: "pullToRefresh") { + await viewModel.loadFurtherMessages() + } + VStack(alignment: .leading) { ForEach(dailyMessages.sorted(by: { $0.key < $1.key }), id: \.key) { dailyMessage in - ConversationDaySection(viewModel: viewModel, - day: dailyMessage.key, - messages: dailyMessage.value, - conversationPath: conversationPath) + ConversationDaySection( + viewModel: viewModel, + day: dailyMessage.key, + messages: dailyMessage.value, + conversationPath: conversationPath) } Spacer() .id("bottom") } } - } - } - .coordinateSpace(name: "pullToRefresh") - .onChange(of: viewModel.dailyMessages.value) { _ in - // TODO: does not work correctly when loadFurtherMessages is called -> is called to early -> investigate - if let id = viewModel.shouldScrollToId { - withAnimation { - value.scrollTo(id, anchor: .bottom) + .coordinateSpace(name: "pullToRefresh") + .onChange(of: viewModel.dailyMessages.value) { + // TODO: does not work correctly when loadFurtherMessages is called -> is called to early -> investigate + if let id = viewModel.shouldScrollToId { + withAnimation { + value.scrollTo(id, anchor: .bottom) + } } } } + } } if isAllowedToPost { SendMessageView(viewModel: viewModel, sendMessageType: .message) } } - .toolbar { - ToolbarItem(placement: .principal) { - Button(action: { - showConversationInfoSheet = true - }, label: { - Text(viewModel.conversation.value?.baseConversation.conversationName ?? R.string.localizable.loading()) - .foregroundColor(.Artemis.primaryLabel) - .frame(width: UIScreen.main.bounds.size.width * 0.6) - }) + .toolbar { + ToolbarItem(placement: .principal) { + Button { + isConversationInfoSheetPresented = true + } label: { + Text(viewModel.conversation.value?.baseConversation.conversationName ?? R.string.localizable.loading()) + .foregroundColor(.Artemis.primaryLabel) + .frame(width: UIScreen.main.bounds.size.width * 0.6) } } - .sheet(isPresented: $showConversationInfoSheet) { - if let course = viewModel.course.value { - ConversationInfoSheetView(conversation: $viewModel.conversation, - course: course, - conversationId: viewModel.conversationId) - } else { - Text(R.string.localizable.loading()) - } + } + .sheet(isPresented: $isConversationInfoSheetPresented) { + if let course = viewModel.course.value { + ConversationInfoSheetView( + conversation: $viewModel.conversation, + course: course, + conversationId: viewModel.conversationId) + } else { + Text(R.string.localizable.loading()) } - .task { - viewModel.shouldScrollToId = "bottom" - if viewModel.dailyMessages.value == nil { - await viewModel.loadMessages() - } + } + .task { + viewModel.shouldScrollToId = "bottom" + if viewModel.dailyMessages.value == nil { + await viewModel.loadMessages() } - .onDisappear { - if navigationController.path.count < 2 { - // only cancel task if we navigate back - viewModel.websocketSubscriptionTask?.cancel() - } + } + .onDisappear { + if navigationController.path.count < 2 { + // only cancel task if we navigate back + viewModel.websocketSubscriptionTask?.cancel() } - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } } @@ -145,17 +149,18 @@ private struct ConversationDaySection: View { Divider() .padding(.horizontal, .l) ForEach(Array(messages.enumerated()), id: \.1.id) { index, message in - MessageCellWrapper(viewModel: viewModel, - day: day, - message: message, - conversationPath: conversationPath, - showHeader: (index == 0 ? true : shouldShowHeader(message: message, previousMessage: messages[index - 1]))) + MessageCellWrapper( + viewModel: viewModel, + day: day, + message: message, + conversationPath: conversationPath, + showHeader: (index == 0 ? true : showHeader(message: message, previousMessage: messages[index - 1]))) } } } // header is not shown if same person messages multiple times within 5 minutes - private func shouldShowHeader(message: Message, previousMessage: Message) -> Bool { + private func showHeader(message: Message, previousMessage: Message) -> Bool { !(message.author == previousMessage.author && message.creationDate ?? .now < (previousMessage.creationDate ?? .yesterday).addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60))) } @@ -185,10 +190,11 @@ private struct MessageCellWrapper: View { } var body: some View { - MessageCell(viewModel: viewModel, - message: messageBinding, - conversationPath: conversationPath, - showHeader: showHeader) + MessageCell( + viewModel: viewModel, + message: messageBinding, + conversationPath: conversationPath, + showHeader: showHeader) } } @@ -226,6 +232,7 @@ private struct PullToRefresh: View { } Spacer() } - }.padding(.top, -50) + } + .padding(.top, -50) } } diff --git a/feature/Messages/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift similarity index 100% rename from feature/Messages/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift rename to ArtemisKit/Sources/Messages/Views/CreateConversationViews/BrowseChannelsView.swift diff --git a/feature/Messages/Sources/Messages/Views/CreateConversationViews/CreateChannelView.swift b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateChannelView.swift similarity index 100% rename from feature/Messages/Sources/Messages/Views/CreateConversationViews/CreateChannelView.swift rename to ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateChannelView.swift diff --git a/feature/Messages/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift b/ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift similarity index 100% rename from feature/Messages/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift rename to ArtemisKit/Sources/Messages/Views/CreateConversationViews/CreateOrAddToChatView.swift diff --git a/feature/Messages/Sources/Messages/Views/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift similarity index 99% rename from feature/Messages/Sources/Messages/Views/MessageActionSheet.swift rename to ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index 39e588cc..1cf96f55 100644 --- a/feature/Messages/Sources/Messages/Views/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -250,7 +250,7 @@ private struct EmojiPickerButton: View { .navigationBarTitleDisplayMode(.inline) } } - .onChange(of: selectedEmoji) { newEmoji in + .onChange(of: selectedEmoji) { _, newEmoji in if let newEmoji, let emojiId = Smile.alias(emoji: newEmoji.value) { Task { diff --git a/feature/Messages/Sources/Messages/Views/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift similarity index 100% rename from feature/Messages/Sources/Messages/Views/MessageCell.swift rename to ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift diff --git a/feature/Messages/Sources/Messages/Views/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift similarity index 99% rename from feature/Messages/Sources/Messages/Views/MessageDetailView.swift rename to ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 306d80fe..90f93f08 100644 --- a/feature/Messages/Sources/Messages/Views/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -111,7 +111,7 @@ public struct MessageDetailView: View { .onAppear { value.scrollTo("bottom", anchor: .bottom) } - .onChange(of: message.answers) { _ in + .onChange(of: message.answers) { withAnimation { if let id = viewModel.shouldScrollToId { value.scrollTo(id, anchor: .bottom) diff --git a/feature/Messages/Sources/Messages/Views/ReactionsView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift similarity index 99% rename from feature/Messages/Sources/Messages/Views/ReactionsView.swift rename to ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift index b1587b28..cb43db0d 100644 --- a/feature/Messages/Sources/Messages/Views/ReactionsView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift @@ -146,7 +146,7 @@ private struct EmojiPickerButton: View { .navigationBarTitleDisplayMode(.inline) } } - .onChange(of: selectedEmoji) { newEmoji in + .onChange(of: selectedEmoji) { _, newEmoji in if let newEmoji, let emojiId = Smile.alias(emoji: newEmoji.value) { Task { diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift new file mode 100644 index 00000000..88326427 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift @@ -0,0 +1,59 @@ +// +// CodeOfConductView.swift +// +// +// Created by Nityananda Zbil on 15.10.23. +// + +import DesignLibrary +import MarkdownUI +import SharedModels +import SwiftUI + +struct CodeOfConductView: View { + + @StateObject private var viewModel: CodeOfConductViewModel + + init(course: Course) { + self._viewModel = StateObject(wrappedValue: CodeOfConductViewModel(course: course)) + } + + var body: some View { + DataStateView(data: $viewModel.codeOfConduct) { + await viewModel.getCodeOfConductInformation() + } content: { _ in + VStack(alignment: .leading) { + Markdown(codeOfConductSanitized() + "\n" + responsibleUserMarkdown()) + // Take all available horizontal space + HStack { + Spacer() + } + } + } + .task { + await viewModel.getCodeOfConductInformation() + } + } +} + +private extension CodeOfConductView { + /// `codeOfConductSanitized` filters HTML comments. + func codeOfConductSanitized() -> String { + (viewModel.codeOfConduct.value ?? "") + .split(separator: "\n") + .filter { line in + let isComment = line.hasPrefix("") + return !isComment + } + .joined(separator: "\n") + } + + /// `responsibleUserMarkdown` creates a Markdown string from the responsible users array. + func responsibleUserMarkdown() -> String { + (viewModel.responsibleUsers.value ?? []) + .map { user in + "- \(user.name) ([\(user.email)](mailto:\(user.email)))" + } + .joined(separator: "\n") + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/Badge.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/Badge.swift new file mode 100644 index 00000000..9e5a45f8 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/Badge.swift @@ -0,0 +1,28 @@ +// +// Badge.swift +// +// +// Created by Nityananda Zbil on 10.11.23. +// + +import DesignLibrary +import SwiftUI + +struct Badge: View { + let count: Int + + var body: some View { + // swiftlint:disable:next empty_count + if count > 0 { + Text("\(count)") + .font(.body.bold().monospacedDigit()) + .foregroundColor(.white) + .padding(.vertical, .xs) + .padding(.horizontal, .m) + .background { + Capsule() + .fill(.red) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift new file mode 100644 index 00000000..3d92ea08 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -0,0 +1,68 @@ +// +// ConversationRow.swift +// +// +// Created by Nityananda Zbil on 10.11.23. +// + +import Navigation +import SharedModels +import SwiftUI + +struct ConversationRow: View { + + @EnvironmentObject var navigationController: NavigationController + + @ObservedObject var viewModel: MessagesAvailableViewModel + + let conversation: T + + var body: some View { + Button { + // should always be non-optional + if let conversation = Conversation(conversation: conversation) { + navigationController.path.append(ConversationPath(conversation: conversation, coursePath: CoursePath(course: viewModel.course))) + } + } label: { + HStack { + if let icon = conversation.icon { + icon + .resizable() + .scaledToFit() + .frame(width: .extraSmallImage, height: .extraSmallImage) + } + Text(conversation.conversationName) + Spacer() + if let unreadCount = conversation.unreadMessagesCount { + Badge(count: unreadCount) + } + } + .opacity((conversation.unreadMessagesCount ?? 0) > 0 ? 1 : 0.7) + .contextMenu { + contextMenuItems + } + } + .foregroundStyle((conversation.isMuted ?? false) ? .secondary : .primary) + .listRowSeparator(.hidden) + } +} + +private extension ConversationRow { + @ViewBuilder var contextMenuItems: some View { + Button((conversation.isFavorite ?? false) ? R.string.localizable.unfavorite() : R.string.localizable.favorite()) { + 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()) { + Task(priority: .userInitiated) { + await viewModel.setIsConversationMuted(conversationId: conversation.id, isMuted: !(conversation.isMuted ?? false)) + } + } + Button((conversation.isHidden ?? false) ? R.string.localizable.show() : R.string.localizable.hide()) { + Task(priority: .userInitiated) { + await viewModel.setConversationIsHidden(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift new file mode 100644 index 00000000..b92633e1 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -0,0 +1,347 @@ +// +// MessagesTabView.swift +// +// +// Created by Sven Andabaka on 03.04.23. +// + +import Common +import DesignLibrary +import Navigation +import SharedModels +import SwiftUI + +public struct MessagesAvailableView: View { + + @StateObject private var viewModel: MessagesAvailableViewModel + + @Binding private var searchText: String + + @State private var isCodeOfConductPresented = false + + private var searchResults: [Conversation] { + if searchText.isEmpty { + return [] + } + return (viewModel.allConversations.value ?? []).filter { + $0.baseConversation.conversationName.lowercased().contains(searchText.lowercased()) + } + } + + public init(course: Course, searchText: Binding) { + self._viewModel = StateObject(wrappedValue: MessagesAvailableViewModel(course: course)) + self._searchText = searchText + } + + public var body: some View { + List { + if !searchText.isEmpty { + if searchResults.isEmpty { + Text(R.string.localizable.noResultForSearch()) + .padding(.l) + .listRowSeparator(.hidden) + } + ForEach(searchResults) { 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) + } + } + } else { + Group { + MixedMessageSection( + viewModel: viewModel, + conversations: $viewModel.favoriteConversations, + sectionTitle: R.string.localizable.favoritesSection()) + MessageSection( + viewModel: viewModel, + conversations: $viewModel.channels, + sectionTitle: R.string.localizable.channels(), + conversationType: .channel) + MessageSection( + viewModel: viewModel, + conversations: $viewModel.exercises, + sectionTitle: R.string.localizable.exercises(), + conversationType: .channel, + isExpanded: false) + MessageSection( + viewModel: viewModel, + conversations: $viewModel.lectures, + sectionTitle: R.string.localizable.lectures(), + conversationType: .channel, + isExpanded: false) + MessageSection( + viewModel: viewModel, + conversations: $viewModel.exams, + sectionTitle: R.string.localizable.exams(), + conversationType: .channel, + 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) + MixedMessageSection( + viewModel: viewModel, + conversations: $viewModel.hiddenConversations, + sectionTitle: R.string.localizable.hiddenSection(), + isExpanded: false) + } + .listRowSeparator(.visible, edges: .top) + .listRowInsets(EdgeInsets(top: .s, leading: .l, bottom: .s, trailing: .l)) + + HStack { + Spacer() + Button { + isCodeOfConductPresented = true + } label: { + HStack { + Image(systemName: "info.circle") + Text(R.string.localizable.codeOfConduct()) + } + } + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .refreshable { + await viewModel.loadConversations() + } + .task { + await viewModel.loadConversations() + } + .task { + await viewModel.subscribeToConversationMembershipTopic() + } + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + .loadingIndicator(isLoading: $viewModel.isLoading) + .sheet(isPresented: $isCodeOfConductPresented) { + NavigationStack { + ScrollView { + CodeOfConductView(course: viewModel.course) + } + .padding() + .navigationTitle(R.string.localizable.codeOfConduct()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + isCodeOfConductPresented = false + } label: { + Text(R.string.localizable.done()) + } + } + } + } + } + } +} + +private struct MixedMessageSection: View { + + @ObservedObject private var viewModel: MessagesAvailableViewModel + + @Binding private var conversations: DataState<[Conversation]> + + @State private var isExpanded = true + + private let sectionTitle: String + + init( + viewModel: MessagesAvailableViewModel, + conversations: Binding>, + sectionTitle: String, + isExpanded: Bool = true + ) { + self.viewModel = viewModel + self._conversations = conversations + self.sectionTitle = sectionTitle + self._isExpanded = State(wrappedValue: isExpanded) + } + + var sectionUnreadCount: Int { + (conversations.value ?? []).reduce(0) { + $0 + ($1.baseConversation.unreadMessagesCount ?? 0) + } + } + + var body: some View { + DataStateView(data: $conversations) { + 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) + } + } + } label: { + SectionDisclosureLabel( + viewModel: viewModel, + sectionTitle: sectionTitle, + sectionUnreadCount: sectionUnreadCount, + isUnreadCountVisible: !isExpanded, + conversationType: nil) + } + } + } + } +} + +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 sectionUnreadCount: Int + let isUnreadCountVisible: Bool + + let conversationType: ConversationType? + + var body: some View { + HStack { + Text(sectionTitle) + .font(.headline) + 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) + } + .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 + } + } + } +} + +private struct MessageSection: View { + + @ObservedObject var viewModel: MessagesAvailableViewModel + + @Binding var conversations: DataState<[T]> + + @State private var isExpanded = true + + var sectionTitle: String + var conversationType: ConversationType + + var sectionUnreadCount: Int { + (conversations.value ?? []).reduce(0) { + $0 + ($1.unreadMessagesCount ?? 0) + } + } + + init( + viewModel: MessagesAvailableViewModel, + conversations: Binding>, + sectionTitle: String, + conversationType: ConversationType, + isExpanded: Bool = true + ) { + self.viewModel = viewModel + self._conversations = conversations + self.sectionTitle = sectionTitle + self.conversationType = conversationType + self._isExpanded = State(wrappedValue: isExpanded) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + DataStateView(data: $conversations) { + await viewModel.loadConversations() + } content: { conversations in + ForEach( + conversations.filter { !($0.isMuted ?? false) }, + id: \.id + ) { conversation in + ConversationRow(viewModel: viewModel, conversation: conversation) + } + ForEach( + conversations.filter { $0.isMuted ?? false }, + id: \.id + ) { conversation in + ConversationRow(viewModel: viewModel, conversation: conversation) + } + } + } label: { + SectionDisclosureLabel( + viewModel: viewModel, + sectionTitle: sectionTitle, + sectionUnreadCount: sectionUnreadCount, + isUnreadCountVisible: !isExpanded, + conversationType: conversationType) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift new file mode 100644 index 00000000..f3da10e7 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesPreferences.swift @@ -0,0 +1,18 @@ +// +// MessagesPreferences.swift +// +// +// Created by Nityananda Zbil on 17.10.23. +// + +import SwiftUI + +/// `MessagesPreferences` is an environment object that signals preferences from `MessagesTabView` to its container view. +/// +/// Unfortunately, the `.preference(key:value:)` modifier did not update the value correctly at the container view. +public class MessagesPreferences: ObservableObject { + /// `isSearchable` signals if the `MessagesTabView` is searchable. + @Published public internal(set) var isSearchable = false + + public init() {} +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift new file mode 100644 index 00000000..d7e317a9 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesTabView.swift @@ -0,0 +1,59 @@ +// +// MessagesTabView.swift +// +// +// Created by Nityananda Zbil on 26.10.23. +// + +import APIClient +import Common +import DesignLibrary +import SharedModels +import SwiftUI + +public struct MessagesTabView: View { + + @EnvironmentObject private var messagesPreferences: MessagesPreferences + + @StateObject private var viewModel: MessagesTabViewModel + + @Binding private var searchText: String + + public init(course: Course, searchText: Binding) { + self._viewModel = StateObject(wrappedValue: MessagesTabViewModel(course: course)) + self._searchText = searchText + } + + public var body: some View { + DataStateView(data: $viewModel.codeOfConductAgreement) { + await viewModel.getCodeOfConductInformation() + } content: { agreement in + if agreement { + MessagesAvailableView(course: viewModel.course, searchText: _searchText) + } else { + ScrollView { + CodeOfConductView(course: viewModel.course) + HStack { + Spacer() + Button { + Task { + await viewModel.acceptCodeOfConduct() + } + } label: { + Text(R.string.localizable.acceptCodeOfConductButtonLabel()) + } + .buttonStyle(ArtemisButton()) + Spacer() + } + } + .padding() + } + } + .task { + await viewModel.getCodeOfConductInformation() + } + .onChange(of: viewModel.codeOfConductAgreement.value) { + messagesPreferences.isSearchable = viewModel.isSearchable + } + } +} diff --git a/feature/Messages/Sources/Messages/Views/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageView.swift similarity index 100% rename from feature/Messages/Sources/Messages/Views/SendMessageView.swift rename to ArtemisKit/Sources/Messages/Views/SendMessageView.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/DeeplinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/DeeplinkHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/CourseHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/CourseHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/CourseHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/CourseHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/DashboardHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/DashboardHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/DashboardHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/DashboardHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/ExerciseHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/ExerciseHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/ExerciseHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/ExerciseHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/LectureHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/LectureHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/LectureHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/LectureHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessageHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/MessagesHandler.swift diff --git a/core/Navigation/Sources/Navigation/Deeplinks/Handlers/UnknownLinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/Handlers/UnknownLinkHandler.swift similarity index 100% rename from core/Navigation/Sources/Navigation/Deeplinks/Handlers/UnknownLinkHandler.swift rename to ArtemisKit/Sources/Navigation/Deeplinks/Handlers/UnknownLinkHandler.swift diff --git a/core/Navigation/Sources/Navigation/NavigationController.swift b/ArtemisKit/Sources/Navigation/NavigationController.swift similarity index 100% rename from core/Navigation/Sources/Navigation/NavigationController.swift rename to ArtemisKit/Sources/Navigation/NavigationController.swift diff --git a/feature/Notifications/Sources/Notifications/Notification.swift b/ArtemisKit/Sources/Notifications/Models/Notification.swift similarity index 100% rename from feature/Notifications/Sources/Notifications/Notification.swift rename to ArtemisKit/Sources/Notifications/Models/Notification.swift diff --git a/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..733ff9f9 --- /dev/null +++ b/ArtemisKit/Sources/Notifications/Resources/en.lproj/Localizable.strings @@ -0,0 +1,7 @@ +"artemisLabel" = "Artemis"; +"ok" = "OK"; + +"notificationsTitle" = "Notifications"; +"notificationAuthorLabel" = "%@ by %@"; +"notificationTargetNotFound" = "The target of the notification could not be determined."; +"noNotifications" = "No Notifications"; diff --git a/feature/Notifications/Sources/Notifications/Services/NotificationService.swift b/ArtemisKit/Sources/Notifications/Services/NotificationService.swift similarity index 100% rename from feature/Notifications/Sources/Notifications/Services/NotificationService.swift rename to ArtemisKit/Sources/Notifications/Services/NotificationService.swift diff --git a/feature/Notifications/Sources/Notifications/Services/NotificationServiceImpl.swift b/ArtemisKit/Sources/Notifications/Services/NotificationServiceImpl.swift similarity index 100% rename from feature/Notifications/Sources/Notifications/Services/NotificationServiceImpl.swift rename to ArtemisKit/Sources/Notifications/Services/NotificationServiceImpl.swift diff --git a/feature/Notifications/Sources/Notifications/Services/NotificationWebsocketService.swift b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketService.swift similarity index 100% rename from feature/Notifications/Sources/Notifications/Services/NotificationWebsocketService.swift rename to ArtemisKit/Sources/Notifications/Services/NotificationWebsocketService.swift diff --git a/feature/Notifications/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift similarity index 74% rename from feature/Notifications/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift rename to ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift index 03a89d24..07996b19 100644 --- a/feature/Notifications/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift +++ b/ArtemisKit/Sources/Notifications/Services/NotificationWebsocketServiceImpl.swift @@ -199,64 +199,44 @@ class NotificationWebsocketServiceImpl: NotificationWebsocketService { } private func subscribeToTutorialGroupNotificationUpdates() async { - let tutorialGroups = await getTutorialGroupsForNotifications() - switch tutorialGroups { - case .loading: + guard let userId = UserSession.shared.user?.id else { + log.debug("User could not be found. Subscription to UserNotifications is not possible") return - case .failure(let error): - log.error("Could not subscribe to tutorial group notifications: \(error.localizedDescription)") - case .done(let tutorialGroups): - await subscribeToTutorialGroupNotificationUpdates(tutorialGroups: tutorialGroups) } - } - private func subscribeToTutorialGroupNotificationUpdates(tutorialGroups: [TutorialGroup]) async { - for tutorialGroup in tutorialGroups { - let tutorialGroupTopic = "/topic/tutorial-group/\(tutorialGroup.id)/notifications" - if !contains(topic: tutorialGroupTopic) { - let stream = subscribe(to: tutorialGroupTopic) - let task = Task { - for await message in stream { - guard let notification = JSONDecoder.getTypeFromSocketMessage(type: Notification.self, message: message) else { continue } - continuation?.yield(notification) - } - } - addTask(task) + let topic = "/topic/user/\(userId)/notifications/tutorial-groups" + let stream = subscribe(to: topic) + + let task = Task { + for await message in stream { + guard let notification = JSONDecoder.getTypeFromSocketMessage(type: Notification.self, message: message) else { continue } + continuation?.yield(notification) } } + addTask(task) } private func subscribeToConversationNotificationUpdates() async { - let conversations = await getConversationsForNotifications() - switch conversations { - case .loading: + guard let userId = UserSession.shared.user?.id else { + log.debug("User could not be found. Subscription to UserNotifications is not possible") return - case .failure(let error): - log.error("Could not subscribe to conversations notifications: \(error.localizedDescription)") - case .done(let conversations): - subscribeToConversationNotificationUpdates(conversations: conversations) } - } - private func subscribeToConversationNotificationUpdates(conversations: [Conversation]) { - for conversation in conversations { - let conversationTopic = "/topic/conversation/\(conversation.id)/notifications" - if !contains(topic: conversationTopic) { - let stream = subscribe(to: conversationTopic) - 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 topic = "/topic/user/\(userId)/notifications/conversations" + let stream = subscribe(to: topic) - // Only add notification if it is not from the current user - if notification.author?.id != userId { - continuation?.yield(notification) - } - } + 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 } + + // Only add notification if it is not from the current user + if notification.author?.id != userId { + continuation?.yield(notification) } - addTask(task) } } + addTask(task) } private func contains(topic: String) -> Bool { @@ -305,52 +285,6 @@ extension NotificationWebsocketServiceImpl { return .failure(error: UserFacingError(error: error)) } } - - struct GetTutorialGroupsForNotificationsRequest: APIRequest { - typealias Response = [TutorialGroup] - - var method: HTTPMethod { - return .get - } - - var resourceName: String { - return "api/tutorial-groups/for-notifications" - } - } - - internal func getTutorialGroupsForNotifications() async -> DataState<[TutorialGroup]> { - let result = await client.sendRequest(GetTutorialGroupsForNotificationsRequest()) - - switch result { - case .success((let response, _)): - return .done(response: response) - case .failure(let error): - return .failure(error: UserFacingError(error: error)) - } - } - - struct GetConversationsForNotificationsRequest: APIRequest { - typealias Response = [Conversation] - - var method: HTTPMethod { - return .get - } - - var resourceName: String { - return "api/courses/conversations-for-notifications" - } - } - - internal func getConversationsForNotifications() async -> DataState<[Conversation]> { - let result = await client.sendRequest(GetConversationsForNotificationsRequest()) - - switch result { - case .success((let response, _)): - return .done(response: response) - case .failure(let error): - return .failure(error: UserFacingError(error: error)) - } - } } private extension String { diff --git a/feature/Notifications/Sources/Notifications/NotificationViewModel.swift b/ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift similarity index 100% rename from feature/Notifications/Sources/Notifications/NotificationViewModel.swift rename to ArtemisKit/Sources/Notifications/ViewModels/NotificationViewModel.swift diff --git a/ArtemisKit/Sources/Notifications/Views/NotificationView.swift b/ArtemisKit/Sources/Notifications/Views/NotificationView.swift new file mode 100644 index 00000000..cd81dc11 --- /dev/null +++ b/ArtemisKit/Sources/Notifications/Views/NotificationView.swift @@ -0,0 +1,88 @@ +// +// SwiftUIView.swift +// +// +// Created by Sven Andabaka on 17.03.23. +// + +import DesignLibrary +import Navigation +import PushNotifications +import SwiftUI + +struct NotificationView: View { + + @ObservedObject var viewModel: NotificationViewModel + + @Environment(\.dismiss) var dismiss + + @State private var isTargetNotFoundAlertPresented = false + + var body: some View { + NavigationStack { + DataStateView(data: $viewModel.notifications) { + await viewModel.loadNotifications() + } content: { notifications in + if notifications.isEmpty { + ContentUnavailableView(R.string.localizable.noNotifications(), systemImage: "bell") + } else { + List { + ForEach(notifications) { notification in + NotificationListRowView(notification: notification) + .onTapGesture { + dismiss() + guard let type = notification.pushNotificationType, + let targetPath = PushNotificationResponseHandler.getTarget(type: type, targetString: notification.target) else { + isTargetNotFoundAlertPresented = true + return + } + DeeplinkHandler.shared.handle(path: targetPath) + } + } + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .refreshable { + await viewModel.loadNotifications() + } + .navigationTitle(R.string.localizable.notificationsTitle()) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + Task { + await viewModel.updateNotificationSeenDate() + } + } + .alert(R.string.localizable.notificationTargetNotFound(), isPresented: $isTargetNotFoundAlertPresented) { + Button(R.string.localizable.ok(), role: .cancel) { } + } + } + } +} + +private struct NotificationListRowView: View { + + let notification: Notification + + var body: some View { + if let title = notification.encodedTitle, + let body = notification.encodedBody { + VStack(alignment: .leading, spacing: .m) { + Text(title) + .font(.title2) + Text(body) + HStack { + Spacer() + Text(R.string.localizable.notificationAuthorLabel( + notification.notificationDate.shortDateAndTime, + notification.author?.name ?? R.string.localizable.artemisLabel())) + .multilineTextAlignment(.trailing) + .foregroundColor(Color.Artemis.secondaryLabel) + } + } + .padding(.l) + .cardModifier(backgroundColor: Color.Artemis.modalCardBackgroundColor) + } + } +} diff --git a/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift b/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift new file mode 100644 index 00000000..c74f4687 --- /dev/null +++ b/ArtemisKit/Sources/Notifications/Views/View+NotificationToolbar.swift @@ -0,0 +1,64 @@ +// +// View+NotificationToolbar.swift +// +// +// Created by Nityananda Zbil on 11.12.23. +// + +import SwiftUI + +public extension View { + func notificationToolbar() -> some View { + modifier(NotificationBell()) + } +} + +private struct NotificationBell: ViewModifier { + + @StateObject private var viewModel = NotificationViewModel() + + @State private var isNotificationSheetPresented = false + + func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + isNotificationSheetPresented = true + } label: { + Image(systemName: "bell.fill") + .overlay(Badge(count: viewModel.newNotificationCount)) + } + } + } + .sheet(isPresented: $isNotificationSheetPresented) { + NotificationView(viewModel: viewModel) + } + .task { + await viewModel.subscribeToNotificationUpdates() + } + } +} + +private struct Badge: View { + let count: Int + + var body: some View { + // swiftlint:disable:next empty_count + if count > 0 { + ZStack(alignment: .topTrailing) { + Color.clear + Text(String(count)) + .font(.system(size: 16)) + .foregroundColor(.white) + .padding(.s) + .background(Color.red) + .clipShape(Circle()) + .alignmentGuide(.top) { $0[.bottom] } + .alignmentGuide(.trailing) { $0[.trailing] - $0.width * 0.25 } + } + } else { + EmptyView() + } + } +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/ArtemisKitTests.swift b/ArtemisKit/Tests/ArtemisKitTests/ArtemisKitTests.swift new file mode 100644 index 00000000..7d9572a6 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/ArtemisKitTests.swift @@ -0,0 +1,3 @@ +import XCTest + +final class ArtemisKitTests: XCTestCase {} diff --git a/README.md b/README.md index 64b37348..7cfc2eb4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,6 @@ All subsystems where network communication is necessary use the *API Client*, al The *Login*, and *Account* subsystems manage the user authentication handling. The *Push Notification* subsystem handles the registration, notification settings handling, decryption, and displaying of push notifications. -![Subsystem decomposition of the App](docu/APP-subsystem.png) +![Subsystem decomposition of the App](docs/APP-subsystem.png) -*Figure: Subsystem decomposition of the `App`.* \ No newline at end of file +*Figure: Subsystem decomposition of the `App`.* diff --git a/core/Navigation/Package.swift b/core/Navigation/Package.swift deleted file mode 100644 index 192018fb..00000000 --- a/core/Navigation/Package.swift +++ /dev/null @@ -1,34 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Navigation", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Navigation", - targets: ["Navigation"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")) - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Navigation", - dependencies: [ - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "Common", package: "artemis-ios-core-modules"), - .product(name: "UserStore", package: "artemis-ios-core-modules") - ]), - .testTarget( - name: "NavigationTests", - dependencies: ["Navigation"]) - ] -) diff --git a/core/Navigation/README.md b/core/Navigation/README.md deleted file mode 100644 index 65733a83..00000000 --- a/core/Navigation/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Navigation - -A description of this package. diff --git a/core/Navigation/Tests/NavigationTests/NavigationTests.swift b/core/Navigation/Tests/NavigationTests/NavigationTests.swift deleted file mode 100644 index c43f1c64..00000000 --- a/core/Navigation/Tests/NavigationTests/NavigationTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import Navigation - -final class NavigationTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Navigation().text, "Hello, World!") - } -} diff --git a/docu/APP-subsystem.png b/docs/APP-subsystem.png similarity index 100% rename from docu/APP-subsystem.png rename to docs/APP-subsystem.png diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2b63c077..48b3e8c5 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -57,7 +57,7 @@ platform :ios do ) end - info_plist_path = "iosApp/Supporting/Info.plist" + info_plist_path = "Artemis/Supporting/Info.plist" set_info_plist_value( path: info_plist_path, @@ -113,7 +113,7 @@ platform :ios do derived_data_path: ".DerivedData", # Custom derived data path output_directory: "./build", # Directory where the output artifacts are generated scheme: "Artemis", # We want to build the "HallOfFame" scheme - xcargs: "-skipPackagePluginValidation" + xcargs: "-skipMacroValidation -skipPackagePluginValidation" ) end diff --git a/feature/CourseRegistration/.gitignore b/feature/CourseRegistration/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/feature/CourseRegistration/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/feature/CourseRegistration/Package.swift b/feature/CourseRegistration/Package.swift deleted file mode 100644 index 2d56313e..00000000 --- a/feature/CourseRegistration/Package.swift +++ /dev/null @@ -1,39 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "CourseRegistration", - defaultLocalization: "en", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "CourseRegistration", - targets: ["CourseRegistration"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "CourseRegistration", - dependencies: [ - .product(name: "APIClient", package: "artemis-ios-core-modules"), - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "DesignLibrary", package: "artemis-ios-core-modules"), - .product(name: "RswiftLibrary", package: "R.swift") - ], - plugins: [.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")] - ), - .testTarget( - name: "CourseRegistrationTests", - dependencies: ["CourseRegistration"]) - ] -) diff --git a/feature/CourseRegistration/README.md b/feature/CourseRegistration/README.md deleted file mode 100644 index 854e29df..00000000 --- a/feature/CourseRegistration/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# CourseRegistration - -A description of this package. diff --git a/feature/CourseRegistration/Tests/CourseRegistrationTests/CourseRegistrationTests.swift b/feature/CourseRegistration/Tests/CourseRegistrationTests/CourseRegistrationTests.swift deleted file mode 100644 index 19f56df9..00000000 --- a/feature/CourseRegistration/Tests/CourseRegistrationTests/CourseRegistrationTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import CourseRegistration - -final class CourseRegistrationTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(CourseRegistration().text, "Hello, World!") - } -} diff --git a/feature/CourseView/.gitignore b/feature/CourseView/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/feature/CourseView/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/feature/CourseView/Package.swift b/feature/CourseView/Package.swift deleted file mode 100644 index 097958b2..00000000 --- a/feature/CourseView/Package.swift +++ /dev/null @@ -1,45 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "CourseView", - defaultLocalization: "en", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "CourseView", - targets: ["CourseView"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")), - .package(path: "../../core/Navigation"), - .package(path: "../Messages"), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "CourseView", - dependencies: [ - .product(name: "APIClient", package: "artemis-ios-core-modules"), - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "UserStore", package: "artemis-ios-core-modules"), - .product(name: "ArtemisMarkdown", package: "artemis-ios-core-modules"), - .product(name: "SharedServices", package: "artemis-ios-core-modules"), - "Navigation", - "Messages", - .product(name: "RswiftLibrary", package: "R.swift") - ], - plugins: [.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")] - ), - .testTarget( - name: "CourseViewTests", - dependencies: ["CourseView"]) - ] -) diff --git a/feature/CourseView/README.md b/feature/CourseView/README.md deleted file mode 100644 index 7f7d7b5a..00000000 --- a/feature/CourseView/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# CourseView - -A description of this package. diff --git a/feature/CourseView/Sources/CourseView/CourseView.swift b/feature/CourseView/Sources/CourseView/CourseView.swift deleted file mode 100644 index 55de24b6..00000000 --- a/feature/CourseView/Sources/CourseView/CourseView.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI -import Common -import SharedModels -import Navigation -import Messages -import DesignLibrary - -public struct CourseView: View { - - @StateObject var viewModel: CourseViewModel - - @EnvironmentObject private var navigationController: NavigationController - - @State private var showNewMessageDialog = false - @State private var searchText = "" - - private let courseId: Int - - public init(courseId: Int) { - self.courseId = courseId - self._viewModel = StateObject(wrappedValue: CourseViewModel(courseId: courseId)) - } - - public var body: some View { - TabView(selection: $navigationController.courseTab) { - ExerciseListView(viewModel: viewModel, searchText: $searchText) - .tabItem { - Label(R.string.localizable.exercisesTabLabel(), systemImage: "list.bullet.clipboard.fill") - } - .tag(TabIdentifier.exercise) - - LectureListView(viewModel: viewModel, searchText: $searchText) - .tabItem { - Label(R.string.localizable.lectureTabLabel(), systemImage: "character.book.closed.fill") - } - .tag(TabIdentifier.lecture) - - if viewModel.course.value == nil || - viewModel.course.value?.courseInformationSharingConfiguration == .communicationAndMessaging || - viewModel.course.value?.courseInformationSharingConfiguration == .messagingOnly { - Group { - if let course = viewModel.course.value { - MessagesTabView(searchText: $searchText, course: course) - } else { - Text("Loading...") - } - } - .tabItem { - Label(R.string.localizable.messagesTabLabel(), systemImage: "bubble.right.fill") - } - .tag(TabIdentifier.communication) - } - } - .navigationTitle(viewModel.course.value?.title ?? R.string.localizable.loading()) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText) - .onChange(of: navigationController.courseTab) { _ in - searchText = "" - } - } -} diff --git a/feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift deleted file mode 100644 index 130ce6dd..00000000 --- a/feature/CourseView/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// ExerciseDetailView.swift -// -// -// Created by Sven Andabaka on 23.03.23. -// - -import SwiftUI -import SharedModels -import UserStore -import DesignLibrary -import Common -import SharedServices - -public struct ExerciseDetailView: View { - - @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 - - 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).clean - } - - private var showFeedbackButton: Bool { - switch exercise.value { - case .fileUpload, .modeling, .programming, .text: - return true - default: - return false - } - } - - public var body: some View { - DataStateView(data: $exercise, retryHandler: { await loadExercise() }) { exercise in - ScrollView { - VStack(alignment: .leading, spacing: .m) { - VStack(alignment: .leading, spacing: .m) { - ForEach(exercise.baseExercise.categories ?? [], id: \.category) { category in - Chip(text: category.category, backgroundColor: UIColor(hexString: category.colorCode).suColor) - } - if let dueDate = exercise.baseExercise.dueDate { - Text(R.string.localizable.dueDate(dueDate.relative ?? "?")) - } else { - Text(R.string.localizable.noDueDate()) - } - HStack { - Text(R.string.localizable.points( - score, - exercise.baseExercise.maxPoints?.clean ?? "0")) - if exercise.baseExercise.includedInOverallScore != .includedCompletly { - Chip(text: exercise.baseExercise.includedInOverallScore.description, backgroundColor: exercise.baseExercise.includedInOverallScore.color) - } - if let assessmentType = exercise.baseExercise.assessmentType?.description { - Text(R.string.localizable.assessment(assessmentType)) - } - } - SubmissionResultStatusView(exercise: exercise) - if let latestResultId, - let participationId, - showFeedbackButton { - Button(R.string.localizable.showFeedback()) { - showFeedback = true - } - .buttonStyle(ArtemisButton()) - .sheet(isPresented: $showFeedback) { - FeedbackView(courseId: courseId, - exerciseId: exerciseId, - participationId: participationId, - resultId: latestResultId) - } - } - ArtemisHintBox(text: R.string.localizable.exerciseParticipationHint(), hintType: .info) - } - .padding(.horizontal, .l) - - ArtemisWebView(urlRequest: $urlRequest, - contentHeight: $webViewHeight, - isLoading: $isWebViewLoading) - .frame(height: webViewHeight) - .loadingIndicator(isLoading: $isWebViewLoading) - Spacer() - } - } - .toolbar { - ToolbarItem(placement: .principal) { - HStack(spacing: .l) { - exercise.image - .renderingMode(.template) - .resizable() - .scaledToFit() - .foregroundColor(Color.Artemis.primaryLabel) - .frame(width: .smallImage) - Text(exercise.baseExercise.title ?? "") - .font(.headline) - } - } - } - } - .task { - await loadExercise() - } - } - - private func loadExercise() async { - if let exercise = exercise.value { - setParticipationAndResultId(from: exercise) - } else { - self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) - if let exercise = self.exercise.value { - setParticipationAndResultId(from: exercise) - } - } - } - - private func setParticipationAndResultId(from exercise: Exercise) { - isWebViewLoading = true - - 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 - } - - urlRequest = URLRequest(url: URL(string: "/courses/\(courseId)/exercises/\(exercise.id)/problem-statement/\(participationId?.description ?? "")", relativeTo: UserSession.shared.institution?.baseURL)!) - } -} - -private struct FeedbackView: View { - - @Environment(\.dismiss) var dismiss - - @State private var webViewHeight = CGFloat.s - @State private var urlRequest: URLRequest - @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)!)) - } - - var body: some View { - NavigationView { - ArtemisWebView(urlRequest: $urlRequest, isLoading: $isWebViewLoading) - .loadingIndicator(isLoading: $isWebViewLoading) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(R.string.localizable.close()) { - dismiss() - } - } - } - } - } -} diff --git a/feature/CourseView/Tests/CourseViewTests/CourseViewTests.swift b/feature/CourseView/Tests/CourseViewTests/CourseViewTests.swift deleted file mode 100644 index 3b550830..00000000 --- a/feature/CourseView/Tests/CourseViewTests/CourseViewTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import CourseView - -final class CourseViewTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(CourseView().text, "Hello, World!") - } -} diff --git a/feature/Dashboard/.gitignore b/feature/Dashboard/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/feature/Dashboard/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/feature/Dashboard/Package.swift b/feature/Dashboard/Package.swift deleted file mode 100644 index f4897e3e..00000000 --- a/feature/Dashboard/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Dashboard", - defaultLocalization: "en", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Dashboard", - targets: ["Dashboard"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")), - .package(path: "../../core/Navigation"), - .package(path: "../CourseRegistration"), - .package(path: "../Notifications"), - .package(path: "../CourseView"), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Dashboard", - dependencies: [ - .product(name: "APIClient", package: "artemis-ios-core-modules"), - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "DesignLibrary", package: "artemis-ios-core-modules"), - .product(name: "Account", package: "artemis-ios-core-modules"), - .product(name: "SharedServices", package: "artemis-ios-core-modules"), - "CourseRegistration", - "Navigation", - "Notifications", - "CourseView", - .product(name: "RswiftLibrary", package: "R.swift") - ], - plugins: [.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")] - ), - .testTarget( - name: "DashboardTests", - dependencies: ["Dashboard"]) - ] -) diff --git a/feature/Dashboard/README.md b/feature/Dashboard/README.md deleted file mode 100644 index 3f30cadb..00000000 --- a/feature/Dashboard/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Dashboard - -A description of this package. diff --git a/feature/Dashboard/Sources/Dashboard/CoursesOverviewView.swift b/feature/Dashboard/Sources/Dashboard/CoursesOverviewView.swift deleted file mode 100644 index 89fc316b..00000000 --- a/feature/Dashboard/Sources/Dashboard/CoursesOverviewView.swift +++ /dev/null @@ -1,156 +0,0 @@ -import SwiftUI -import Common -import SharedModels -import CourseRegistration -import DesignLibrary -import Navigation -import CourseView -import Account -import Notifications - -/** - * Display the course overview with the course list. - */ -public struct CoursesOverviewView: View { - - @StateObject private var viewModel = CoursesOverviewViewModel() - - @State private var showCourseRegistrationSheet = false - @State private var showNotificationSheet = false - - public init() { } - - public var body: some View { - VStack(alignment: .center) { - DataStateView(data: $viewModel.coursesForDashboard, - retryHandler: { await viewModel.loadCourses() }) { coursesForDashboard in - List { - Group { - ForEach(coursesForDashboard) { courseForDashboard in - CourseListCell(courseForDashboard: courseForDashboard) - } - Button(R.string.localizable.dasboard_register_for_course()) { - showCourseRegistrationSheet = true - } - .buttonStyle(ArtemisButton()) - } - .listRowSeparator(.hidden) - } - .listStyle(PlainListStyle()) - .refreshable { - await viewModel.loadCourses() - } - } - } - .navigationTitle(Text(R.string.localizable.dashboard_title())) - .accountMenu(error: $viewModel.error) - .notificationToolBar() - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) - .sheet(isPresented: $showCourseRegistrationSheet) { - CourseRegistrationView(successCompletion: { - showCourseRegistrationSheet = false - viewModel.coursesForDashboard = .loading - Task { - await viewModel.loadCourses() - } - }) - } - .navigationBarBackButtonHidden() - .task { - await viewModel.loadCourses() - } - } -} - -private struct CourseListCell: View { - - @EnvironmentObject var navigationController: NavigationController - - let courseForDashboard: CourseForDashboard - - var nextExercise: Exercise? { - // filters out every already successful (100%) exercise, only exercises left that still need work - let exercisesWithOpenTasks = courseForDashboard.course.upcomingExercises.filter { exercise in - return !(exercise.baseExercise.studentParticipations?.first?.baseParticipation.submissions?.first?.baseSubmission.results?.first?.successful ?? false) - } - return exercisesWithOpenTasks.first - } - - var body: some View { - if let title = courseForDashboard.course.title { - HStack { - Spacer() - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center) { - if let url = courseForDashboard.course.courseIconURL { - ArtemisAsyncImage(imageURL: url) { - Image("questionmark.square.dashed") - } - .frame(width: .extraLargeImage, height: .extraLargeImage) - .clipShape(Circle()) - .padding(.m) - } - VStack(alignment: .leading) { - Text(title) - .font(.custom("SF Pro", size: 21, relativeTo: .title)) - .lineLimit(2) - Text(R.string.localizable.dashboard_exercises_label(courseForDashboard.course.exercises?.count ?? 0)) - Text(R.string.localizable.dashboard_lectures_label(courseForDashboard.course.lectures?.count ?? 0)) - } - .foregroundColor(.white) - .padding(.m) - Spacer() - } - .frame(maxWidth: .infinity) - .background(courseForDashboard.course.courseColor) - HStack { - Spacer() - Group { - if let totalScore = courseForDashboard.totalScores { - ProgressBar(value: Int(totalScore.studentScores.absoluteScore), - total: Int(totalScore.reachablePoints)) - .frame(height: 120) - .padding(.vertical, .l) - } else { - Text("No statistics available") - } - } - Spacer() - }.padding(.vertical, .m) - HStack { - if let nextExercise, - let nextExerciseTitle = nextExercise.baseExercise.title { - HStack { - Text(R.string.localizable.dashboard_next_exercise_label()) - .padding(.trailing, .m) - nextExercise.image - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: .extraSmallImage) - Text(nextExerciseTitle) - .bold() - .lineLimit(1) - }.padding(.l) - } else { - Text(R.string.localizable.dashboard_no_exercise_planned_label()) - .padding(.l) - } - Spacer() - } - .frame(maxWidth: .infinity) - .background(Color.Artemis.dashboardCardBackgroundColor) - .foregroundColor(Color.Artemis.secondaryLabel) - } - .cardModifier(backgroundColor: .clear, hasBorder: true) - .onTapGesture { - navigationController.path.append(CoursePath(course: courseForDashboard.course)) - } - .frame(maxWidth: 720) - Spacer() - } - } else { - EmptyView() - } - } -} diff --git a/feature/Dashboard/Sources/Dashboard/CoursesOverviewViewModel.swift b/feature/Dashboard/Sources/Dashboard/CoursesOverviewViewModel.swift deleted file mode 100644 index b82f477d..00000000 --- a/feature/Dashboard/Sources/Dashboard/CoursesOverviewViewModel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import SharedModels -import SharedServices -import UserStore -import Common - -@MainActor -class CoursesOverviewViewModel: ObservableObject { - - @Published var coursesForDashboard: DataState<[CourseForDashboard]> = DataState.loading - @Published var error: UserFacingError? { - didSet { - showError = error != nil - } - } - @Published var showError = false - - init() { - Task { - await loadCourses() - } - } - - func loadCourses() async { - coursesForDashboard = await CourseServiceFactory.shared.getCourses() - } -} diff --git a/feature/Dashboard/Sources/Dashboard/Resources/en.lproj/Localizable.strings b/feature/Dashboard/Sources/Dashboard/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 3b757cca..00000000 --- a/feature/Dashboard/Sources/Dashboard/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,7 +0,0 @@ -"dasboard_register_for_course" = "Course Enrollment"; -"dashboard_title" = "Courses"; -"dashboard_notifications_label" = "Notifications"; -"dashboard_exercises_label" = "Exercises: %d"; -"dashboard_lectures_label" = "Lectures: %d"; -"dashboard_no_exercise_planned_label" = "No exercise planned"; -"dashboard_next_exercise_label" = "Next Exercise:"; diff --git a/feature/Dashboard/Tests/DashboardTests/DashboardTests.swift b/feature/Dashboard/Tests/DashboardTests/DashboardTests.swift deleted file mode 100644 index 9e392777..00000000 --- a/feature/Dashboard/Tests/DashboardTests/DashboardTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import Dashboard - -final class DashboardTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Dashboard().text, "Hello, World!") - } -} diff --git a/feature/Messages/.gitignore b/feature/Messages/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/feature/Messages/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/feature/Messages/Package.swift b/feature/Messages/Package.swift deleted file mode 100644 index 3db2373e..00000000 --- a/feature/Messages/Package.swift +++ /dev/null @@ -1,46 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Messages", - defaultLocalization: "en", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Messages", - targets: ["Messages"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")), - .package(path: "../../core/Navigation"), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0"), - .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Messages", - dependencies: [ - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "APIClient", package: "artemis-ios-core-modules"), - .product(name: "DesignLibrary", package: "artemis-ios-core-modules"), - .product(name: "UserStore", package: "artemis-ios-core-modules"), - .product(name: "ArtemisMarkdown", package: "artemis-ios-core-modules"), - .product(name: "SharedServices", package: "artemis-ios-core-modules"), - "Navigation", - "EmojiPicker", - .product(name: "RswiftLibrary", package: "R.swift") - ], - plugins: [.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")] - ), - .testTarget( - name: "MessagesTests", - dependencies: ["Messages"]) - ] -) diff --git a/feature/Messages/README.md b/feature/Messages/README.md deleted file mode 100644 index 12e4b7f5..00000000 --- a/feature/Messages/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Messages - -A description of this package. diff --git a/feature/Messages/Sources/Messages/Views/MessagesTabView.swift b/feature/Messages/Sources/Messages/Views/MessagesTabView.swift deleted file mode 100644 index d9769b74..00000000 --- a/feature/Messages/Sources/Messages/Views/MessagesTabView.swift +++ /dev/null @@ -1,337 +0,0 @@ -// -// MessagesTabView.swift -// -// -// Created by Sven Andabaka on 03.04.23. -// - -import SwiftUI -import DesignLibrary -import Common -import SharedModels -import Navigation - -public struct MessagesTabView: View { - - @StateObject private var viewModel: MessagesTabViewModel - - @Binding private var searchText: String - - private var searchResults: [Conversation] { - if searchText.isEmpty { - return [] - } - return (viewModel.allConversations.value ?? []).filter { $0.baseConversation.conversationName.lowercased().contains(searchText.lowercased()) } - } - - public init(searchText: Binding, course: Course) { - self._searchText = searchText - self._viewModel = StateObject(wrappedValue: MessagesTabViewModel(course: course)) - } - - public var body: some View { - List { - if !searchText.isEmpty { - if searchResults.isEmpty { - Text(R.string.localizable.noResultForSearch()) - .padding(.l) - .listRowSeparator(.hidden) - } - ForEach(searchResults) { 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) - } - } - } else { - Group { - MixedMessageSection(viewModel: viewModel, - conversations: $viewModel.favoriteConversations, - sectionTitle: R.string.localizable.favoritesSection()) - MessageSection(viewModel: viewModel, - conversations: $viewModel.channels, - sectionTitle: R.string.localizable.channels(), - conversationType: .channel) - MessageSection(viewModel: viewModel, - conversations: $viewModel.exercises, - sectionTitle: R.string.localizable.exercises(), - conversationType: .channel, - isExpanded: false) - MessageSection(viewModel: viewModel, - conversations: $viewModel.lectures, - sectionTitle: R.string.localizable.lectures(), - conversationType: .channel, - isExpanded: false) - MessageSection(viewModel: viewModel, - conversations: $viewModel.exams, - sectionTitle: R.string.localizable.exams(), - conversationType: .channel, - 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) - MixedMessageSection(viewModel: viewModel, - conversations: $viewModel.hiddenConversations, - sectionTitle: R.string.localizable.hiddenSection(), - isExpanded: false) - } - .listRowSeparator(.visible, edges: .top) - .listRowInsets(EdgeInsets(top: .s, leading: .l, bottom: .s, trailing: .l)) - } - } - .listStyle(PlainListStyle()) - .refreshable { - await viewModel.loadConversations() - } - .task { - await viewModel.loadConversations() - } - .task { - await viewModel.subscribeToConversationMembershipTopic() - } - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) - .loadingIndicator(isLoading: $viewModel.isLoading) - } -} - -private struct MixedMessageSection: View { - - @ObservedObject private var viewModel: MessagesTabViewModel - - @Binding private var conversations: DataState<[Conversation]> - - @State private var isExpanded = true - - private let sectionTitle: String - - init(viewModel: MessagesTabViewModel, - conversations: Binding>, - sectionTitle: String, - isExpanded: Bool = true) { - self.viewModel = viewModel - self._conversations = conversations - self.sectionTitle = sectionTitle - self._isExpanded = State(wrappedValue: isExpanded) - } - - var sectionUnreadCount: Int { - (conversations.value ?? []).reduce(0, { $0 + ($1.baseConversation.unreadMessagesCount ?? 0) }) - } - - var body: some View { - DataStateView(data: $conversations, - retryHandler: { await viewModel.loadConversations() }) { conversations in - if !conversations.isEmpty { - DisclosureGroup(isExpanded: $isExpanded, content: { - ForEach(conversations) { 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, - sectionUnreadCount: sectionUnreadCount, - showUnreadCount: !isExpanded, - conversationType: nil) - }) - } - } - } -} - -private struct SectionDisclosureLabel: View { - - @ObservedObject var viewModel: MessagesTabViewModel - - @State private var showNewConversationSheet = false - @State private var showNewConversationActionDialog = false - @State private var showBrowseChannels = false - @State private var showCreateChannel = false - - let sectionTitle: String - let sectionUnreadCount: Int - let showUnreadCount: Bool - - let conversationType: ConversationType? - - var body: some View { - HStack { - Text(sectionTitle) - .font(.headline) - Spacer() - if let conversationType { - Image(systemName: "plus.bubble") - .onTapGesture { - if conversationType == .channel { - if viewModel.course.isAtLeastTutorInCourse { - showNewConversationActionDialog = true - } else { - showBrowseChannels = true - } - } else { - showNewConversationSheet = true - } - } - } - if showUnreadCount { - Badge(unreadCount: sectionUnreadCount) - } - } - .sheet(isPresented: $showNewConversationSheet) { - CreateOrAddToChatView(courseId: viewModel.courseId) - } - .sheet(isPresented: $showCreateChannel, onDismiss: { - Task { - await viewModel.loadConversations() - } - }) { - CreateChannelView(courseId: viewModel.courseId) - } - .sheet(isPresented: $showBrowseChannels, onDismiss: { - Task { - await viewModel.loadConversations() - } - }) { - BrowseChannelsView(courseId: viewModel.courseId) - } - .confirmationDialog("", isPresented: $showNewConversationActionDialog, titleVisibility: .hidden, actions: { - Button(R.string.localizable.browseChannels()) { - showBrowseChannels = true - } - Button(R.string.localizable.createChannel()) { - showCreateChannel = true - } - }) - } -} - -private struct MessageSection: View { - - @ObservedObject var viewModel: MessagesTabViewModel - - @Binding var conversations: DataState<[T]> - - @State private var isExpanded = true - - var sectionTitle: String - var conversationType: ConversationType - - var sectionUnreadCount: Int { - (conversations.value ?? []).reduce(0, { $0 + ($1.unreadMessagesCount ?? 0) }) - } - - init(viewModel: MessagesTabViewModel, - conversations: Binding>, - sectionTitle: String, - conversationType: ConversationType, - isExpanded: Bool = true) { - self.viewModel = viewModel - self._conversations = conversations - self.sectionTitle = sectionTitle - self.conversationType = conversationType - self._isExpanded = State(wrappedValue: isExpanded) - } - - var body: some View { - DisclosureGroup(isExpanded: $isExpanded, content: { - DataStateView(data: $conversations, - retryHandler: { await viewModel.loadConversations() }) { conversations in - ForEach(conversations, id: \.id) { conversation in - ConversationRow(viewModel: viewModel, conversation: conversation) - } - } - }, label: { - SectionDisclosureLabel(viewModel: viewModel, - sectionTitle: sectionTitle, - sectionUnreadCount: sectionUnreadCount, - showUnreadCount: !isExpanded, - conversationType: conversationType) - }) - } -} - -private struct ConversationRow: View { - - @EnvironmentObject var navigationController: NavigationController - - @ObservedObject var viewModel: MessagesTabViewModel - - let conversation: T - - var body: some View { - Button(action: { - // should always be non-optional - if let conversation = Conversation(conversation: conversation) { - navigationController.path.append(ConversationPath(conversation: conversation, coursePath: CoursePath(course: viewModel.course))) - } - }, label: { - HStack { - if let icon = conversation.icon { - icon - .resizable() - .scaledToFit() - .frame(width: .extraSmallImage, height: .extraSmallImage) - } - Text(conversation.conversationName) - Spacer() - if let unreadCount = conversation.unreadMessagesCount { - Badge(unreadCount: unreadCount) - } - } - .opacity((conversation.unreadMessagesCount ?? 0) > 0 ? 1 : 0.7) - .contextMenu { - contextMenuItems - } - }) - .listRowSeparator(.hidden) - } - - var contextMenuItems: some View { - Group { - Button((conversation.isHidden ?? false) ? R.string.localizable.show() : R.string.localizable.hide()) { - Task(priority: .userInitiated) { - await viewModel.hideUnhideConversation(conversationId: conversation.id, isHidden: !(conversation.isHidden ?? false)) - } - } - Button((conversation.isFavorite ?? false) ? R.string.localizable.unfavorite() : R.string.localizable.favorite()) { - Task(priority: .userInitiated) { - await viewModel.setIsFavoriteConversation(conversationId: conversation.id, isFavorite: !(conversation.isFavorite ?? false)) - } - } - } - } -} - -private struct Badge: View { - let unreadCount: Int - - var body: some View { - if unreadCount > 0 { - Text("\(unreadCount)") - .foregroundColor(.white) - .font(.headline) - .padding(.m) - .background(.red) - .clipShape(Circle()) - } else { - EmptyView() - } - } -} diff --git a/feature/Messages/Tests/MessagesTests/MessagesTests.swift b/feature/Messages/Tests/MessagesTests/MessagesTests.swift deleted file mode 100644 index 4f7a0048..00000000 --- a/feature/Messages/Tests/MessagesTests/MessagesTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import Messages - -final class MessagesTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Messages().text, "Hello, World!") - } -} diff --git a/feature/Notifications/.gitignore b/feature/Notifications/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/feature/Notifications/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/feature/Notifications/Package.swift b/feature/Notifications/Package.swift deleted file mode 100644 index 69e6f8aa..00000000 --- a/feature/Notifications/Package.swift +++ /dev/null @@ -1,42 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Notifications", - defaultLocalization: "en", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "Notifications", - targets: ["Notifications"]) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "2.0.0")), - .package(path: "../../core/Navigation"), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "Notifications", - dependencies: [ - .product(name: "APIClient", package: "artemis-ios-core-modules"), - .product(name: "SharedModels", package: "artemis-ios-core-modules"), - .product(name: "DesignLibrary", package: "artemis-ios-core-modules"), - .product(name: "UserStore", package: "artemis-ios-core-modules"), - .product(name: "PushNotifications", package: "artemis-ios-core-modules"), - "Navigation", - .product(name: "RswiftLibrary", package: "R.swift") - ], - plugins: [.plugin(name: "RswiftGeneratePublicResources", package: "R.swift")] - ), - .testTarget( - name: "NotificationsTests", - dependencies: ["Notifications"]) - ] -) diff --git a/feature/Notifications/README.md b/feature/Notifications/README.md deleted file mode 100644 index 27d553de..00000000 --- a/feature/Notifications/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Notifications - -A description of this package. diff --git a/feature/Notifications/Sources/Notifications/NotificationView.swift b/feature/Notifications/Sources/Notifications/NotificationView.swift deleted file mode 100644 index 24fbac0f..00000000 --- a/feature/Notifications/Sources/Notifications/NotificationView.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// SwiftUIView.swift -// -// -// Created by Sven Andabaka on 17.03.23. -// - -import SwiftUI -import DesignLibrary -import Navigation -import PushNotifications - -struct NotificationView: View { - - @ObservedObject var viewModel: NotificationViewModel - - @Environment(\.dismiss) var dismiss - - @State private var showTargetNotFoundAlert = false - - var body: some View { - NavigationView { - List { - DataStateView(data: $viewModel.notifications, - retryHandler: { await viewModel.loadNotifications() }) { notifications in - if notifications.isEmpty { - Text(R.string.localizable.no_notifications_yet_label()) - } else { - ForEach(notifications) { notification in - NotificationCell(notification: notification) - .onTapGesture { - dismiss() - guard let type = notification.pushNotificationType, - let targetPath = PushNotificationResponseHandler.getTarget(type: type, targetString: notification.target) else { - showTargetNotFoundAlert = true - return - } - DeeplinkHandler.shared.handle(path: targetPath) - } - } - } - }.listRowSeparator(.hidden) - } - .listStyle(PlainListStyle()) - .refreshable { - await viewModel.loadNotifications() - } - .navigationTitle(R.string.localizable.notifications_title()) - .onAppear { - Task { - await viewModel.updateNotificationSeenDate() - } - } - .alert(R.string.localizable.notification_target_not_found(), isPresented: $showTargetNotFoundAlert) { - Button(R.string.localizable.ok(), role: .cancel) { } - } - } - } -} - -struct NotificationCell: View { - - let notification: Notification - - var body: some View { - if let title = notification.encodedTitle, - let body = notification.encodedBody { - VStack(alignment: .leading, spacing: .m) { - Text(title) - .font(.title2) - Text(body) - HStack { - Spacer() - Text(R.string.localizable.notification_author_label(notification.notificationDate.shortDateAndTime, - notification.author?.name ?? R.string.localizable.artemis_label())) - .multilineTextAlignment(.trailing) - .foregroundColor(Color.Artemis.secondaryLabel) - } - } - .padding(.l) - .cardModifier(backgroundColor: Color.Artemis.modalCardBackgroundColor) - } else { - EmptyView() - } - } -} - -struct NotificationBell: ViewModifier { - - @StateObject private var viewModel = NotificationViewModel() - - @State private var showNotificationSheet = false - - func body(content: Content) -> some View { - content - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { showNotificationSheet = true }, label: { - Image(systemName: "bell.fill") - .overlay(Badge(count: viewModel.newNotificationCount)) - }) - } - } - .sheet(isPresented: $showNotificationSheet) { - NotificationView(viewModel: viewModel) - } - .task { - await viewModel.subscribeToNotificationUpdates() - } - } -} - -public extension View { - func notificationToolBar() -> some View { - modifier(NotificationBell()) - } -} - -struct Badge: View { - let count: Int - - var body: some View { - // swiftlint:disable:next empty_count - if count > 0 { - ZStack(alignment: .topTrailing) { - Color.clear - Text(String(count)) - .font(.system(size: 16)) - .foregroundColor(.white) - .padding(.s) - .background(Color.red) - .clipShape(Circle()) - .alignmentGuide(.top) { $0[.bottom] } - .alignmentGuide(.trailing) { $0[.trailing] - $0.width * 0.25 } - } - } else { - EmptyView() - } - } -} - -private struct NewExerciseTarget: Codable { - var id: Int - var course: Int -} diff --git a/feature/Notifications/Sources/Notifications/Resources/en.lproj/Localizable.strings b/feature/Notifications/Sources/Notifications/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 8c821efe..00000000 --- a/feature/Notifications/Sources/Notifications/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,6 +0,0 @@ -"notifications_title" = "Notifications"; -"no_notifications_yet_label" = "No Notifications yet!"; -"notification_author_label" = "%@ by %@"; -"artemis_label" = "Artemis"; -"notification_target_not_found" = "The target of the notification could not be determined."; -"ok" = "OK"; diff --git a/feature/Notifications/Tests/NotificationsTests/NotificationsTests.swift b/feature/Notifications/Tests/NotificationsTests/NotificationsTests.swift deleted file mode 100644 index 4bd96b42..00000000 --- a/feature/Notifications/Tests/NotificationsTests/NotificationsTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import Notifications - -final class NotificationsTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(Notifications().text, "Hello, World!") - } -} diff --git a/iosApp/Localizable.strings b/iosApp/Localizable.strings deleted file mode 100644 index 8b137891..00000000 --- a/iosApp/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - From 84de14d0603dcd1b380533bfcc759ec951700a78 Mon Sep 17 00:00:00 2001 From: Nityananda Zbil Date: Mon, 4 Mar 2024 12:59:58 +0100 Subject: [PATCH 2/4] `General`: Release (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `Development`: Preview message detail view (#77) * Format * Preview MessageCell * Rename showHeader: isHeaderVisible * BaseMessage+IsContinuation * Make ConversationViewModel internal * Fix warning: - Reference to property 'stompClient' in closure requires explicit use of 'self' to make capture semantics explicit; this is an error in Swift 6 * Preview ReactionsView * Preview ConversationDaySection * Preview MessageDetailView * `Communication`: Usability of the conversation and thread views (#69) * Refine MessageCell * Align reaction leading * Apply system to send message overlay * Highlight message, not author * Pass isEmojiPickerButtonVisible through the environment * Hide image by height, not opacity * Inflect "reply" * `Communication`: Support user mentions and channel references (#47) * Format * Update overlay modifier - Deprecated: https://developer.apple.com/documentation/swiftui/view/overlay(_:alignment:) * Fix runtime warning: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. * Use getCourseMembers(courseId:searchLoginOrName:) API * Fix warning: - Deprecated: https://developer.apple.com/documentation/uikit/uiapplication/1622918-applicationiconbadgenumber * Add getChannelsPublicOverview * Duplicate SendMessageMemberPickerModel * Move SendMessageView * Improve search * Dependency injection * [R]un when this view initially appears * Stick to design system * Rename show*: is*Presented * Create SendMessageViewModel * Make Observables final * Move is[Modal]Presented * Test SendMessageViewModel * `Communication`: Navigate to exercises and lectures (#81) * Fix sheet * Add OpenURLAction * Check host * Inject UserSession * Split NavigationController * Format * `Exercise`: Add pull-to-refresh to ExerciseListView and ExerciseDetailView (#78) * initial pull-to-refresh feature * update apollon-ios-module dependency * `Modeling Exercise`: Improve Submit Button (#79) * add alert after submitting diagram * update Apollon-iOS-Module version * Improve submit button with colors * `Development`: Refactor ExerciseView (#82) * Make SendMessageViewModel primary * Initialize view model at caller * Move sendMessageType * Move isEditMode: isEditing * Format * Distinguish presentation * Organize ConversationViewModel * Extract button action * Inject dependencies * Remove conversationViewModel dependency * Create SendMessageViewModelDelegate; separate isLoading * Fix warning: - redundant_type_annotation * `Communication`: Restore draft message (#83) * Rename folder * Create schema * Fix runtime: Object 0x600000494860 of class AnyRepository deallocated with non-zero retain count 2. This object's deinit, or something called from it, may have created a strong reference to self which outlived deinit, resulting in a dangling reference. * Store context * Create MessagesRepository * Fix issue: https://github.com/ls1intum/artemis-ios/pull/83#discussion_r1508003860 * Add inverse relationship: https://www.hackingwithswift.com/quick-start/swiftdata/how-to-create-one-to-many-relationships * Change url to host; fix error: testRoundtrip(): failed: caught error: "SwiftDataError(_error: SwiftData.SwiftDataError._Error.unsupportedPredicate)" * Insert course model * Add message model * performOnDisappear * log verbose begin context access * Rename sendMessageType: configuration --------- Co-authored-by: Alexander Görtzen <40467337+AlexanderG2207@users.noreply.github.com> --- .../xcshareddata/swiftpm/Package.resolved | 12 +- ArtemisKit/Package.swift | 6 +- ArtemisKit/Sources/ArtemisKit/RootView.swift | 95 ++--- .../CourseRegistrationServiceImpl.swift | 2 +- .../ModelingExerciseViewModel.swift | 3 +- .../Views/EditModelingExerciseView.swift | 57 ++- .../ExerciseTab/ExerciseDetailView.swift | 17 +- .../ExerciseTab/ExerciseListView.swift | 5 + .../Resources/en.lproj/Localizable.strings | 6 + .../Models/BaseMessage+IsContinuation.swift | 25 ++ .../Messages/Models/ChannelIdAndNameDTO.swift | 11 + .../Messages/Models/Schema/Schema.swift | 13 + .../Messages/Models/Schema/SchemaV1.swift | 82 ++++ .../Messages/Navigation/MessagePath.swift | 44 +++ ...igationDestinationThreadViewModifier.swift | 19 + .../Repositories/MessagesRepository.swift | 124 ++++++ .../Resources/en.lproj/Localizable.strings | 6 +- .../MessagesService/MessagesService.swift | 5 + .../MessagesService/MessagesServiceImpl.swift | 27 +- .../MessagesService/MessagesServiceStub.swift | 197 ++++++++++ .../ConversationViewModel.swift | 170 ++++---- .../SendMessageMentionChannelViewModel.swift | 42 ++ .../SendMessageMentionMemberViewModel.swift | 33 ++ .../SendMessageViewModel.swift | 369 ++++++++++++++++++ .../SendMessageViewModelDelegate.swift | 24 ++ .../ConversationView/ConversationView.swift | 53 ++- .../MessageActionSheet.swift | 237 ++++++----- .../Views/MessageDetailView/MessageCell.swift | 160 +++++--- .../MessageDetailView/MessageDetailView.swift | 259 ++++++------ .../MessageDetailView/ReactionsView.swift | 224 ++++++----- .../MessagesTabView/CodeOfConductView.swift | 4 +- .../Messages/Views/SendMessageView.swift | 289 -------------- .../SendMessageExercisePicker.swift | 59 +++ .../SendMessageLecturePicker.swift | 33 ++ .../SendMessageMentionChannelView.swift | 58 +++ .../SendMessageMentionMemberView.swift | 58 +++ .../SendMessageViews/SendMessageView.swift | 173 ++++++++ .../Deeplinks/DeeplinkHandler.swift | 49 +-- .../Navigation/NavigationController.swift | 136 +------ .../Navigation/NavigationPathValues.swift | 77 ++++ .../Sources/Navigation/TabIdentifier.swift | 12 + .../Messages/MessagesRepositoryTests.swift | 31 ++ ...ndMessageChannelPickerViewModelTests.swift | 18 + .../Messages/SendMessageViewModelTests.swift | 88 +++++ 44 files changed, 2419 insertions(+), 993 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift create mode 100644 ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift create mode 100644 ArtemisKit/Sources/Messages/Models/Schema/Schema.swift create mode 100644 ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift create mode 100644 ArtemisKit/Sources/Messages/Navigation/MessagePath.swift create mode 100644 ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift create mode 100644 ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift create mode 100644 ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift delete mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift create mode 100644 ArtemisKit/Sources/Navigation/NavigationPathValues.swift create mode 100644 ArtemisKit/Sources/Navigation/TabIdentifier.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift create mode 100644 ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d614fa61..64d396c7 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/apollon-ios-module", "state" : { - "revision" : "1690e711415330b28e836cd8035e1805c0a4e479", - "version" : "1.0.2" + "revision" : "73a3999b4cdcdd0ae2b86426d65a7b75c6ac3af0", + "version" : "1.0.6" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "b5b5a7282691d27ea121aadc08b89369f3c8d566", - "version" : "9.0.0" + "revision" : "b14fec4f95b78587c9fa107353d0bca0895288b0", + "version" : "9.1.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "3ec0ab0bca4feb56e8b33e289c9496e89059dd08", - "version" : "7.10.2" + "revision" : "5b92f029fab2cce44386d28588098b5be0824ef5", + "version" : "7.11.0" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 0174e7da..b63beb2e 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: "9.0.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "9.1.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ @@ -125,6 +125,8 @@ let package = Package( ]), .testTarget( name: "ArtemisKitTests", - dependencies: []) + dependencies: [ + "Messages" + ]) ] ) diff --git a/ArtemisKit/Sources/ArtemisKit/RootView.swift b/ArtemisKit/Sources/ArtemisKit/RootView.swift index 86dea441..b0531eb2 100644 --- a/ArtemisKit/Sources/ArtemisKit/RootView.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootView.swift @@ -1,12 +1,12 @@ -import SwiftUI -import Login -import Dashboard +import Common import CourseRegistration import CourseView +import Dashboard +import Login +import Messages import Navigation import PushNotifications -import Common -import Messages +import SwiftUI public struct RootView: View { @@ -28,47 +28,7 @@ public struct RootView: View { if viewModel.didSetupNotifications { NavigationStack(path: $navigationController.path) { DashboardView() - .navigationDestination(for: CoursePath.self) { coursePath in - CourseView(courseId: coursePath.id) - .id(coursePath.id) - } - // Sadly the following navigationDestination have to be here since SwiftUI is ... - .navigationDestination(for: ExercisePath.self) { exercisePath in - if let course = exercisePath.coursePath.course, - let exercise = exercisePath.exercise { - ExerciseDetailView(course: course, exercise: exercise) - } else { - ExerciseDetailView(courseId: exercisePath.coursePath.id, exerciseId: exercisePath.id) - } - } - .navigationDestination(for: LecturePath.self) { lecturePath in - if let course = lecturePath.coursePath.course { - LectureDetailView(course: course, lectureId: lecturePath.id) - } else { - LectureDetailView(courseId: lecturePath.coursePath.id, lectureId: lecturePath.id) - } - } - .navigationDestination(for: MessagePath.self) { messagePath in - if let message = messagePath.message, - let conversationViewModel = messagePath.conversationViewModel as? ConversationViewModel { - MessageDetailView(viewModel: conversationViewModel, - message: message) - } else { - MessageDetailView(viewModel: ConversationViewModel(courseId: messagePath.coursePath.id, - conversationId: messagePath.conversationPath.id), - messageId: messagePath.id) - } - } - .navigationDestination(for: ConversationPath.self) { conversationPath in - if let conversation = conversationPath.conversation, - let course = conversationPath.coursePath.course { - ConversationView(course: course, - conversation: conversation) - } else { - ConversationView(courseId: conversationPath.coursePath.id, - conversationId: conversationPath.id) - } - } + .modifier(NavigationDestinationRootViewModifier()) } .onChange(of: navigationController.path) { log.debug("NavigationController count: \(navigationController.path.count)") @@ -77,6 +37,13 @@ public struct RootView: View { .onOpenURL { url in DeeplinkHandler.shared.handle(url: url) } + .environment(\.openURL, OpenURLAction { url in + if DeeplinkHandler.shared.handle(url: url) { + return .handled + } else { + return .systemAction + } + }) } else { PushNotificationSetupView() } @@ -97,3 +64,39 @@ public struct RootView: View { }) } } + +private struct NavigationDestinationRootViewModifier: ViewModifier { + func body(content: Content) -> some View { + content + .navigationDestination(for: CoursePath.self) { coursePath in + CourseView(courseId: coursePath.id) + .id(coursePath.id) + } + .navigationDestination(for: ExercisePath.self) { exercisePath in + if let course = exercisePath.coursePath.course, + let exercise = exercisePath.exercise { + ExerciseDetailView(course: course, exercise: exercise) + } else { + ExerciseDetailView(courseId: exercisePath.coursePath.id, exerciseId: exercisePath.id) + } + } + .navigationDestination(for: LecturePath.self) { lecturePath in + if let course = lecturePath.coursePath.course { + LectureDetailView(course: course, lectureId: lecturePath.id) + } else { + LectureDetailView(courseId: lecturePath.coursePath.id, lectureId: lecturePath.id) + } + } + .navigationDestination(for: ConversationPath.self) { conversationPath in + if let conversation = conversationPath.conversation, + let course = conversationPath.coursePath.course { + ConversationView(course: course, + conversation: conversation) + } else { + ConversationView(courseId: conversationPath.coursePath.id, + conversationId: conversationPath.id) + } + } + .modifier(NavigationDestinationThreadViewModifier()) + } +} diff --git a/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift index e3b9ce9b..c02596f7 100644 --- a/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift +++ b/ArtemisKit/Sources/CourseRegistration/Services/CourseRegistrationService/CourseRegistrationServiceImpl.swift @@ -48,7 +48,7 @@ class CourseRegistrationServiceImpl: CourseRegistrationService { let result = await client.sendRequest(RegisterCourseRequest(courseId: courseId)) switch result { - case .success((let response, _)): + case .success: return .success case .failure(let error): return .failure(error: UserFacingError(error: error)) diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift index 4c6b14a9..694a1b1b 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/ModelingExerciseViewModel.swift @@ -102,7 +102,7 @@ class ModelingExerciseViewModel: BaseViewModel { } } - func submitSubmission() async { + func submitSubmission() async throws { guard var submitSubmission = submission as? ModelingSubmission, let umlModel else { return } @@ -117,6 +117,7 @@ class ModelingExerciseViewModel: BaseViewModel { try await exerciseService.updateSubmission(exerciseId: exercise.id, submission: submitSubmission) } catch { log.error(String(describing: error)) + throw error } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift index dd0a5cc7..094be0c9 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseParticipation/ModelingExercise/Views/EditModelingExerciseView.swift @@ -13,6 +13,8 @@ import DesignLibrary struct EditModelingExerciseView: View { @StateObject var modelingViewModel: ModelingExerciseViewModel + @State private var showSubmissionAlert = false + @State private var isSubmissionSuccessful = false init(exercise: Exercise, participationId: Int, problemStatementURL: URLRequest) { self._modelingViewModel = StateObject(wrappedValue: ModelingExerciseViewModel(exercise: exercise, @@ -47,27 +49,70 @@ struct EditModelingExerciseView: View { if !modelingViewModel.diagramTypeUnsupported { HStack { ProblemStatementButton(modelingViewModel: modelingViewModel) - SubmitButton(modelingViewModel: modelingViewModel) + SubmitButton(modelingViewModel: modelingViewModel, showSubmissionAlert: $showSubmissionAlert, 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()) + ) + } + } } } 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 { - Task { - await modelingViewModel.submitSubmission() - } + submit() } label: { - Text(R.string.localizable.submitSubmission()) - }.buttonStyle(ArtemisButton()) + 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) + } + + private func submit() { + isSubmitting = true + Task { + do { + try await modelingViewModel.submitSubmission() + isSubmissionSuccessful = true + } catch { + isSubmissionSuccessful = false + } + withAnimation { + isSubmitting = false + showSubmissionAlert.toggle() + } + } } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift index 96d45a68..1aeba55e 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailView.swift @@ -62,7 +62,7 @@ public struct ExerciseDetailView: View { private var showFeedbackButton: Bool { switch exercise.value { - case .fileUpload, .modeling, .programming, .text: + case .fileUpload, .programming, .text: return true default: return false @@ -240,16 +240,23 @@ public struct ExerciseDetailView: View { .task { await loadExercise() } + .refreshable { + await refreshExercise() + } } private func loadExercise() async { if let exercise = exercise.value { setParticipationAndResultId(from: exercise) } else { - self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) - if let exercise = self.exercise.value { - setParticipationAndResultId(from: exercise) - } + await refreshExercise() + } + } + + private func refreshExercise() async { + self.exercise = await ExerciseServiceFactory.shared.getExercise(exerciseId: exerciseId) + if let exercise = self.exercise.value { + setParticipationAndResultId(from: exercise) } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index 9ffabd1c..c7d93197 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -76,6 +76,11 @@ struct ExerciseListView: View { } } } + .refreshable { + if let courseId = viewModel.course.value?.id { + await viewModel.loadCourse(id: courseId) + } + } } } diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index 4e178c93..79c97747 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -66,6 +66,12 @@ "difficulty" = "Difficulty:"; "categories" = "Categories:"; +// Modeling Submission Alerts +"successfulSubmissionAlertTitle" = "Your submission was successful!"; +"successfulSubmissionAlertMessage" = "You can change your submission or wait for your feedback."; +"failedSubmissionAlertTitle" = "An error occurred..."; +"failedSubmissionAlertMessage" = "Please try again later."; + // Modeling Feedback "modelingFeedbackElement" = "Element"; "modelingFeedbackPoints" = "Points"; diff --git a/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift new file mode 100644 index 00000000..f7301231 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/BaseMessage+IsContinuation.swift @@ -0,0 +1,25 @@ +// +// BaseMessage+IsContinuation.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import Foundation +import SharedModels + +// swiftlint:disable:next identifier_name +private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 + +extension BaseMessage { + /// Whether the same author messaged multiple times within 5 minutes. + func isContinuation(of message: some BaseMessage) -> Bool { + guard author == message.author, + let lhs = creationDate, + let rhs = message.creationDate else { + return false + } + + return lhs < rhs.addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60)) + } +} diff --git a/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift b/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift new file mode 100644 index 00000000..daeba748 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/ChannelIdAndNameDTO.swift @@ -0,0 +1,11 @@ +// +// ChannelIdAndNameDTO.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +struct ChannelIdAndNameDTO: Codable, Identifiable { + let id: Int + let name: String +} diff --git a/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift b/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift new file mode 100644 index 00000000..5cdb85f8 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/Schema/Schema.swift @@ -0,0 +1,13 @@ +// +// Schema.swift +// +// +// Created by Nityananda Zbil on 29.02.24. +// + +// Alias for the most recent schema + +typealias ServerModel = SchemaV1.Server +typealias CourseModel = SchemaV1.Course +typealias ConversationModel = SchemaV1.Conversation +typealias MessageModel = SchemaV1.Message diff --git a/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift new file mode 100644 index 00000000..aaeed5d7 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Models/Schema/SchemaV1.swift @@ -0,0 +1,82 @@ +// +// SchemaV1.swift +// +// +// Created by Nityananda Zbil on 29.02.24. +// + +import Foundation +import SwiftData + +enum SchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + + static var models: [any PersistentModel.Type] { + [Server.self, Course.self, Conversation.self, Message.self] + } + + @Model + final class Server { + @Attribute(.unique) + var host: String + + @Relationship(deleteRule: .cascade, inverse: \Course.server) + var courses: [Course] + + init(host: String, courses: [Course] = []) { + self.host = host + self.courses = courses + } + } + + @Model + final class Course { + var server: Server + + @Attribute(.unique) + var courseId: Int + + @Relationship(deleteRule: .cascade, inverse: \Conversation.course) + var conversations: [Conversation] + + init(server: Server, courseId: Int, conversations: [Conversation] = []) { + self.server = server + self.courseId = courseId + self.conversations = conversations + } + } + + @Model + final class Conversation { + var course: Course + + @Attribute(.unique) + var conversationId: Int + + /// A user's draft of a message, which they began to compose. + var messageDraft: String + + init(course: Course, conversationId: Int, messageDraft: String = "") { + self.course = course + self.conversationId = conversationId + self.messageDraft = messageDraft + } + } + + @Model + final class Message { + var conversation: Conversation + + @Attribute(.unique) + var messageId: Int + + /// A user's draft of an answer message, which they began to compose. + var answerMessageDraft: String + + init(conversation: Conversation, messageId: Int, answerMessageDraft: String = "") { + self.conversation = conversation + self.messageId = messageId + self.answerMessageDraft = answerMessageDraft + } + } +} diff --git a/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift b/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift new file mode 100644 index 00000000..6ca2ac17 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Navigation/MessagePath.swift @@ -0,0 +1,44 @@ +// +// MessagePath.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import Common +import Navigation +import SharedModels +import SwiftUI + +struct MessagePath: Hashable { + let id: Int64 + let message: Binding> + let coursePath: CoursePath + let conversationPath: ConversationPath + let conversationViewModel: ConversationViewModel + + init?( + message: Binding>, + coursePath: CoursePath, + conversationPath: ConversationPath, + conversationViewModel: ConversationViewModel + ) { + guard let id = message.wrappedValue.value?.id else { + return nil + } + + self.id = id + self.message = message + self.coursePath = coursePath + self.conversationPath = conversationPath + self.conversationViewModel = conversationViewModel + } + + static func == (lhs: MessagePath, rhs: MessagePath) -> Bool { + lhs.id == rhs.id && lhs.coursePath == rhs.coursePath && lhs.conversationPath == rhs.conversationPath + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift new file mode 100644 index 00000000..c8224765 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Navigation/NavigationDestinationThreadViewModifier.swift @@ -0,0 +1,19 @@ +// +// NavigationDestinationThreadViewModifier.swift +// +// +// Created by Nityananda Zbil on 07.02.24. +// + +import SwiftUI + +/// Navigates to a thread view of a message. +public struct NavigationDestinationThreadViewModifier: ViewModifier { + public init() {} + + public func body(content: Content) -> some View { + content.navigationDestination(for: MessagePath.self) { messagePath in + MessageDetailView(viewModel: messagePath.conversationViewModel, message: messagePath.message) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift new file mode 100644 index 00000000..c98e7d5a --- /dev/null +++ b/ArtemisKit/Sources/Messages/Repositories/MessagesRepository.swift @@ -0,0 +1,124 @@ +// +// MessagesRepository.swift +// +// +// Created by Nityananda Zbil on 28.02.24. +// + +import Common +import Foundation +import SwiftData + +@MainActor +final class MessagesRepository { + static let shared: MessagesRepository = { + do { + return try MessagesRepository() + } catch { + log.error(error) + fatalError("Failed to initialize repository") + } + }() + + private let context: ModelContext + + init() throws { + let schema = Schema(versionedSchema: SchemaV1.self) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: configuration) + self.context = container.mainContext + } + + deinit { + do { + try context.save() + } catch { + log.error(error) + } + } +} + +extension MessagesRepository { + + // MARK: - Server + + @discardableResult + func insertServer(host: String) -> ServerModel { + log.verbose("begin") + let server = ServerModel(host: host) + context.insert(server) + return server + } + + func fetchServer(host: String) throws -> ServerModel? { + log.verbose("begin") + let predicate = #Predicate { server in + server.host == host + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Course + + @discardableResult + func insertCourse(host: String, courseId: Int) throws -> CourseModel { + log.verbose("begin") + let server = try fetchServer(host: host) ?? insertServer(host: host) + let course = CourseModel(server: server, courseId: courseId) + context.insert(course) + return course + } + + func fetchCourse(host: String, courseId: Int) throws -> CourseModel? { + log.verbose("begin") + let predicate = #Predicate { course in + course.server.host == host + && course.courseId == courseId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Conversation + + @discardableResult + func insertConversation(host: String, courseId: Int, conversationId: Int, messageDraft: String) throws -> ConversationModel { + log.verbose("begin") + let course = try fetchCourse(host: host, courseId: courseId) ?? insertCourse(host: host, courseId: courseId) + let conversation = ConversationModel(course: course, conversationId: conversationId, messageDraft: messageDraft) + context.insert(conversation) + return conversation + } + + func fetchConversation(host: String, courseId: Int, conversationId: Int) throws -> ConversationModel? { + log.verbose("begin") + let predicate = #Predicate { conversation in + conversation.course.server.host == host + && conversation.course.courseId == courseId + && conversation.conversationId == conversationId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } + + // MARK: - Message + + @discardableResult + func insertMessage(host: String, courseId: Int, conversationId: Int, messageId: Int, answerMessageDraft: String) throws -> MessageModel { + log.verbose("begin") + let conversation = try fetchConversation(host: host, courseId: courseId, conversationId: conversationId) + ?? insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: "") + let message = MessageModel(conversation: conversation, messageId: messageId, answerMessageDraft: answerMessageDraft) + context.insert(message) + return message + } + + func fetchMessage(host: String, courseId: Int, conversationId: Int, messageId: Int) throws -> MessageModel? { + log.verbose("begin") + let predicate = #Predicate { message in + message.conversation.course.server.host == host + && message.conversation.course.courseId == courseId + && message.conversation.conversationId == conversationId + && message.messageId == messageId + } + return try context.fetch(FetchDescriptor(predicate: predicate)).first + } +} diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index ab17c4de..74c70d80 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -7,7 +7,11 @@ // MARK: SendMessageView "exercise" = "Exercise"; +"exercisesUnavailable" = "No Exercises"; "lecture" = "Lecture"; +"channelsUnavailable" = "No Channels"; +"lecturesUnavailable" = "No Lectures"; +"membersUnavailable" = "No Members"; "messageAction" = "Message %@"; // MARK: ReactionsView @@ -50,7 +54,7 @@ // MARK: ConversationView "noMessages" = "No Messages"; "noMessagesDescription" = "Write the first message to kickstart this conversation."; -"replyAction" = "%d reply"; +"reply" = "reply"; "new" = "New"; // MARK: CreateChannelView diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index 33dedaaf..130afaa2 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -88,6 +88,11 @@ protocol MessagesService { */ func getChannelsOverview(for courseId: Int) async -> DataState<[Channel]> + /** + * Perform a get request to retrieve all channels in the public overview in a specific course to the server. + */ + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> + /** * Perform a post request to add members to a specific channels in a specific course to the server. */ diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index 08d83331..4b2411cb 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Sven Andabaka on 03.04.23. // @@ -437,6 +437,31 @@ class MessagesServiceImpl: MessagesService { } } + struct GetChannelsPublicOverviewRequest: APIRequest { + typealias Response = [ChannelIdAndNameDTO] + + let courseId: Int + + var method: HTTPMethod { + return .get + } + + var resourceName: String { + return "api/courses/\(courseId)/channels/public-overview" + } + } + + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> { + let result = await client.sendRequest(GetChannelsPublicOverviewRequest(courseId: courseId)) + + switch result { + case let .success((channels, _)): + return .done(response: channels) + case let .failure(error): + return .failure(error: UserFacingError(error: error)) + } + } + struct AddMembersToChannelRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift new file mode 100644 index 00000000..bf2d5365 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -0,0 +1,197 @@ +// +// MessagesServiceStub.swift +// +// +// Created by Nityananda Zbil on 14.02.24. +// + +import Common +import Foundation +import SharedModels + +struct MessagesServiceStub { + static let now: Date = { + // swiftlint:disable:next force_try + try! Date("2024-01-08T9:41:32Z", strategy: .iso8601) + }() + + static let course: Course = { + let course = Course(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging) + return course + }() + + static let conversation: Conversation = { + var oneToOneChat = OneToOneChat(id: 1) + oneToOneChat.lastReadDate = now + let conversation = Conversation.oneToOneChat(conversation: oneToOneChat) + return conversation + }() + + static let alice: ConversationUser = { + var author = ConversationUser(id: 1) + author.name = "Alice" + return author + }() + + static let bob: ConversationUser = { + var author = ConversationUser(id: 2) + author.name = "Bob" + return author + }() + + static let message: Message = { + var message = Message(id: 1) + message.author = alice + message.creationDate = Calendar.current.date(byAdding: .minute, value: 1, to: now) + + message.content = "Hello, world!" + + message.updatedDate = Calendar.current.date(byAdding: .minute, value: 2, to: now) + + message.reactions = [ + Reaction(id: 1), + Reaction(id: 2), + Reaction(id: 3, emojiId: "heart") + ] + + message.answers = [answer] + + return message + }() + + static let answer: AnswerMessage = { + var answer = AnswerMessage(id: 2) + answer.author = bob + answer.creationDate = Calendar.current.date(byAdding: .minute, value: 3, to: now) + answer.content = "Hello, Alice!" + return answer + }() + + static let continuation: Message = { + var message = Message(id: 3) + message.author = alice + message.creationDate = Calendar.current.date(byAdding: .minute, value: 4, to: now) + message.content = "How are you?" + return message + }() + + static let reply: Message = { + var message = Message(id: 4) + message.author = bob + message.creationDate = Calendar.current.date(byAdding: .minute, value: 4, to: now) + message.content = "I am great." + return message + }() + + var messages: [Message] = [message, continuation, reply] +} + +extension MessagesServiceStub: MessagesService { + func getConversations(for courseId: Int) async -> DataState<[Conversation]> { + .loading + } + + func updateIsConversationFavorite(for courseId: Int, and conversationId: Int64, isFavorite: Bool) async -> NetworkResponse { + .loading + } + + func updateIsConversationMuted(for courseId: Int, and conversationId: Int64, isMuted: Bool) async -> NetworkResponse { + .loading + } + + func updateIsConversationHidden(for courseId: Int, and conversationId: Int64, isHidden: Bool) async -> NetworkResponse { + .loading + } + + func getMessages(for courseId: Int, and conversationId: Int64, size: Int) async -> DataState<[Message]> { + .done(response: messages) + } + + func sendMessage(for courseId: Int, conversation: Conversation, content: String) async -> NetworkResponse { + .loading + } + + func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse { + .loading + } + + func deleteMessage(for courseId: Int, messageId: Int64) async -> NetworkResponse { + .loading + } + + func deleteAnswerMessage(for courseId: Int, anserMessageId: Int64) async -> NetworkResponse { + .loading + } + + func editMessage(for courseId: Int, message: Message) async -> NetworkResponse { + .loading + } + + func editAnswerMessage(for courseId: Int, answerMessage: AnswerMessage) async -> NetworkResponse { + .loading + } + + func addReactionToAnswerMessage(for courseId: Int, answerMessage: AnswerMessage, emojiId: String) async -> NetworkResponse { + .loading + } + + func addReactionToMessage(for courseId: Int, message: Message, emojiId: String) async -> NetworkResponse { + .loading + } + + func removeReactionFromMessage(for courseId: Int, reaction: Reaction) async -> NetworkResponse { + .loading + } + + func getChannelsOverview(for courseId: Int) async -> DataState<[Channel]> { + .loading + } + + func getChannelsPublicOverview(for courseId: Int) async -> DataState<[ChannelIdAndNameDTO]> { + .done(response: [ChannelIdAndNameDTO(id: 2, name: "announcement")]) + } + + func addMembersToChannel(for courseId: Int, channelId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func removeMembersFromChannel(for courseId: Int, channelId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func addMembersToGroupChat(for courseId: Int, groupChatId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func removeMembersFromGroupChat(for courseId: Int, groupChatId: Int64, usernames: [String]) async -> NetworkResponse { + .loading + } + + func createChannel(for courseId: Int, name: String, description: String?, isPrivate: Bool, isAnnouncement: Bool) async -> DataState { + .loading + } + + func searchForUsers(for courseId: Int, searchText: String) async -> DataState<[ConversationUser]> { + .loading + } + + func createGroupChat(for courseId: Int, usernames: [String]) async -> DataState { + .loading + } + + func createOneToOneChat(for courseId: Int, usernames: [String]) async -> DataState { + .loading + } + + func getMembersOfConversation(for courseId: Int, conversationId: Int64, page: Int) async -> DataState<[ConversationUser]> { + .loading + } + + func archiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { + .loading + } + + func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { + .loading + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index 8cceb13a..2251bef0 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -14,7 +14,7 @@ import UserStore // swiftlint:disable file_length @MainActor -public class ConversationViewModel: BaseViewModel { +class ConversationViewModel: BaseViewModel { @Published var dailyMessages: DataState<[Date: [Message]]> = .loading @Published var conversation: DataState = .loading @@ -28,23 +28,52 @@ public class ConversationViewModel: BaseViewModel { private var size = 50 - public init(course: Course, conversation: Conversation) { + private let courseService: CourseService + private let messagesService: MessagesService + private let stompClient: ArtemisStompClient + private let userSession: UserSession + + init( + course: Course, + conversation: Conversation, + courseService: CourseService = CourseServiceFactory.shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + stompClient: ArtemisStompClient = .shared, + userSession: UserSession = .shared + ) { self._course = Published(wrappedValue: .done(response: course)) self.courseId = course.id self._conversation = Published(wrappedValue: .done(response: conversation)) self.conversationId = conversation.id + self.courseService = courseService + self.messagesService = messagesService + self.stompClient = stompClient + self.userSession = userSession + super.init() subscribeToConversationTopic() } - public init(courseId: Int, conversationId: Int64) { + init( + courseId: Int, + conversationId: Int64, + courseService: CourseService = CourseServiceFactory.shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + stompClient: ArtemisStompClient = .shared, + userSession: UserSession = .shared + ) { self.courseId = courseId self.conversationId = conversationId self._conversation = Published(wrappedValue: .loading) self._course = Published(wrappedValue: .loading) + self.courseService = courseService + self.messagesService = messagesService + self.stompClient = stompClient + self.userSession = userSession + super.init() Task { @@ -60,6 +89,13 @@ public class ConversationViewModel: BaseViewModel { deinit { websocketSubscriptionTask?.cancel() } +} + +// MARK: - Internal + +extension ConversationViewModel { + + // MARK: Load func loadFurtherMessages() async { size += 50 @@ -73,7 +109,7 @@ public class ConversationViewModel: BaseViewModel { } func loadMessages() async { - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -83,13 +119,15 @@ public class ConversationViewModel: BaseViewModel { case .done(let response): var dailyMessages: [Date: [Message]] = [:] - response.forEach { message in + for message in response { if let date = message.creationDate?.startOfDay { if dailyMessages[date] == nil { dailyMessages[date] = [message] } else { dailyMessages[date]?.append(message) - dailyMessages[date] = dailyMessages[date]?.sorted(by: { $0.creationDate! < $1.creationDate! }) + dailyMessages[date] = dailyMessages[date]?.sorted { + $0.creationDate! < $1.creationDate! + } } } } @@ -100,7 +138,7 @@ public class ConversationViewModel: BaseViewModel { func loadMessage(messageId: Int64) async -> DataState { // TODO: add API to only load one single message - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -117,7 +155,7 @@ public class ConversationViewModel: BaseViewModel { func loadAnswerMessage(answerMessageId: Int64) async -> DataState { // TODO: add API to only load one single answer message - let result = await MessagesServiceFactory.shared.getMessages(for: courseId, and: conversationId, size: size) + let result = await messagesService.getMessages(for: courseId, and: conversationId, size: size) switch result { case .loading: @@ -133,59 +171,15 @@ public class ConversationViewModel: BaseViewModel { } } - func sendMessage(text: String) async -> NetworkResponse { - guard let conversation = conversation.value else { - let error = UserFacingError(title: R.string.localizable.conversationNotLoaded()) - presentError(userFacingError: error) - return .failure(error: error) - } - isLoading = true - let result = await MessagesServiceFactory.shared.sendMessage(for: courseId, conversation: conversation, content: text) - switch result { - case .notStarted, .loading: - isLoading = false - case .success: - shouldScrollToId = "bottom" - await loadMessages() - isLoading = false - case .failure(let error): - isLoading = false - if let apiClientError = error as? APIClientError { - presentError(userFacingError: UserFacingError(error: apiClientError)) - } else { - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - } - } - return result - } - - func sendAnswerMessage(text: String, for message: Message, completion: () async -> Void) async -> NetworkResponse { - isLoading = true - let result = await MessagesServiceFactory.shared.sendAnswerMessage(for: courseId, message: message, content: text) - switch result { - case .notStarted, .loading: - isLoading = false - case .success: - await completion() - isLoading = false - case .failure(let error): - isLoading = false - if let apiClientError = error as? APIClientError { - presentError(userFacingError: UserFacingError(error: apiClientError)) - } else { - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - } - } - return result - } + // MARK: React func addReactionToMessage(for message: Message, emojiId: String) async -> DataState { isLoading = true let result: NetworkResponse if let reaction = message.getReactionFromMe(emojiId: emojiId) { - result = await MessagesServiceFactory.shared.removeReactionFromMessage(for: courseId, reaction: reaction) + result = await messagesService.removeReactionFromMessage(for: courseId, reaction: reaction) } else { - result = await MessagesServiceFactory.shared.addReactionToMessage(for: courseId, message: message, emojiId: emojiId) + result = await messagesService.addReactionToMessage(for: courseId, message: message, emojiId: emojiId) } switch result { case .notStarted, .loading: @@ -214,9 +208,9 @@ public class ConversationViewModel: BaseViewModel { isLoading = true let result: NetworkResponse if let reaction = message.getReactionFromMe(emojiId: emojiId) { - result = await MessagesServiceFactory.shared.removeReactionFromMessage(for: courseId, reaction: reaction) + result = await messagesService.removeReactionFromMessage(for: courseId, reaction: reaction) } else { - result = await MessagesServiceFactory.shared.addReactionToAnswerMessage(for: courseId, answerMessage: message, emojiId: emojiId) + result = await messagesService.addReactionToAnswerMessage(for: courseId, answerMessage: message, emojiId: emojiId) } switch result { case .notStarted, .loading: @@ -241,13 +235,15 @@ public class ConversationViewModel: BaseViewModel { } } + // MARK: Delete + func deleteMessage(messageId: Int64?) async -> Bool { guard let messageId else { presentError(userFacingError: UserFacingError(title: R.string.localizable.deletionErrorLabel())) return false } - let result = await MessagesServiceFactory.shared.deleteMessage(for: courseId, messageId: messageId) + let result = await messagesService.deleteMessage(for: courseId, messageId: messageId) switch result { case .notStarted, .loading: @@ -267,37 +263,7 @@ public class ConversationViewModel: BaseViewModel { return false } - let result = await MessagesServiceFactory.shared.deleteAnswerMessage(for: courseId, anserMessageId: messageId) - - switch result { - case .notStarted, .loading: - return false - case .success: - await loadMessages() - return true - case .failure(let error): - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - return false - } - } - - func editMessage(message: Message) async -> Bool { - let result = await MessagesServiceFactory.shared.editMessage(for: courseId, message: message) - - switch result { - case .notStarted, .loading: - return false - case .success: - await loadMessages() - return true - case .failure(let error): - presentError(userFacingError: UserFacingError(title: error.localizedDescription)) - return false - } - } - - func editAnswerMessage(answerMessage: AnswerMessage) async -> Bool { - let result = await MessagesServiceFactory.shared.editAnswerMessage(for: courseId, answerMessage: answerMessage) + let result = await messagesService.deleteAnswerMessage(for: courseId, anserMessageId: messageId) switch result { case .notStarted, .loading: @@ -312,11 +278,14 @@ public class ConversationViewModel: BaseViewModel { } } -// MARK: Start (initializer) +// MARK: - Private private extension ConversationViewModel { + + // MARK: Start (initializer) + func loadConversation() async { - let result = await MessagesServiceFactory.shared.getConversations(for: courseId) + let result = await messagesService.getConversations(for: courseId) switch result { case .loading: @@ -333,7 +302,7 @@ private extension ConversationViewModel { } func loadCourse() async { - let result = await CourseServiceFactory.shared.getCourse(courseId: courseId) + let result = await courseService.getCourse(courseId: courseId) switch result { case .loading: @@ -349,31 +318,34 @@ private extension ConversationViewModel { let topic: String if conversation.value?.baseConversation.type == .channel { topic = WebSocketTopic.makeChannelNotifications(courseId: courseId) - } else if let id = UserSession.shared.user?.id { + } else if let id = userSession.user?.id { topic = WebSocketTopic.makeConversationNotifications(userId: id) } else { return } - if ArtemisStompClient.shared.didSubscribeTopic(topic) { + if stompClient.didSubscribeTopic(topic) { return } websocketSubscriptionTask = Task { [weak self] in - let stream = ArtemisStompClient.shared.subscribe(to: topic) + guard let stream = self?.stompClient.subscribe(to: topic) else { + return + } for await message in stream { guard let messageWebsocketDTO = JSONDecoder.getTypeFromSocketMessage(type: MessageWebsocketDTO.self, message: message) else { continue } - self?.onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) + guard let self else { + return + } + onMessageReceived(messageWebsocketDTO: messageWebsocketDTO) } } } -} -// MARK: Receive message + // MARK: Receive message -private extension ConversationViewModel { func onMessageReceived(messageWebsocketDTO: MessageWebsocketDTO) { // Guard message corresponds to conversation guard messageWebsocketDTO.post.conversation?.id == conversation.value?.id else { diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift new file mode 100644 index 00000000..7a50d9c4 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionChannelViewModel.swift @@ -0,0 +1,42 @@ +// +// SendMessageMentionChannelViewModel.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +import Common +import SharedModels +import SharedServices +import SwiftUI + +@Observable +final class SendMessageMentionChannelViewModel { + + let course: Course + + var channels: DataState<[ChannelIdAndNameDTO]> = .loading + + private let messagesService: MessagesService + + init( + course: Course, + messagesService: MessagesService = MessagesServiceFactory.shared + ) { + self.course = course + self.messagesService = messagesService + } + + func search(idOrName: String) async { + let channels = await messagesService.getChannelsPublicOverview(for: course.id) + if case let .done(channels) = channels { + let filtered = channels.filter { channel in + let range = channel.name.range(of: idOrName, options: [.caseInsensitive, .diacriticInsensitive]) + return range != nil + } + self.channels = .done(response: filtered) + } else { + self.channels = channels + } + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift new file mode 100644 index 00000000..8c16fe54 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionMemberViewModel.swift @@ -0,0 +1,33 @@ +// +// SendMessageMentionMemberViewModel.swift +// +// +// Created by Nityananda Zbil on 28.10.23. +// + +import Common +import SharedModels +import SharedServices +import SwiftUI + +@Observable +final class SendMessageMentionMemberViewModel { + + let course: Course + + var members: DataState<[UserNameAndLoginDTO]> = .loading + + private let courseService: CourseService + + init( + course: Course, + courseService: CourseService = CourseServiceFactory.shared + ) { + self.course = course + self.courseService = courseService + } + + func search(loginOrName: String) async { + members = await courseService.getCourseMembers(courseId: course.id, searchLoginOrName: loginOrName) + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift new file mode 100644 index 00000000..97ad0648 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -0,0 +1,369 @@ +// +// SendMessageViewModel.swift +// +// +// Created by Nityananda Zbil on 22.02.24. +// + +import APIClient +import Common +import Foundation +import SharedModels +import UserStore + +extension SendMessageViewModel { + enum Configuration { + case message + case answerMessage(Message, () async -> Void) + case editMessage(Message, () -> Void) + case editAnswerMessage(AnswerMessage, () -> Void) + } + + enum ConditionalPresentation { + case memberPicker + case channelPicker + } + + enum ModalPresentation: Identifiable { + case exercisePicker + case lecturePicker + + var id: Self { + self + } + } +} + +@Observable +final class SendMessageViewModel { + let course: Course + let conversation: Conversation + let configuration: Configuration + + private let delegate: SendMessageViewModelDelegate + private let messagesRepository: MessagesRepository + private let messagesService: MessagesService + private let userSession: UserSession + + // MARK: Loading + + var isLoading = false + + // MARK: Text + + var text = "" + + var isEditing: Bool { + switch configuration { + case .message, .answerMessage: + return false + case .editMessage, .editAnswerMessage: + return true + } + } + + // MARK: Presentation + + var conditionalPresentation: ConditionalPresentation? { + if !isMemberPickerSuppressed, searchMember() != nil { + .memberPicker + } else if !isChannelPickerSuppressed, searchChannel() != nil { + .channelPicker + } else { + nil + } + } + + var isMemberPickerSuppressed = false + var isChannelPickerSuppressed = false + + var modalPresentation: ModalPresentation? + + // MARK: Life cycle + + init( + course: Course, + conversation: Conversation, + configuration: Configuration, + delegate: SendMessageViewModelDelegate, + messagesRepository: MessagesRepository = .shared, + messagesService: MessagesService = MessagesServiceFactory.shared, + userSession: UserSession = .shared + ) { + self.course = course + self.conversation = conversation + self.configuration = configuration + + self.delegate = delegate + self.messagesRepository = messagesRepository + self.messagesService = messagesService + self.userSession = userSession + } +} + +// MARK: - Actions + +extension SendMessageViewModel { + @MainActor + func performOnAppear() { + do { + switch configuration { + case .message: + if let host = userSession.institution?.baseURL?.host() { + let conversation = try messagesRepository.fetchConversation( + host: host, + courseId: course.id, + conversationId: Int(conversation.id)) + text = conversation?.messageDraft ?? "" + } + case let .answerMessage(message, _): + if let host = userSession.institution?.baseURL?.host() { + let message = try messagesRepository.fetchMessage( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageId: Int(message.id)) + text = message?.answerMessageDraft ?? "" + } + case let .editMessage(message, _): + text = message.content ?? "" + case let .editAnswerMessage(message, _): + text = message.content ?? "" + } + } catch { + log.error(error) + } + } + + @MainActor + func performOnDisappear() { + do { + if let host = userSession.institution?.baseURL?.host() { + switch configuration { + case .message: + try messagesRepository.insertConversation( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageDraft: text) + case let .answerMessage(message, _): + try messagesRepository.insertMessage( + host: host, + courseId: course.id, + conversationId: Int(conversation.id), + messageId: Int(message.id), + answerMessageDraft: text) + default: + break + } + } + } catch { + log.error(error) + } + } + + // MARK: Toolbar + + func didTapBoldButton() { + text.append("****") + } + + func didTapItalicButton() { + text.append("**") + } + + func didTapUnderlineButton() { + text.append("") + } + + func didTapBlockquoteButton() { + text.append("> Reference") + } + + func didTapCodeButton() { + text.append("``") + } + + func didTapCodeBlockButton() { + text.append(""" + ```java + Source Code + ``` + """) + } + + func didTapLinkButton() { + text.append("[](http://)") + } + + func didTapAtButton() { + if conditionalPresentation == .memberPicker { + isMemberPickerSuppressed = true + } else { + isMemberPickerSuppressed = false + text += "@" + } + } + + func didTapNumberButton() { + if conditionalPresentation == .channelPicker { + isChannelPickerSuppressed = true + } else { + isChannelPickerSuppressed = false + text += "#" + } + } + + // MARK: Send Message + + func didTapSendButton() { + isLoading = true + Task { @MainActor in + var result: NetworkResponse? + switch configuration { + case .message: + result = await sendMessage(text: text) + case let .answerMessage(message, completion): + result = await sendAnswerMessage(text: text, for: message, completion: completion) + case let .editMessage(message, completion): + var newMessage = message + newMessage.content = text + let success = await editMessage(message: newMessage) + isLoading = false + if success { + completion() + } + case let .editAnswerMessage(message, completion): + var newMessage = message + newMessage.content = text + let success = await editAnswerMessage(answerMessage: newMessage) + isLoading = false + if success { + completion() + } + } + switch result { + case .success: + text = "" + default: + return + } + } + } + + @MainActor + private func sendMessage(text: String) async -> NetworkResponse { + isLoading = true + let result = await messagesService.sendMessage(for: course.id, conversation: conversation, content: text) + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + delegate.scrollToId("bottom") + await delegate.loadMessages() + isLoading = false + case .failure(let error): + isLoading = false + if let apiClientError = error as? APIClientError { + delegate.presentError(UserFacingError(error: apiClientError)) + } else { + delegate.presentError(UserFacingError(title: error.localizedDescription)) + } + } + return result + } + + @MainActor + private func sendAnswerMessage(text: String, for message: Message, completion: () async -> Void) async -> NetworkResponse { + isLoading = true + let result = await messagesService.sendAnswerMessage(for: course.id, message: message, content: text) + switch result { + case .notStarted, .loading: + isLoading = false + case .success: + await completion() + isLoading = false + case .failure(let error): + isLoading = false + if let apiClientError = error as? APIClientError { + delegate.presentError(UserFacingError(error: apiClientError)) + } else { + delegate.presentError(UserFacingError(title: error.localizedDescription)) + } + } + return result + } + + @MainActor + private func editMessage(message: Message) async -> Bool { + let result = await messagesService.editMessage(for: course.id, message: message) + + switch result { + case .notStarted, .loading: + return false + case .success: + await delegate.loadMessages() + return true + case .failure(let error): + delegate.presentError(UserFacingError(title: error.localizedDescription)) + return false + } + } + + @MainActor + private func editAnswerMessage(answerMessage: AnswerMessage) async -> Bool { + let result = await messagesService.editAnswerMessage(for: course.id, answerMessage: answerMessage) + + switch result { + case .notStarted, .loading: + return false + case .success: + await delegate.loadMessages() + return true + case .failure(let error): + delegate.presentError(UserFacingError(title: error.localizedDescription)) + return false + } + } + + // MARK: Search and Replace + + func searchChannel() -> Substring? { + let matches = text.matches(of: #/#(?[\w-]*)/#) + return matches.last?.candidate + } + + func replace(channel: ChannelIdAndNameDTO) { + guard let candidate = searchChannel() else { + return + } + + // Replaces all occurrences. Otherwise, we need to get the match. + let range = Range?.none + + text = text.replacingOccurrences( + of: "#" + candidate, + with: "[channel]\(channel.name)(\(channel.id))[/channel]", + range: range) + } + + func searchMember() -> Substring? { + let matches = text.matches(of: #/@(?[\w]*)/#) + return matches.last?.candidate + } + + func replace(member: UserNameAndLoginDTO) { + guard let candidate = searchMember(), + let name = member.name, let login = member.login else { + return + } + + // Replaces all occurrences. Otherwise, we need to get the match. + let range = Range?.none + + text = text.replacingOccurrences( + of: "@" + candidate, + with: "[user]\(name)(\(login))[/user]", + range: range) + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift new file mode 100644 index 00000000..f95a1a34 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModelDelegate.swift @@ -0,0 +1,24 @@ +// +// SendMessageViewModelDelegate.swift +// +// +// Created by Nityananda Zbil on 28.02.24. +// + +import Common +import SwiftUI + +@MainActor +struct SendMessageViewModelDelegate { + let loadMessages: () async -> Void + let presentError: (UserFacingError) -> Void + let scrollToId: (String) -> Void +} + +extension SendMessageViewModelDelegate { + init(_ conversationViewModel: ConversationViewModel) { + self.loadMessages = conversationViewModel.loadMessages + self.presentError = conversationViewModel.presentError(userFacingError:) + self.scrollToId = { conversationViewModel.shouldScrollToId = $0 } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 712d597d..456cb1ac 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -12,9 +12,6 @@ import Navigation import SharedModels import SwiftUI -// swiftlint:disable:next identifier_name -private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 - public struct ConversationView: View { @EnvironmentObject var navigationController: NavigationController @@ -91,8 +88,17 @@ public struct ConversationView: View { } } } - if isAllowedToPost { - SendMessageView(viewModel: viewModel, sendMessageType: .message) + if isAllowedToPost, + let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .message, + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) } } .toolbar { @@ -154,16 +160,10 @@ private struct ConversationDaySection: View { day: day, message: message, conversationPath: conversationPath, - showHeader: (index == 0 ? true : showHeader(message: message, previousMessage: messages[index - 1]))) + isHeaderVisible: index == 0 || !message.isContinuation(of: messages[index - 1])) } } } - - // header is not shown if same person messages multiple times within 5 minutes - private func showHeader(message: Message, previousMessage: Message) -> Bool { - !(message.author == previousMessage.author && - message.creationDate ?? .now < (previousMessage.creationDate ?? .yesterday).addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60))) - } } private struct MessageCellWrapper: View { @@ -172,7 +172,7 @@ private struct MessageCellWrapper: View { let day: Date let message: Message let conversationPath: ConversationPath - let showHeader: Bool + let isHeaderVisible: Bool private var messageBinding: Binding> { Binding(get: { @@ -194,7 +194,7 @@ private struct MessageCellWrapper: View { viewModel: viewModel, message: messageBinding, conversationPath: conversationPath, - showHeader: showHeader) + isHeaderVisible: isHeaderVisible) } } @@ -236,3 +236,28 @@ private struct PullToRefresh: View { .padding(.top, -50) } } + +#Preview { + ConversationDaySection( + viewModel: { + let viewModel = ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation) + viewModel.dailyMessages = .done(response: [ + MessagesServiceStub.now: [ + MessagesServiceStub.message, + MessagesServiceStub.continuation, + MessagesServiceStub.reply + ] + ]) + return viewModel + }(), + day: MessagesServiceStub.now, + messages: [ + MessagesServiceStub.message, + MessagesServiceStub.continuation, + MessagesServiceStub.reply + ], + conversationPath: ConversationPath(id: 1, coursePath: CoursePath(course: MessagesServiceStub.course)) + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift index 1cf96f55..97f50d9f 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActionSheet.swift @@ -1,17 +1,17 @@ // // SwiftUIView.swift -// +// // // Created by Sven Andabaka on 08.04.23. // -import SwiftUI -import SharedModels -import UserStore +import Common import EmojiPicker import Navigation -import Common +import SharedModels import Smile +import SwiftUI +import UserStore struct MessageActionSheet: View { @@ -27,13 +27,17 @@ struct MessageActionSheet: View { @State private var showEditSheet = false var isAbleToEditDelete: Bool { - guard let message = message.value else { return false } + guard let message = message.value else { + return false + } if message.isCurrentUserAuthor { return true } - guard let channel = viewModel.conversation.value?.baseConversation as? Channel else { return false } + guard let channel = viewModel.conversation.value?.baseConversation as? Channel else { + return false + } if channel.hasChannelModerationRights ?? false && message is Message { return true } @@ -51,28 +55,33 @@ struct MessageActionSheet: View { EmojiTextButton(viewModel: viewModel, message: $message, emoji: "🚀") EmojiPickerButton(viewModel: viewModel, message: $message) } - .padding(.l) + .padding(.l) if message.value is Message, let conversationPath { Divider() - Button(action: { - if let messagePath = MessagePath(message: $message, coursePath: conversationPath.coursePath, conversationPath: conversationPath, conversationViewModel: viewModel) { + Button { + if let messagePath = MessagePath( + message: $message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { dismiss() navigationController.path.append(messagePath) } else { viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } - }, label: { + } label: { ButtonContent(title: R.string.localizable.replyInThread(), icon: "text.bubble.fill") - }) + } } Divider() - Button(action: { + Button { UIPasteboard.general.string = message.value?.content dismiss() - }, label: { + } label: { ButtonContent(title: R.string.localizable.copyText(), icon: "clipboard.fill") - }) + } editDeleteSection @@ -80,75 +89,99 @@ struct MessageActionSheet: View { } Spacer() } - .padding(.vertical, .xxl) - .loadingIndicator(isLoading: $viewModel.isLoading) - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + .padding(.vertical, .xxl) + .loadingIndicator(isLoading: $viewModel.isLoading) + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } +} +private extension MessageActionSheet { var editDeleteSection: some View { Group { if isAbleToEditDelete { Divider() - Button(action: { + Button { showEditSheet = true - }, label: { + } label: { ButtonContent(title: R.string.localizable.editMessage(), icon: "pencil") - }) - .sheet(isPresented: $showEditSheet) { - NavigationView { - Group { - if let message = message.value as? Message { - SendMessageView(viewModel: viewModel, sendMessageType: .editMessage(message, { self.dismiss() })) - } else if let answerMessage = message.value as? AnswerMessage { - SendMessageView(viewModel: viewModel, sendMessageType: .editAnswerMessage(answerMessage, { self.dismiss() })) - } else { - Text(R.string.localizable.loading()) - } - } - .navigationTitle(R.string.localizable.editMessage()) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(R.string.localizable.cancel()) { - showEditSheet = false - } - } - } - }.presentationDetents([.height(200), .medium]) - } + } + .sheet(isPresented: $showEditSheet) { + editMessage + } - Button(action: { + Button { showDeleteAlert = true - }, label: { + } label: { ButtonContent(title: R.string.localizable.deleteMessage(), icon: "trash.fill") .foregroundColor(.red) - }) - .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { - Button(R.string.localizable.confirm(), role: .destructive) { - viewModel.isLoading = true - Task(priority: .userInitiated) { - let success: Bool - let tempMessage = message.value - if message.value is AnswerMessage { - success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) - } else { - success = await viewModel.deleteMessage(messageId: message.value?.id) - } - viewModel.isLoading = false - if success { - dismiss() - // if we deleted a Message and are in the MessageDetailView we pop it - if navigationController.path.count == 3 && tempMessage is Message { - navigationController.path.removeLast() - } + } + .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { + Button(R.string.localizable.confirm(), role: .destructive) { + viewModel.isLoading = true + Task(priority: .userInitiated) { + let success: Bool + let tempMessage = message.value + if message.value is AnswerMessage { + success = await viewModel.deleteAnswerMessage(messageId: message.value?.id) + } else { + success = await viewModel.deleteMessage(messageId: message.value?.id) + } + viewModel.isLoading = false + if success { + dismiss() + // if we deleted a Message and are in the MessageDetailView we pop it + if navigationController.path.count == 3 && tempMessage is Message { + navigationController.path.removeLast() } } } - Button(R.string.localizable.cancel(), role: .cancel) { } } + Button(R.string.localizable.cancel(), role: .cancel) { } + } + } + } + } + + var editMessage: some View { + NavigationView { + Group { + if let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + if let message = message.value as? Message { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .editMessage(message, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) + } else if let answerMessage = message.value as? AnswerMessage { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .editAnswerMessage(answerMessage, { self.dismiss() }), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) + } else { + Text(R.string.localizable.loading()) + } + } + } + .navigationTitle(R.string.localizable.editMessage()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(R.string.localizable.cancel()) { + showEditSheet = false + } + } } } + .presentationDetents([.height(200), .medium]) } } @@ -166,8 +199,8 @@ private struct ButtonContent: View { Text(title) .font(.headline) } - .padding(.horizontal, .l) - .foregroundColor(.Artemis.primaryLabel) + .padding(.horizontal, .l) + .foregroundColor(.Artemis.primaryLabel) } } @@ -233,7 +266,9 @@ private struct EmojiPickerButton: View { @State var selectedEmoji: Emoji? var body: some View { - Button(action: { showEmojiPicker = true }, label: { + Button { + showEmojiPicker = true + } label: { Image("face-smile", bundle: .module) .renderingMode(.template) .resizable() @@ -242,43 +277,43 @@ private struct EmojiPickerButton: View { .frame(width: .smallImage, height: .smallImage) .padding(20) .background(Capsule().fill(Color.Artemis.reactionCapsuleColor)) - }) - .sheet(isPresented: $showEmojiPicker) { - NavigationView { - EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) - .navigationTitle(R.string.localizable.emojis()) - .navigationBarTitleDisplayMode(.inline) - } + } + .sheet(isPresented: $showEmojiPicker) { + NavigationView { + EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) + .navigationTitle(R.string.localizable.emojis()) + .navigationBarTitleDisplayMode(.inline) } - .onChange(of: selectedEmoji) { _, newEmoji in - if let newEmoji, - let emojiId = Smile.alias(emoji: newEmoji.value) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } + } + .onChange(of: selectedEmoji) { _, newEmoji in + if let newEmoji, + let emojiId = Smile.alias(emoji: newEmoji.value) { + Task { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) } - selectedEmoji = nil - dismiss() } + selectedEmoji = nil + dismiss() } } + } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index da650689..ec277021 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -5,24 +5,23 @@ // Created by Sven Andabaka on 12.04.23. // -import SwiftUI import ArtemisMarkdown -import SharedModels -import Navigation import Common -import UserStore import DesignLibrary +import Navigation +import SharedModels +import SwiftUI +import UserStore struct MessageCell: View { - @EnvironmentObject var navigationController: NavigationController @ObservedObject var viewModel: ConversationViewModel @Binding var message: DataState - @State private var showMessageActionSheet = false - @State private var isPressed = false + @State private var isActionSheetPresented = false + @State private var isDetectingLongPress = false var author: String { message.value?.author?.name ?? "" @@ -34,36 +33,37 @@ struct MessageCell: View { message.value?.content ?? "" } + var user: () -> User? = { UserSession.shared.user } + let conversationPath: ConversationPath? - let showHeader: Bool + let isHeaderVisible: Bool var body: some View { - HStack(alignment: .top, spacing: .l) { + HStack(alignment: .top, spacing: .m) { Image(systemName: "person") .resizable() .scaledToFit() - .frame(width: 30, height: 30) + .frame(width: 40, height: isHeaderVisible ? 40 : 0) .padding(.top, .s) - .opacity(showHeader ? 1 : 0) - VStack(alignment: .leading, spacing: .m) { - if showHeader { - HStack(alignment: .bottom, spacing: .m) { + VStack(alignment: .leading, spacing: .xs) { + if isHeaderVisible { + HStack(alignment: .firstTextBaseline, spacing: .m) { Text(author) .bold() if let creationDate { Text(creationDate, formatter: DateFormatter.timeOnly) .font(.caption) - if let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate, - lastReadDate < creationDate, - UserSession.shared.user?.id != message.value?.author?.id { - Chip(text: R.string.localizable.new(), - backgroundColor: .Artemis.artemisBlue, - padding: .s) - .font(.footnote) - } + Chip( + text: R.string.localizable.new(), + backgroundColor: .Artemis.artemisBlue, + padding: .s + ) + .font(.footnote) + .opacity(isChipVisible(creationDate: creationDate) ? 1 : 0) } } } + ArtemisMarkdownView(string: content) if message.value?.updatedDate != nil { @@ -72,51 +72,93 @@ struct MessageCell: View { .font(.footnote) } - ReactionsView(viewModel: viewModel, message: $message, showEmojiAddButton: false) + ReactionsView(viewModel: viewModel, message: $message) + if let message = message.value as? Message, - let answerCount = message.answers?.count, - let conversationPath, - answerCount > 0 { - Button(R.string.localizable.replyAction(answerCount)) { - if let messagePath = MessagePath(message: self.$message, coursePath: conversationPath.coursePath, conversationPath: conversationPath, conversationViewModel: viewModel) { + let answerCount = message.answers?.count, answerCount > 0, + let conversationPath { + Button("^[\(answerCount) \(R.string.localizable.reply())](inflect: true)") { + if let messagePath = MessagePath( + message: self.$message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { navigationController.path.append(messagePath) } else { viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } } } - }.id(message.value?.id.description) - Spacer() - } - .padding(.horizontal, .l) - .contentShape(Rectangle()) - .background(isPressed ? Color.Artemis.messsageCellPressed : Color.clear) - .onTapGesture { - if let conversationPath, - let messagePath = MessagePath(message: $message, - coursePath: conversationPath.coursePath, - conversationPath: conversationPath, - conversationViewModel: viewModel) { - navigationController.path.append(messagePath) - } } - .onLongPressGesture(minimumDuration: 0.1, maximumDistance: 30, perform: { - guard let conversation = viewModel.conversation.value else { return } - if let channel = conversation.baseConversation as? Channel, - channel.isArchived ?? false { - return - } - - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - showMessageActionSheet = true - isPressed = false - }, onPressingChanged: { pressed in - isPressed = pressed - }) - .sheet(isPresented: $showMessageActionSheet) { - MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: conversationPath) - .presentationDetents([.height(350), .large]) + .background { + RoundedRectangle(cornerRadius: .m) + .foregroundStyle( + (isDetectingLongPress || isActionSheetPresented) ? Color.Artemis.messsageCellPressed : Color.clear) } + .id(message.value?.id.description) + } + .padding(.horizontal, .l) + .contentShape(.rect) + .onTapGesture(perform: onTapPresentMessage) + .onLongPressGesture(perform: onLongPressPresentActionSheet) { changed in + isDetectingLongPress = changed + } + .sheet(isPresented: $isActionSheetPresented) { + MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: conversationPath) + .presentationDetents([.height(350), .large]) + } } } + +private extension MessageCell { + func isChipVisible(creationDate: Date) -> Bool { + guard let lastReadDate = conversationPath?.conversation?.baseConversation.lastReadDate else { + return false + } + + return lastReadDate < creationDate && user()?.id != message.value?.author?.id + } + + // MARK: Gestures + + func onTapPresentMessage() { + // Tap is disabled, if conversation path is nil, e.g., in the message detail view. + if let conversationPath, let messagePath = MessagePath( + message: $message, + coursePath: conversationPath.coursePath, + conversationPath: conversationPath, + conversationViewModel: viewModel + ) { + navigationController.path.append(messagePath) + } + } + + func onLongPressPresentActionSheet() { + guard let conversation = viewModel.conversation.value else { + return + } + if let channel = conversation.baseConversation as? Channel, channel.isArchived ?? false { + return + } + + let impactMed = UIImpactFeedbackGenerator(style: .heavy) + impactMed.impactOccurred() + isActionSheetPresented = true + isDetectingLongPress = false + } +} + +#Preview { + MessageCell( + viewModel: ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message)), + conversationPath: ConversationPath( + conversation: MessagesServiceStub.conversation, + coursePath: CoursePath(course: MessagesServiceStub.course) + ), + isHeaderVisible: true + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 90f93f08..56b91a74 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -5,140 +5,119 @@ // Created by Sven Andabaka on 08.04.23. // -import SwiftUI -import SharedModels import ArtemisMarkdown -import Navigation -import DesignLibrary import Common +import DesignLibrary +import Navigation +import SharedModels +import SwiftUI -// swiftlint:disable:next identifier_name -private let MAX_MINUTES_FOR_GROUPING_MESSAGES = 5 - -public struct MessageDetailView: View { +struct MessageDetailView: View { @ObservedObject var viewModel: ConversationViewModel @Binding private var message: DataState - @State private var showMessageActionSheet = false + @State private var isMessageActionSheetPresented = false @State private var viewRerenderWorkaround = false private let messageId: Int64? @State private var internalMessage: BaseMessage? - public init(viewModel: ConversationViewModel, - message: Binding>) { + init(viewModel: ConversationViewModel, message: Binding>) { self.viewModel = viewModel self.messageId = message.wrappedValue.value?.id self._message = message } - public init(viewModel: ConversationViewModel, - messageId: Int64) { - self.viewModel = viewModel - self.messageId = messageId - self._message = Binding(get: { .loading }, set: { _ in return }) - self.internalMessage = nil - - self._message = Binding(get: { [self] in - if let internalMessage = self.internalMessage { - return .done(response: internalMessage) - } - return .loading - }, set: { [self] in - if let message = $0.value as? Message { - self.internalMessage = message - } - }) - } - - public var body: some View { - DataStateView(data: $message, retryHandler: { await reloadMessage() }) { message in + var body: some View { + DataStateView(data: $message) { + await reloadMessage() + } content: { message in VStack(alignment: .leading) { - ScrollViewReader { value in + ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading) { - HStack(alignment: .top, spacing: .l) { - Image(systemName: "person") - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - .padding(.top, .s) - VStack(alignment: .leading, spacing: .m) { - Text(message.author?.name ?? "") - .bold() - if let creationDate = message.creationDate { - Text(creationDate, formatter: DateFormatter.timeOnly) - .font(.caption) - } - } - Spacer() - } - - ArtemisMarkdownView(string: message.content ?? "") - - if let updatedDate = message.updatedDate { - Text("\(R.string.localizable.edited()) (\(updatedDate.shortDateAndTime))") - .foregroundColor(.Artemis.secondaryLabel) - .font(.footnote) - } - - ReactionsView(viewModel: viewModel, message: $message) - } - .padding(.horizontal, .l) - .contentShape(Rectangle()) - .onLongPressGesture(maximumDistance: 30) { - let impactMed = UIImpactFeedbackGenerator(style: .heavy) - impactMed.impactOccurred() - showMessageActionSheet = true - } - .sheet(isPresented: $showMessageActionSheet) { - MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: nil) - .presentationDetents([.height(350), .large]) - } - if let message = message as? Message { - Divider() - VStack { - let sortedArray = (message.answers ?? []).sorted(by: { $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday }) - ForEach(Array(sortedArray.enumerated()), id: \.1) { index, answerMessage in - MessageCellWrapper(viewModel: viewModel, - answerMessage: answerMessage, - showHeader: (index == 0 ? true : shouldShowHeader(message: answerMessage, previousMessage: sortedArray[index - 1]))) - } - Spacer() - .id("bottom") - .onAppear { - value.scrollTo("bottom", anchor: .bottom) - } - .onChange(of: message.answers) { - withAnimation { - if let id = viewModel.shouldScrollToId { - value.scrollTo(id, anchor: .bottom) - } - } - } - }.padding(.horizontal, .l) - } + top(message: message) + answers(of: message, proxy: proxy) } } Spacer() if !((viewModel.conversation.value?.baseConversation as? Channel)?.isArchived ?? false), - let message = message as? Message { - SendMessageView(viewModel: viewModel, sendMessageType: .answerMessage(message, { await reloadMessage() })) + let message = message as? Message, + let course = viewModel.course.value, + let conversation = viewModel.conversation.value { + SendMessageView( + viewModel: SendMessageViewModel( + course: course, + conversation: conversation, + configuration: .answerMessage(message, reloadMessage), + delegate: SendMessageViewModelDelegate(viewModel) + ) + ) } } } - .navigationTitle(R.string.localizable.thread()) - .task { - if message.value == nil { - await reloadMessage() + .navigationTitle(R.string.localizable.thread()) + .task { + if message.value == nil { + await reloadMessage() + } + } + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } +} + +private extension MessageDetailView { + func top(message: BaseMessage) -> some View { + MessageCell( + viewModel: viewModel, + message: Binding>.constant(DataState.done(response: message)), + conversationPath: nil, + isHeaderVisible: true + ) + .environment(\.isEmojiPickerButtonVisible, true) + .onLongPressGesture(maximumDistance: 30) { + let impactMed = UIImpactFeedbackGenerator(style: .heavy) + impactMed.impactOccurred() + isMessageActionSheetPresented = true + } + .sheet(isPresented: $isMessageActionSheetPresented) { + MessageActionSheet(viewModel: viewModel, message: $message, conversationPath: nil) + .presentationDetents([.height(350), .large]) + } + } + + @ViewBuilder + func answers(of message: BaseMessage, proxy: ScrollViewProxy) -> some View { + if let message = message as? Message { + Divider() + VStack { + let sortedArray = (message.answers ?? []).sorted { + $0.creationDate ?? .tomorrow < $1.creationDate ?? .yesterday } + ForEach(Array(sortedArray.enumerated()), id: \.1) { index, answerMessage in + MessageCellWrapper( + viewModel: viewModel, + answerMessage: answerMessage, + isHeaderVisible: index == 0 || !answerMessage.isContinuation(of: sortedArray[index - 1])) + } + Spacer() + .id("bottom") + .onAppear { + proxy.scrollTo("bottom", anchor: .bottom) + } + .onChange(of: message.answers) { + withAnimation { + if let id = viewModel.shouldScrollToId { + proxy.scrollTo(id, anchor: .bottom) + } + } + } } - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + } } - private func reloadMessage() async { + func reloadMessage() async { viewModel.shouldScrollToId = "bottom" guard let messageId else { return } let result = await viewModel.loadMessage(messageId: messageId) @@ -152,12 +131,6 @@ public struct MessageDetailView: View { viewRerenderWorkaround.toggle() } } - - // header is not shown if same person messages multiple times within 5 minutes - private func shouldShowHeader(message: AnswerMessage, previousMessage: AnswerMessage) -> Bool { - !(message.author == previousMessage.author && - message.creationDate ?? .now < (previousMessage.creationDate ?? .yesterday).addingTimeInterval(TimeInterval(MAX_MINUTES_FOR_GROUPING_MESSAGES * 60))) - } } private struct MessageCellWrapper: View { @@ -165,31 +138,46 @@ private struct MessageCellWrapper: View { @ObservedObject var viewModel: ConversationViewModel let answerMessage: AnswerMessage - let showHeader: Bool + let isHeaderVisible: Bool private var answerMessageBinding: Binding> { - Binding(get: { - if let keys = viewModel.dailyMessages.value?.keys { - let answerMessage: AnswerMessage? = keys.compactMap { key in - if let messageIndex = viewModel.dailyMessages.value?[key]?.firstIndex(where: { $0.answers?.contains(where: { $0.id == self.answerMessage.id }) ?? false }), - let answerMessage = viewModel.dailyMessages.value?[key]?[messageIndex].answers?.first(where: { $0.id == self.answerMessage.id }) { + + let isAnswerMessage = { (answer: AnswerMessage) -> Bool in + answer.id == self.answerMessage.id + } + let messageContainsAnswer = { (message: Message) -> Bool in + message.answers?.contains(where: isAnswerMessage) ?? false + } + + return Binding(get: { + if let dailyMessages = viewModel.dailyMessages.value { + let answerMessages: [AnswerMessage] = dailyMessages.keys.compactMap { key in + + if let messages = dailyMessages[key], + let messageIndex = messages.firstIndex(where: messageContainsAnswer), + let answerMessage = messages[messageIndex].answers?.first(where: isAnswerMessage) { return answerMessage } return nil - }.first - if let answerMessage { + } + + if let answerMessage = answerMessages.first { return .done(response: answerMessage) } } return .loading }, set: { newValue in - if let keys = viewModel.dailyMessages.value?.keys { - keys.forEach { key in - if let messageIndex = viewModel.dailyMessages.value?[key]?.firstIndex(where: { $0.answers?.contains(where: { $0.id == answerMessage.id }) ?? false }), - let answerMessageIndex = viewModel.dailyMessages.value?[key]?[messageIndex].answers?.firstIndex(where: { $0.id == answerMessage.id }), - let newAnswerMessage = newValue.value as? AnswerMessage { + if let newAnswerMessage = newValue.value as? AnswerMessage, + let dailyMessages = viewModel.dailyMessages.value { + + for key in dailyMessages.keys { + + if let messages = dailyMessages[key], + let messageIndex = messages.firstIndex(where: messageContainsAnswer), + let answerMessageIndex = messages[messageIndex].answers?.firstIndex(where: isAnswerMessage) { + viewModel.dailyMessages.value?[key]?[messageIndex].answers?[answerMessageIndex] = newAnswerMessage - return + continue } } } @@ -197,9 +185,26 @@ private struct MessageCellWrapper: View { } var body: some View { - MessageCell(viewModel: viewModel, - message: answerMessageBinding, - conversationPath: nil, - showHeader: showHeader) + MessageCell( + viewModel: viewModel, + message: answerMessageBinding, + conversationPath: nil, + isHeaderVisible: isHeaderVisible) } } + +#Preview { + MessageDetailView( + viewModel: { + let viewModel = ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation) + viewModel.dailyMessages = .done(response: [ + MessagesServiceStub.now: [ + MessagesServiceStub.message + ] + ]) + return viewModel + }(), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message))) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift index cb43db0d..916cb0a3 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ReactionsView.swift @@ -5,21 +5,22 @@ // Created by Sven Andabaka on 08.04.23. // -import SwiftUI +import Common +import EmojiPicker import SharedModels import Smile +import SwiftUI import UserStore -import EmojiPicker -import Common struct ReactionsView: View { + @Environment(\.isEmojiPickerButtonVisible) var isEmojiPickerButtonVisible: Bool @ObservedObject private var viewModel: ConversationViewModel @Binding var message: DataState - let showEmojiAddButton: Bool @State private var viewRerenderWorkaround = false + let columns = [ GridItem(.adaptive(minimum: 45)) ] var mappedReaction: [String: [Reaction]] { @@ -38,18 +39,20 @@ struct ReactionsView: View { return reactions } - init(viewModel: ConversationViewModel, message: Binding>, showEmojiAddButton: Bool = true) { + init( + viewModel: ConversationViewModel, + message: Binding> + ) { self.viewModel = viewModel self._message = message - self.showEmojiAddButton = showEmojiAddButton } var body: some View { - LazyVGrid(columns: columns) { + LazyVGrid(columns: columns, alignment: .leading) { ForEach(mappedReaction.sorted(by: { $0.key < $1.key }), id: \.key) { map in EmojiTextButton(viewModel: viewModel, pair: (map.key, map.value), message: $message) } - if !mappedReaction.isEmpty || showEmojiAddButton { + if !mappedReaction.isEmpty || isEmojiPickerButtonVisible { EmojiPickerButton(viewModel: viewModel, message: $message, viewRerenderWorkaround: $viewRerenderWorkaround) } } @@ -64,57 +67,66 @@ private struct EmojiTextButton: View { @Binding var message: DataState var body: some View { - Text("\(pair.0) \(pair.1.count)") - .font(.caption) - .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) - .frame(height: .extraSmallImage) - .padding(.m) - .background( - Group { - if isMyReaction { - Capsule() - .strokeBorder(Color.Artemis.artemisBlue, lineWidth: 1) - .background(Capsule().foregroundColor(Color.Artemis.artemisBlue.opacity(0.25))) - } else { - Capsule().fill(Color.Artemis.reactionCapsuleColor) - } + Button { + if let emojiId = Smile.alias(emoji: pair.0) { + Task { + await addReaction(emojiId: emojiId) } - ) - .onTapGesture { - if let emojiId = Smile.alias(emoji: pair.0) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } + } + } label: { + Text("\(pair.0) \(pair.1.count)") + .font(.caption) + .foregroundColor(isMyReaction ? Color.Artemis.artemisBlue : Color.Artemis.primaryLabel) + .frame(height: .extraSmallImage) + .padding(.m) + .background( + Group { + if isMyReaction { + Capsule() + .strokeBorder(Color.Artemis.artemisBlue, lineWidth: 1) + .background(Capsule().foregroundColor(Color.Artemis.artemisBlue.opacity(0.25))) + } else { + Capsule() + .fill(Color.Artemis.reactionCapsuleColor) } } - } + ) + } + } +} + +private extension EmojiTextButton { + func addReaction(emojiId: String) async { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } } - private var isMyReaction: Bool { - if let emojiId = Smile.alias(emoji: pair.0), - let message = message.value { - return message.containsReactionFromMe(emojiId: emojiId) + var isMyReaction: Bool { + guard let emojiId = Smile.alias(emoji: pair.0), + let message = message.value else { + return false } - return false + + return message.containsReactionFromMe(emojiId: emojiId) } } @@ -122,14 +134,16 @@ private struct EmojiPickerButton: View { @ObservedObject var viewModel: ConversationViewModel - @State private var showEmojiPicker = false + @State private var isEmojiPickerPresented = false @State var selectedEmoji: Emoji? @Binding var message: DataState @Binding var viewRerenderWorkaround: Bool var body: some View { - Button(action: { showEmojiPicker = true }, label: { + Button { + isEmojiPickerPresented = true + } label: { Image("face-smile", bundle: .module) .renderingMode(.template) .resizable() @@ -138,43 +152,75 @@ private struct EmojiPickerButton: View { .frame(height: .extraSmallImage) .padding(.m) .background(Capsule().fill(Color.Artemis.reactionCapsuleColor)) - }) - .sheet(isPresented: $showEmojiPicker) { - NavigationView { - EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) - .navigationTitle(R.string.localizable.emojis()) - .navigationBarTitleDisplayMode(.inline) - } + } + .sheet(isPresented: $isEmojiPickerPresented) { + NavigationView { + EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: Color.Artemis.artemisBlue) + .navigationTitle(R.string.localizable.emojis()) + .navigationBarTitleDisplayMode(.inline) } - .onChange(of: selectedEmoji) { _, newEmoji in - if let newEmoji, - let emojiId = Smile.alias(emoji: newEmoji.value) { - Task { - if let message = message.value as? Message { - let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } else if let answerMessage = message.value as? AnswerMessage { - let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) - switch result { - case .loading: - self.message = .loading - case .failure(let error): - self.message = .failure(error: error) - case .done(let response): - self.message = .done(response: response) - } - } - viewRerenderWorkaround.toggle() - selectedEmoji = nil - } + } + .onChange(of: selectedEmoji) { _, newEmoji in + if let newEmoji, + let emojiId = Smile.alias(emoji: newEmoji.value) { + Task { + await addReaction(emojiId: emojiId) + viewRerenderWorkaround.toggle() + selectedEmoji = nil } } + } + } +} + +private extension EmojiPickerButton { + func addReaction(emojiId: String) async { + if let message = message.value as? Message { + let result = await viewModel.addReactionToMessage(for: message, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } else if let answerMessage = message.value as? AnswerMessage { + let result = await viewModel.addReactionToAnswerMessage(for: answerMessage, emojiId: emojiId) + switch result { + case .loading: + self.message = .loading + case .failure(let error): + self.message = .failure(error: error) + case .done(let response): + self.message = .done(response: response) + } + } } } + +// MARK: - Environment+IsEmojiPickerVisible + +private enum IsEmojiPickerVisibleEnvironmentKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var isEmojiPickerButtonVisible: Bool { + get { + self[IsEmojiPickerVisibleEnvironmentKey.self] + } + set { + self[IsEmojiPickerVisibleEnvironmentKey.self] = newValue + } + } +} + +#Preview { + ReactionsView( + viewModel: ConversationViewModel( + course: MessagesServiceStub.course, + conversation: MessagesServiceStub.conversation), + message: Binding.constant(DataState.done(response: MessagesServiceStub.message)) + ) +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift index 88326427..7af7e522 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/CodeOfConductView.swift @@ -5,8 +5,8 @@ // Created by Nityananda Zbil on 15.10.23. // +import ArtemisMarkdown import DesignLibrary -import MarkdownUI import SharedModels import SwiftUI @@ -23,7 +23,7 @@ struct CodeOfConductView: View { await viewModel.getCodeOfConductInformation() } content: { _ in VStack(alignment: .leading) { - Markdown(codeOfConductSanitized() + "\n" + responsibleUserMarkdown()) + ArtemisMarkdownView(string: codeOfConductSanitized() + "\n" + responsibleUserMarkdown()) // Take all available horizontal space HStack { Spacer() diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageView.swift deleted file mode 100644 index 595fcff8..00000000 --- a/ArtemisKit/Sources/Messages/Views/SendMessageView.swift +++ /dev/null @@ -1,289 +0,0 @@ -// -// SendMessageView.swift -// -// -// Created by Sven Andabaka on 08.04.23. -// - -import SwiftUI -import DesignLibrary -import Common -import SharedModels - -enum SendMessageType { - case message, answerMessage(Message, () async -> Void), editMessage(Message, () -> Void), editAnswerMessage(AnswerMessage, () -> Void) -} - -struct SendMessageView: View { - - @ObservedObject var viewModel: ConversationViewModel - - @State private var responseText = "" - @State private var showExercisePicker = false - @State private var showLecturePicker = false - - @FocusState private var isFocused: Bool - - let sendMessageType: SendMessageType - - var isEditMode: Bool { - switch sendMessageType { - case .message: - return false - case .answerMessage: - return false - case .editMessage: - return true - case .editAnswerMessage: - return true - } - } - - var body: some View { - VStack { - if isFocused && !isEditMode { - Capsule() - .fill(Color.secondary) - .frame(width: 50, height: 3) - .padding(.top, .m) - } - HStack(alignment: .bottom) { - textField - .lineLimit(10) - .focused($isFocused) - .toolbar { - ToolbarItem(placement: .keyboard) { - keyboardToolbarContent - } - } - if !isFocused { - sendButton - } - } - .padding(.horizontal, .l) - .padding(.bottom, .l) - .padding(.top, isFocused ? .m : .l) - } - .onAppear { - if case .editMessage(let message, _) = sendMessageType { - responseText = message.content ?? "" - } - if case .editAnswerMessage(let answerMessage, _) = sendMessageType { - responseText = answerMessage.content ?? "" - } - } - .overlay( - Group { - if isEditMode { - EmptyView() - } else { - RoundedRectangle(cornerRadius: 20) - .trim(from: isFocused ? 0.52 : 0.51, to: isFocused ? 0.98 : 0.99) - .stroke(Color.Artemis.artemisBlue, lineWidth: 2) - } - } - ) - .gesture( - DragGesture(minimumDistance: 30, coordinateSpace: .local) - .onEnded({ value in - if value.translation.height > 0 { - // down - isFocused = false - let impactMed = UIImpactFeedbackGenerator(style: .medium) - impactMed.impactOccurred() - } - }) - ) - } - - var textField: some View { - Group { - if isEditMode { - TextField(R.string.localizable.messageAction(viewModel.conversation.value?.baseConversation.conversationName ?? ""), - text: $responseText, axis: .vertical) - .textFieldStyle(ArtemisTextField()) - } else { - TextField(R.string.localizable.messageAction(viewModel.conversation.value?.baseConversation.conversationName ?? ""), - text: $responseText, axis: .vertical) - } - } - } - - var keyboardToolbarContent: some View { - HStack { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Button(action: { - responseText.append("****") - }, label: { - Image(systemName: "bold") - }) - Button(action: { - responseText.append("**") - }, label: { - Image(systemName: "italic") - }) - Button(action: { - responseText.append("") - }, label: { - Image(systemName: "underline") - }) - Button(action: { - responseText.append("> Reference") - }, label: { - Image(systemName: "quote.opening") - }) - Button(action: { - responseText.append("``") - }, label: { - Image(systemName: "curlybraces") - }) - Button(action: { - responseText.append("```java\nSource Code\n```") - }, label: { - Image(systemName: "curlybraces.square.fill") - }) - Button(action: { - responseText.append("[](http://)") - }, label: { - Image(systemName: "link") - }) - Button(action: { - isFocused = false - showExercisePicker = true - }, label: { - Text(R.string.localizable.exercise()) - }) - .sheet(isPresented: $showExercisePicker, onDismiss: { isFocused = true }) { - if let course = viewModel.course.value { - SendMessageExercisePicker(text: $responseText, course: course) - } else { - Text(R.string.localizable.loading()) - } - } - Button(action: { - isFocused = false - showLecturePicker = true - }, label: { - Text(R.string.localizable.lecture()) - }) - .sheet(isPresented: $showLecturePicker, onDismiss: { isFocused = true }) { - if let course = viewModel.course.value { - SendMessageLecturePicker(text: $responseText, course: course) - } else { - Text(R.string.localizable.loading()) - } - } - } - } - Spacer() - sendButton - } - } - - var sendButton: some View { - Button(action: { - viewModel.isLoading = true - Task { - var result: NetworkResponse? - switch sendMessageType { - case .message: - result = await viewModel.sendMessage(text: responseText) - case let .answerMessage(message, completion): - result = await viewModel.sendAnswerMessage(text: responseText, for: message, completion: completion) - case let .editMessage(message, completion): - var newmessage = message - newmessage.content = responseText - let success = await viewModel.editMessage(message: newmessage) - viewModel.isLoading = false - if success { - completion() - } - case let .editAnswerMessage(message, completion): - var newmessage = message - newmessage.content = responseText - let success = await viewModel.editAnswerMessage(answerMessage: newmessage) - viewModel.isLoading = false - if success { - completion() - } - } - switch result { - case .success: - responseText = "" - default: - return - } - } - }, label: { - Image(systemName: "paperplane.fill") - .imageScale(.large) - }) - .padding(.leading, .l) - .disabled(responseText.isEmpty) - .loadingIndicator(isLoading: $viewModel.isLoading) - } -} - -private struct SendMessageExercisePicker: View { - - @Environment(\.dismiss) var dismiss - - @Binding var text: String - - let course: Course - - var body: some View { - List(course.exercises ?? []) { exercise in - if let title = exercise.baseExercise.title { - Button(title) { - appendMarkdown(for: exercise) - dismiss() - } - } - } - } - - func appendMarkdown(for exercise: Exercise) { - let type: String? - switch exercise { - case .fileUpload: - type = "file-upload" - case .modeling: - type = "modeling" - case .programming: - type = "programming" - case .quiz: - type = "quiz" - case .text: - type = "text" - case .unknown: - type = nil - } - - guard let type, - let title = exercise.baseExercise.title else { return } - - text.append("[\(type)]\(title)(/courses/\(course.id)/exercises/\(exercise.id))[/\(type)]") - } -} - -private struct SendMessageLecturePicker: View { - - @Environment(\.dismiss) var dismiss - - @Binding var text: String - - let course: Course - - var body: some View { - List(course.lectures ?? [], id: \.id) { lecture in - if let title = lecture.title { - Button(title) { - text.append("[lecture]\(title)(/courses/\(course.id)/lectures/\(lecture.id))[/lecture]") - dismiss() - } - } - } - } -} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift new file mode 100644 index 00000000..4cccd8c1 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageExercisePicker.swift @@ -0,0 +1,59 @@ +// +// SendMessageExercisePicker.swift +// +// +// Created by Nityananda Zbil on 29.10.23. +// + +import SharedModels +import SwiftUI + +struct SendMessageExercisePicker: View { + + @Environment(\.dismiss) var dismiss + + @Binding var text: String + + 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() + } + } + } + } else { + ContentUnavailableView(R.string.localizable.exercisesUnavailable(), systemImage: "magnifyingglass") + } + } +} + +private extension SendMessageExercisePicker { + func appendMarkdown(for exercise: Exercise) { + let type: String? + switch exercise { + case .fileUpload: + type = "file-upload" + case .modeling: + type = "modeling" + case .programming: + type = "programming" + case .quiz: + type = "quiz" + case .text: + type = "text" + case .unknown: + type = nil + } + + guard let type, let title = exercise.baseExercise.title else { + return + } + + text.append("[\(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 new file mode 100644 index 00000000..237b3861 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageLecturePicker.swift @@ -0,0 +1,33 @@ +// +// SendMessageLecturePicker.swift +// +// +// Created by Nityananda Zbil on 29.10.23. +// + +import SharedModels +import SwiftUI + +struct SendMessageLecturePicker: View { + + @Environment(\.dismiss) var dismiss + + @Binding var text: String + + let course: Course + + 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() + } + } + } + } else { + ContentUnavailableView(R.string.localizable.lecturesUnavailable(), systemImage: "magnifyingglass") + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift new file mode 100644 index 00000000..147b1b0c --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionChannelView.swift @@ -0,0 +1,58 @@ +// +// SendMessageMentionChannelView.swift +// +// +// Created by Nityananda Zbil on 02.12.23. +// + +import DesignLibrary +import SwiftUI + +struct SendMessageMentionChannelView: View { + + @State var viewModel: SendMessageMentionChannelViewModel + + @Bindable var sendMessageViewModel: SendMessageViewModel + + var body: some View { + HStack { + Spacer() + DataStateView(data: $viewModel.channels) { + if let candidate = sendMessageViewModel.searchChannel().map(String.init) { + await viewModel.search(idOrName: candidate) + } + } content: { channels in + if !channels.isEmpty { + List { + ForEach(channels) { channel in + Button(channel.name) { + sendMessageViewModel.replace(channel: channel) + } + } + } + } else { + ContentUnavailableView(R.string.localizable.channelsUnavailable(), systemImage: "magnifyingglass") + } + } + .onChange(of: sendMessageViewModel.text, initial: true, search) + Spacer() + } + .listStyle(.plain) + .clipShape(.rect(cornerRadius: .l)) + .overlay { + RoundedRectangle(cornerRadius: .l) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + .padding(.bottom, .m) + } +} + +private extension SendMessageMentionChannelView { + func search() { + if let candidate = sendMessageViewModel.searchChannel().map(String.init) { + Task { + await viewModel.search(idOrName: candidate) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift new file mode 100644 index 00000000..5df78635 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionMemberView.swift @@ -0,0 +1,58 @@ +// +// SendMessageMentionMemberView.swift +// +// +// Created by Nityananda Zbil on 28.10.23. +// + +import DesignLibrary +import SwiftUI + +struct SendMessageMentionMemberView: View { + + @State var viewModel: SendMessageMentionMemberViewModel + + @Bindable var sendMessageViewModel: SendMessageViewModel + + var body: some View { + HStack { + Spacer() + DataStateView(data: $viewModel.members) { + if let candidate = sendMessageViewModel.searchMember().map(String.init) { + await viewModel.search(loginOrName: candidate) + } + } content: { members in + if !members.isEmpty { + List { + ForEach(members, id: \.login) { member in + Button(member.name ?? "") { + sendMessageViewModel.replace(member: member) + } + } + } + } else { + ContentUnavailableView(R.string.localizable.membersUnavailable(), systemImage: "magnifyingglass") + } + } + .onChange(of: sendMessageViewModel.text, initial: true, search) + Spacer() + } + .listStyle(.plain) + .clipShape(.rect(cornerRadius: .l)) + .overlay { + RoundedRectangle(cornerRadius: .l) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + .padding(.bottom, .m) + } +} + +private extension SendMessageMentionMemberView { + func search() { + if let candidate = sendMessageViewModel.searchMember().map(String.init) { + Task { + await viewModel.search(loginOrName: candidate) + } + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift new file mode 100644 index 00000000..dffcfed5 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -0,0 +1,173 @@ +// +// SendMessageView.swift +// +// +// Created by Sven Andabaka on 08.04.23. +// + +import Common +import DesignLibrary +import SharedModels +import SwiftUI + +struct SendMessageView: View { + + @State var viewModel: SendMessageViewModel + + @FocusState private var isFocused: Bool + + var body: some View { + VStack { + mentions + VStack { + if isFocused && !viewModel.isEditing { + Capsule() + .fill(Color.secondary) + .frame(width: 50, height: 3) + .padding(.top, .m) + } + HStack(alignment: .bottom) { + textField + .lineLimit(10) + .focused($isFocused) + .toolbar { + ToolbarItem(placement: .keyboard) { + keyboardToolbarContent + } + } + if !isFocused { + sendButton + } + } + .padding(.horizontal, .l) + .padding(.bottom, .l) + .padding(.top, isFocused ? .m : .l) + } + .onAppear { + viewModel.performOnAppear() + } + .onDisappear { + viewModel.performOnDisappear() + } + .overlay { + if viewModel.isEditing { + EmptyView() + } else { + RoundedRectangle(cornerRadius: .m) + .trim(from: isFocused ? 0.52 : 0.51, to: isFocused ? 0.98 : 0.99) + .stroke(Color.Artemis.artemisBlue, lineWidth: 2) + } + } + .gesture( + DragGesture(minimumDistance: 30, coordinateSpace: .local) + .onEnded { value in + if value.translation.height > 0 { + // down + isFocused = false + let impactMed = UIImpactFeedbackGenerator(style: .medium) + impactMed.impactOccurred() + } + } + ) + } + .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) + } + } + } +} + +private extension SendMessageView { + @ViewBuilder var mentions: some View { + switch viewModel.conditionalPresentation { + case .memberPicker: + SendMessageMentionMemberView( + viewModel: SendMessageMentionMemberViewModel(course: viewModel.course), + sendMessageViewModel: viewModel + ) + case .channelPicker: + SendMessageMentionChannelView( + viewModel: SendMessageMentionChannelViewModel(course: viewModel.course), + sendMessageViewModel: viewModel + ) + case nil: + EmptyView() + } + } + + @ViewBuilder var textField: some View { + let label = R.string.localizable.messageAction(viewModel.conversation.baseConversation.conversationName) + if viewModel.isEditing { + TextField(label, text: $viewModel.text, axis: .vertical) + .textFieldStyle(ArtemisTextField()) + } else { + TextField(label, text: $viewModel.text, axis: .vertical) + } + } + + var keyboardToolbarContent: some View { + HStack { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Button(action: viewModel.didTapBoldButton) { + Image(systemName: "bold") + } + Button(action: viewModel.didTapItalicButton) { + Image(systemName: "italic") + } + Button(action: viewModel.didTapUnderlineButton) { + Image(systemName: "underline") + } + Button(action: viewModel.didTapBlockquoteButton) { + Image(systemName: "quote.opening") + } + Button(action: viewModel.didTapCodeButton) { + Image(systemName: "curlybraces") + } + Button(action: viewModel.didTapCodeBlockButton) { + Image(systemName: "curlybraces.square.fill") + } + Button(action: viewModel.didTapLinkButton) { + Image(systemName: "link") + } + Button(action: viewModel.didTapAtButton) { + Image(systemName: "at") + } + Button(action: viewModel.didTapNumberButton) { + Image(systemName: "number") + } + Button { + isFocused = false + viewModel.modalPresentation = .exercisePicker + } label: { + Text(R.string.localizable.exercise()) + } + Button { + isFocused = false + viewModel.modalPresentation = .lecturePicker + } label: { + Text(R.string.localizable.lecture()) + } + } + } + Spacer() + sendButton + } + } + + var sendButton: some View { + Button(action: viewModel.didTapSendButton) { + Image(systemName: "paperplane.fill") + .imageScale(.large) + } + .padding(.leading, .l) + .disabled(viewModel.text.isEmpty) + .loadingIndicator(isLoading: $viewModel.isLoading) + } +} diff --git a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift index 6676b03b..e64184fb 100644 --- a/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift +++ b/ArtemisKit/Sources/Navigation/Deeplinks/DeeplinkHandler.swift @@ -20,27 +20,42 @@ public class DeeplinkHandler { var navigationController: NavigationController? - private init() { } + private let userSession: UserSession + + private init( + userSession: UserSession = .shared + ) { + self.userSession = userSession + } func setup(navigationController: NavigationController) { self.navigationController = navigationController } public func handle(path: String) { - guard let url = URL(string: path, relativeTo: UserSession.shared.institution?.baseURL) else { return } + guard let url = URL(string: path, relativeTo: userSession.institution?.baseURL) else { + return + } handle(url: url) } - public func handle(url: URL) { - guard let navigationController else { - return + /// - Returns: Whether a handler could handle the URL. + @discardableResult + public func handle(url: URL) -> Bool { + guard url.host() == userSession.institution?.baseURL?.host(), + let navigationController, + let handler = buildHandler(from: url) else { + return false } - buildHandler(from: url)?.handle(with: navigationController) + + handler.handle(with: navigationController) + + return true } private func buildHandler(from url: URL) -> Deeplink? { - // warning: the order of the array matters - let builderFuncs: [(URL) -> Deeplink?] = [ + // Attention: the order of the array matters + let builders: [(URL) -> Deeplink?] = [ ExerciseHandler.build, LectureHandler.build, MessageHandler.build, @@ -50,20 +65,10 @@ public class DeeplinkHandler { UnknownLinkHandler.build ] - return builderFuncs - .map { $0(url) } - .compactMap { $0 } + return builders + .compactMap { builder in + builder(url) + } .first } } - -extension URL { - func trimBaseUrl() -> String? { - let string = self.absoluteString - - guard let baseURL = UserSession.shared.institution?.baseURL, - let endIndex = string.range(of: baseURL.absoluteString)?.upperBound else { return nil } - - return String(string.suffix(from: endIndex)) - } -} diff --git a/ArtemisKit/Sources/Navigation/NavigationController.swift b/ArtemisKit/Sources/Navigation/NavigationController.swift index c4b1b98e..60949689 100644 --- a/ArtemisKit/Sources/Navigation/NavigationController.swift +++ b/ArtemisKit/Sources/Navigation/NavigationController.swift @@ -1,6 +1,5 @@ -import SwiftUI -import SharedModels import Common +import SwiftUI @MainActor public class NavigationController: ObservableObject { @@ -18,157 +17,50 @@ public class NavigationController: ObservableObject { DeeplinkHandler.shared.setup(navigationController: self) } +} - public func popToRoot() { +public extension NavigationController { + func popToRoot() { path = NavigationPath() } - public func goToCourse(id: Int) { + func goToCourse(id: Int) { popToRoot() path.append(CoursePath(id: id)) log.debug("CoursePath was appended to queue") } - public func goToExercise(courseId: Int, exerciseId: Int) { + func goToExercise(courseId: Int, exerciseId: Int) { courseTab = .exercise goToCourse(id: courseId) - path.append(ExercisePath(id: exerciseId, - coursePath: CoursePath(id: courseId))) + path.append(ExercisePath(id: exerciseId, coursePath: CoursePath(id: courseId))) log.debug("ExercisePath was appended to queue") } - public func goToLecture(courseId: Int, lectureId: Int) { + func goToLecture(courseId: Int, lectureId: Int) { courseTab = .lecture goToCourse(id: courseId) - path.append(LecturePath(id: lectureId, - coursePath: CoursePath(id: courseId))) + path.append(LecturePath(id: lectureId, coursePath: CoursePath(id: courseId))) log.debug("LecturePath was appended to queue") } - public func setTab(identifier: TabIdentifier) { + func setTab(identifier: TabIdentifier) { courseTab = identifier } - public func goToCourseConversations(courseId: Int) { + func goToCourseConversations(courseId: Int) { courseTab = .communication goToCourse(id: courseId) } - public func goToCourseConversation(courseId: Int, conversationId: Int64) { + func goToCourseConversation(courseId: Int, conversationId: Int64) { goToCourseConversations(courseId: courseId) - path.append(ConversationPath(id: conversationId, - coursePath: CoursePath(id: courseId))) + path.append(ConversationPath(id: conversationId, coursePath: CoursePath(id: courseId))) } - public func showDeeplinkNotSupported(url: URL) { + func showDeeplinkNotSupported(url: URL) { notSupportedUrl = url showDeeplinkNotSupported = true } } - -public enum TabIdentifier { - case exercise, lecture, communication -} - -public struct CoursePath: Hashable { - public let id: Int - public let course: Course? - - public init(id: Int) { - self.id = id - self.course = nil - } - - public init(course: Course) { - self.id = course.id - self.course = course - } -} - -public struct ExercisePath: Hashable { - public let id: Int - public let exercise: Exercise? - public let coursePath: CoursePath - - init(id: Int, coursePath: CoursePath) { - self.id = id - self.exercise = nil - self.coursePath = coursePath - } - - public init(exercise: Exercise, coursePath: CoursePath) { - self.id = exercise.id - self.exercise = exercise - self.coursePath = coursePath - } -} - -public struct LecturePath: Hashable { - public let id: Int - public let lecture: Lecture? - public let coursePath: CoursePath - - init(id: Int, coursePath: CoursePath) { - self.id = id - self.lecture = nil - self.coursePath = coursePath - } - - public init(lecture: Lecture, coursePath: CoursePath) { - self.id = lecture.id - self.lecture = lecture - self.coursePath = coursePath - } -} - -public struct ConversationPath: Hashable { - public let id: Int64 - public let conversation: Conversation? - public let coursePath: CoursePath - - public init(id: Int64, coursePath: CoursePath) { - self.id = id - self.conversation = nil - self.coursePath = coursePath - } - - public init(conversation: Conversation, coursePath: CoursePath) { - self.id = conversation.id - self.conversation = conversation - self.coursePath = coursePath - } -} - -public struct MessagePath: Hashable { - public let id: Int64 - public let message: Binding>? - public let coursePath: CoursePath - public let conversationPath: ConversationPath - public let conversationViewModel: Any? - - init(id: Int64, coursePath: CoursePath, conversationPath: ConversationPath) { - self.id = id - self.message = nil - self.coursePath = coursePath - self.conversationPath = conversationPath - self.conversationViewModel = nil - } - - public init?(message: Binding>, coursePath: CoursePath, conversationPath: ConversationPath, conversationViewModel: Any) { - guard let id = message.wrappedValue.value?.id else { return nil } - self.id = id - self.message = message - self.coursePath = coursePath - self.conversationPath = conversationPath - self.conversationViewModel = conversationViewModel - } - - public static func == (lhs: MessagePath, rhs: MessagePath) -> Bool { - lhs.id == rhs.id && lhs.coursePath == rhs.coursePath && lhs.conversationPath == rhs.conversationPath - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} diff --git a/ArtemisKit/Sources/Navigation/NavigationPathValues.swift b/ArtemisKit/Sources/Navigation/NavigationPathValues.swift new file mode 100644 index 00000000..ae5cc4e6 --- /dev/null +++ b/ArtemisKit/Sources/Navigation/NavigationPathValues.swift @@ -0,0 +1,77 @@ +// +// NavigationPathValues.swift +// +// +// Created by Nityananda Zbil on 26.02.24. +// + +import SharedModels + +public struct CoursePath: Hashable { + public let id: Int + public let course: Course? + + public init(id: Int) { + self.id = id + self.course = nil + } + + public init(course: Course) { + self.id = course.id + self.course = course + } +} + +public struct ExercisePath: Hashable { + public let id: Int + public let exercise: Exercise? + public let coursePath: CoursePath + + init(id: Int, coursePath: CoursePath) { + self.id = id + self.exercise = nil + self.coursePath = coursePath + } + + public init(exercise: Exercise, coursePath: CoursePath) { + self.id = exercise.id + self.exercise = exercise + self.coursePath = coursePath + } +} + +public struct LecturePath: Hashable { + public let id: Int + public let lecture: Lecture? + public let coursePath: CoursePath + + init(id: Int, coursePath: CoursePath) { + self.id = id + self.lecture = nil + self.coursePath = coursePath + } + + public init(lecture: Lecture, coursePath: CoursePath) { + self.id = lecture.id + self.lecture = lecture + self.coursePath = coursePath + } +} + +public struct ConversationPath: Hashable { + public let id: Int64 + public let conversation: Conversation? + public let coursePath: CoursePath + + public init(id: Int64, coursePath: CoursePath) { + self.id = id + self.conversation = nil + self.coursePath = coursePath + } + + public init(conversation: Conversation, coursePath: CoursePath) { + self.id = conversation.id + self.conversation = conversation + self.coursePath = coursePath + } +} diff --git a/ArtemisKit/Sources/Navigation/TabIdentifier.swift b/ArtemisKit/Sources/Navigation/TabIdentifier.swift new file mode 100644 index 00000000..d98e0ea2 --- /dev/null +++ b/ArtemisKit/Sources/Navigation/TabIdentifier.swift @@ -0,0 +1,12 @@ +// +// TabIdentifier.swift +// +// +// Created by Nityananda Zbil on 26.02.24. +// + +public enum TabIdentifier { + case exercise + case lecture + case communication +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift new file mode 100644 index 00000000..a3e8f5c1 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/MessagesRepositoryTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Messages + +final class MessagesRepositoryTests: XCTestCase { + func testInsertAndUpdateAndFetch() async throws { + // given + let url = try XCTUnwrap(URL(string: "https://example.org")) + let host = try XCTUnwrap(url.host()) + let courseId = 1 + let conversationId = 1 + let messageDraft = "Hello" + let messageDraftUpdate = "Hello, world!" + + // when + // - init + let repository = try await MessagesRepository() + + await repository.insertServer(host: host) + + // - insert & update + try await repository.insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: messageDraft) + try await repository.insertConversation(host: host, courseId: courseId, conversationId: conversationId, messageDraft: messageDraftUpdate) + + // - fetch + let conversation = try await repository.fetchConversation(host: host, courseId: courseId, conversationId: conversationId) + + // then + let first = try XCTUnwrap(conversation) + XCTAssertEqual(first.messageDraft, messageDraftUpdate) + } +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift new file mode 100644 index 00000000..8f8a21f2 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageChannelPickerViewModelTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Messages + +final class SendMessageChannelPickerViewModelTests: XCTestCase { + func testChannelNameCaseInsensitivity() async throws { + // given + let viewModel = SendMessageMentionChannelViewModel( + course: .init(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging), + messagesService: MessagesServiceStub()) + + // when + await viewModel.search(idOrName: "Annôunce") + + // then + let channels = try XCTUnwrap(viewModel.channels.value) + XCTAssertNotNil(channels.first) + } +} diff --git a/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift new file mode 100644 index 00000000..3170be46 --- /dev/null +++ b/ArtemisKit/Tests/ArtemisKitTests/Messages/SendMessageViewModelTests.swift @@ -0,0 +1,88 @@ +import XCTest +import SharedModels +@testable import Messages + +final class SendMessageViewModelTests: XCTestCase { + func makeViewModel() -> SendMessageViewModel { + SendMessageViewModel( + course: Course(id: 1, courseInformationSharingConfiguration: .communicationAndMessaging), + conversation: Conversation(conversation: Channel(id: 1))!, + configuration: .message, + delegate: SendMessageViewModelDelegate( + loadMessages: {}, + presentError: { _ in }, + scrollToId: { _ in })) + } + + func testWriteAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@" + + // then + XCTAssertEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testWriteNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#" + + // then + XCTAssertEqual(viewModel.conditionalPresentation, .channelPicker) + } + + func testSuppressAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@" + viewModel.isMemberPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testSuppressNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#" + viewModel.isChannelPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .channelPicker) + } + + func testOverrideAt() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "@user " + viewModel.text += "#channel" + viewModel.isMemberPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .memberPicker) + } + + func testOverrideNumber() { + // given + let viewModel = makeViewModel() + + // when + viewModel.text += "#channel " + viewModel.text += "@user" + viewModel.isChannelPickerSuppressed = true + + // then + XCTAssertNotEqual(viewModel.conditionalPresentation, .channelPicker) + } +} From 43091d81372f2d4f3b544883e27bded5eb74d3a6 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Tue, 16 Jul 2024 21:00:11 +0200 Subject: [PATCH 3/4] `General`: Release 1.1.0 (#132) - Participation in Text Exercises - Improvements to Conversation List, such as swipe gestures, icons, simplified buttons and menus - New design of Exercise and Lecture List - Number of Lectures now shows "Lectures" instead of "Exercises" - Fix Notifications for Exercises could not be opened - Fix Pull to Refresh on Exercises was broken --- Artemis.xcodeproj/project.pbxproj | 137 ++++- .../xcshareddata/swiftpm/Package.resolved | 12 +- .../xcshareddata/xcschemes/Artemis.xcscheme | 11 + .../xcschemes/ArtemisUITests.xcscheme | 54 ++ Artemis/Supporting/Info.plist | 2 +- ArtemisKit/Package.swift | 2 +- .../Sources/ArtemisKit/AppDelegate.swift | 2 +- .../Sources/ArtemisKit/RootViewModel.swift | 2 +- .../CourseRegistrationView.swift | 23 +- .../Sources/CourseView/CourseViewModel.swift | 3 +- ...xerciseParticipationAssessmentButton.swift | 30 ++ .../ExerciseParticipationProblemButton.swift | 30 ++ .../ExerciseParticipationSubmitButton.swift | 62 +++ .../Views/EditModelingExerciseView.swift | 148 ++---- .../ViewModelingExerciseResultView.swift | 37 +- .../TextExercise/EditTextExerciseView.swift | 110 ++++ .../EditTextExerciseViewModel.swift | 90 ++++ .../TextExercise/ViewTextExerciseView.swift | 40 ++ .../ExerciseTab/ExerciseDetailView.swift | 484 +++++++++--------- .../ExerciseTab/ExerciseDetailViewModel.swift | 132 +++++ .../ExerciseTab/ExerciseListView.swift | 98 ++-- .../LectureTab/LectureDetailView.swift | 6 +- .../LectureTab/LectureListView.swift | 9 +- .../Resources/en.lproj/Localizable.strings | 4 +- .../ExerciseSubmissionService.swift | 2 + .../TextExerciseSubmissionServiceImpl.swift | 92 ++++ .../LectureService/LectureServiceImpl.swift | 4 +- .../Sources/Dashboard/CourseGridCell.swift | 20 +- .../Sources/Dashboard/CourseServiceStub.swift | 6 +- .../Repositories/MessagesRepository.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 16 + .../CodeOfConductService.swift | 6 +- .../CodeOfConductServiceImpl.swift | 2 +- .../CodeOfConductServiceStub.swift | 27 + .../CodeOfConductStorageServiceImpl.swift | 4 +- .../LectureService/LectureService.swift | 17 + .../LectureService/LectureServiceImpl.swift | 40 ++ .../MessagesService/MessagesService.swift | 12 +- .../MessagesService/MessagesServiceImpl.swift | 2 +- .../MessagesService/MessagesServiceStub.swift | 2 +- .../ConversationViewModel.swift | 2 +- .../MessageCellModel+MentionScheme.swift | 44 +- .../MessageCellModel.swift | 2 +- .../MessagesAvailableViewModel.swift | 6 +- .../SendMessageLecturePickerViewModel.swift | 82 +++ .../SendMessageMentionChannelViewModel.swift | 2 +- .../SendMessageMentionContentDelegate.swift | 10 + .../SendMessageViewModel.swift | 13 +- .../ConversationInfoSheetView.swift | 4 +- .../Views/MessageDetailView/MessageCell.swift | 34 ++ .../ConversationRow/ConversationRow.swift | 46 +- .../MessagesAvailableView.swift | 287 ++++++----- .../MessagesTabView/MessagesTabView.swift | 2 +- .../SendMessageExercisePicker.swift | 28 +- .../SendMessageLecturePicker.swift | 96 +++- .../SendMessageMentionContentView.swift | 47 ++ .../SendMessageViews/SendMessageView.swift | 115 +++-- .../Deeplinks/DeeplinkHandler.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../NotificationWebsocketServiceImpl.swift | 8 +- .../ViewModels/NotificationViewModel.swift | 2 +- .../Views/NotificationView.swift | 7 + .../Views/View+NotificationToolbar.swift | 19 +- ArtemisUITests/ArtemisUITests.swift | 43 ++ ArtemisUITests/SnapshotHelper.swift | 313 +++++++++++ fastlane/Fastfile | 11 + fastlane/README.md | 8 + fastlane/Snapfile | 26 + 68 files changed, 2306 insertions(+), 736 deletions(-) create mode 100644 Artemis.xcodeproj/xcshareddata/xcschemes/ArtemisUITests.xcscheme create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationAssessmentButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationProblemButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/ExerciseParticipationSubmitButton.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/EditTextExerciseViewModel.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseParticipation/TextExercise/ViewTextExerciseView.swift create mode 100644 ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseDetailViewModel.swift create mode 100644 ArtemisKit/Sources/CourseView/Services/ExerciseService/TextExerciseSubmissionServiceImpl.swift create mode 100644 ArtemisKit/Sources/Messages/Services/CodeOfConductService/CodeOfConductServiceStub.swift create mode 100644 ArtemisKit/Sources/Messages/Services/LectureService/LectureService.swift create mode 100644 ArtemisKit/Sources/Messages/Services/LectureService/LectureServiceImpl.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageLecturePickerViewModel.swift create mode 100644 ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageMentionContentDelegate.swift create mode 100644 ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift create mode 100644 ArtemisUITests/ArtemisUITests.swift create mode 100644 ArtemisUITests/SnapshotHelper.swift create mode 100644 fastlane/Snapfile 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 From 1862db237c9b4cfb491abb49f568bdc6f0fd35f9 Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Thu, 22 Aug 2024 18:12:06 +0200 Subject: [PATCH 4/4] Fix merge conflict --- .../MessagesTabView/ConversationRow/ConversationRow.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift index cc29649a..49fba182 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/ConversationRow/ConversationRow.swift @@ -48,12 +48,6 @@ struct ConversationRow: View { Image(systemName: "ellipsis") .padding(.m) } - Menu { - contextMenuItems - } label: { - Image(systemName: "ellipsis") - .padding(.m) - } } .opacity((conversation.unreadMessagesCount ?? 0) > 0 ? 1 : 0.7) .contextMenu {