From c2515e0a4909c3a3b2164b693641124532c4beba Mon Sep 17 00:00:00 2001 From: Anian Schleyer <98647423+anian03@users.noreply.github.com> Date: Sat, 5 Oct 2024 13:05:14 +0200 Subject: [PATCH] Communication: Add Profile Info View (#177) --- .../xcshareddata/swiftpm/Package.resolved | 8 +- ArtemisKit/Package.swift | 2 +- .../Sources/ArtemisKit/RootViewModel.swift | 3 + .../Resources/en.lproj/Localizable.strings | 5 + .../ProfileViewModel.swift | 66 +++++++++ .../Views/MessageDetailView/MessageCell.swift | 2 +- .../ProfilePictureView.swift | 128 ++++++++++++++++-- 7 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 26601de8..dfc00784 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" : "e10992600af821dab51ef29b5f28db7ae457c78c", - "version" : "14.4.0" + "revision" : "c5ad034195a126f616ed7e5e6b71a57472a120d4", + "version" : "14.5.0" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ashleymills/Reachability.swift", "state" : { - "revision" : "7cbd73f46a7dfaeca079e18df7324c6de6d1834a", - "version" : "5.2.3" + "revision" : "21d1dc412cfecbe6e34f1f4c4eb88d3f912654a6", + "version" : "5.2.4" } }, { diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 1d55b55b..607fe366 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -20,7 +20,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"), .package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.4.0")), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.5.0")), .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") ], targets: [ diff --git a/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift index 2623c208..54ab8f11 100644 --- a/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift +++ b/ArtemisKit/Sources/ArtemisKit/RootViewModel.swift @@ -62,6 +62,9 @@ private extension RootViewModel { userSession.setTokenExpired(expired: false) case .done: isLoggedIn = userSession.isLoggedIn + if isLoggedIn, let user = user.value { + userSession.user = user + } didSetupNotifications = userSession.getCurrentNotificationDeviceConfiguration() != nil } isLoading = false diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 8afb3092..eb53ab7a 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -75,6 +75,11 @@ "pinMessage" = "Pin Message"; "unpinMessage" = "Unpin Message"; +// MARK: ProfileView +"profile" = "Profile"; +"actions" = "Actions"; +"name" = "Name"; + // MARK: ConversationView "noMessages" = "No Messages"; "noMessagesDescription" = "Write the first message to kickstart this conversation."; diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..592d08aa --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift @@ -0,0 +1,66 @@ +// +// ProfileViewModel.swift +// ArtemisKit +// +// Created by Anian Schleyer on 23.09.24. +// + +import Common +import Foundation +import Navigation +import SharedModels +import SharedServices +import UserStore + +@Observable +class ProfileViewModel { + var error: UserFacingError? + var isLoading = false + var showProfileSheet = false + + let user: ConversationUser + let role: UserRole? + private var login: String? + let course: Course + + init(course: Course, user: ConversationUser, role: UserRole?) { + self.course = course + self.user = user + self.role = role + } + + // We can only create a conversation if we have the user's login + // Don't allow sending messages to oneself + var canSendMessage: Bool { + (user.login != nil || login != nil) + && UserSessionFactory.shared.user?.id != user.id + } + + func loadUserLogin() async { + guard let name = user.name else { return } + isLoading = true + let result = await CourseServiceFactory.shared.getCourseMembers(courseId: course.id, searchLoginOrName: name) + switch result { + case .done(let matches): + login = matches.first(where: { $0.name == user.name })?.login + isLoading = false + default: + // No user found – cannot send a message + break + } + } + + @MainActor + func openConversation(navigationController: NavigationController, completion: @escaping () -> Void) { + guard let login = user.login ?? login else { return } + isLoading = true + Task { + let messageCellModel = MessageCellModel(course: course, conversationPath: nil, isHeaderVisible: false, roundBottomCorners: false, retryButtonAction: {}) + if let conversation = await messageCellModel.getOneToOneChatOrCreate(login: login) { + navigationController.goToCourseConversation(courseId: course.id, conversation: conversation) + completion() + } + isLoading = false + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 9f915ac7..20c1cc04 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -186,7 +186,7 @@ private extension MessageCell { if viewModel.isHeaderVisible { HStack(alignment: .top, spacing: .m) { if let authorUser { - ProfilePictureView(user: authorUser) + ProfilePictureView(user: authorUser, role: authorRole, course: viewModel.course) } VStack(alignment: .leading, spacing: .xs) { HStack(alignment: .firstTextBaseline, spacing: .m) { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift index 1fca22b0..cfb30035 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift @@ -6,32 +6,47 @@ // import DesignLibrary +import Navigation import SharedModels import SwiftUI struct ProfilePictureView: View { - let user: ConversationUser + @State private var viewModel: ProfileViewModel + + init(user: ConversationUser, role: UserRole?, course: Course) { + self._viewModel = State(initialValue: ProfileViewModel(course: course, user: user, role: role)) + } var body: some View { - Group { - if let url = user.imagePath { + Button { + viewModel.showProfileSheet = true + } label: { + if let url = viewModel.user.imagePath { ArtemisAsyncImage(imageURL: url) { - defaultProfilePicture + DefaultProfilePictureView(viewModel: viewModel) } } else { - defaultProfilePicture + DefaultProfilePictureView(viewModel: viewModel) } } .frame(width: 44, height: 44) .clipShape(.rect(cornerRadius: .m)) + .sheet(isPresented: $viewModel.showProfileSheet) { + ProfileInfoSheet(viewModel: viewModel) + } } +} + +private struct DefaultProfilePictureView: View { + let viewModel: ProfileViewModel + var font: Font = .headline.bold() - @ViewBuilder var defaultProfilePicture: some View { + var body: some View { ZStack { Rectangle() .fill(backgroundColor) Text(initials) - .font(.headline.bold()) + .font(font) .fontDesign(.rounded) .foregroundStyle(.white) } @@ -39,7 +54,7 @@ struct ProfilePictureView: View { } private var initials: String { - let nameComponents = user.name?.split(separator: " ") + let nameComponents = viewModel.user.name?.split(separator: " ") let initialFirstName = nameComponents?.first?.prefix(1) ?? "" let initialLastName = nameComponents?.last?.prefix(1) ?? "" let initials = initialFirstName + initialLastName @@ -51,7 +66,102 @@ struct ProfilePictureView: View { } private var backgroundColor: Color { - let hash = abs(String(user.id).hashValue) % 255 + let hash = abs(String(viewModel.user.id).hashValue) % 255 return Color(hue: Double(hash) / 255, saturation: 0.5, brightness: 0.5) } } + +struct ProfileInfoSheet: View { + @EnvironmentObject var navController: NavigationController + @Environment(\.dismiss) var dismiss + let viewModel: ProfileViewModel + + var body: some View { + @Bindable var viewModel = viewModel + NavigationStack { + List { + Group { + if let name = viewModel.user.name { + HStack(alignment: .center, spacing: .l) { + Group { + if let profileUrl = viewModel.user.imagePath { + ArtemisAsyncImage(imageURL: profileUrl) {} + } else { + DefaultProfilePictureView(viewModel: viewModel, font: .largeTitle) + } + } + .frame(width: 100, height: 100) + .clipShape(.rect(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: .m) { + if let role = viewModel.role { + Chip(text: role.displayName, + backgroundColor: role.badgeColor, + horizontalPadding: .m, + verticalPadding: .s) + .font(.body) + // For visual alignment + .padding(.top, .m) + } + + Text(name) + .font(.title) + .multilineTextAlignment(.leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + if viewModel.canSendMessage { + Section(R.string.localizable.actions()) { + Button(R.string.localizable.sendMessage(), systemImage: "bubble.fill") { + viewModel.openConversation(navigationController: navController) { + dismiss() + } + } + } + } + } + .listRowBackground( + Spacer() + .background(.primary.opacity(0.15)) + .background(.thinMaterial) + ) + } + .navigationTitle(R.string.localizable.profile()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(R.string.localizable.done()) { + dismiss() + } + } + } + .scrollContentBackground(.hidden) + .background(backgroundImage) + } + .task { + await viewModel.loadUserLogin() + } + .loadingIndicator(isLoading: $viewModel.isLoading) + .alert(isPresented: Binding(get: { + viewModel.error != nil + }, set: { newValue in + if !newValue { + viewModel.error = nil + } + }), error: viewModel.error, actions: {}) + } + + @ViewBuilder private var backgroundImage: some View { + if let profileUrl = viewModel.user.imagePath { + ArtemisAsyncImage(imageURL: profileUrl) {} + .scaledToFill() + .ignoresSafeArea() + .blur(radius: .l, opaque: true) + .opacity(0.15) + } + } +}