diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift index 21fe18470..30356f083 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -12,16 +12,58 @@ import Views self.recommendationGroup = recommendationGroup } - func loadGroups(dataService _: DataService) async { - isLoading = true + var nonAdmins: [InternalUserProfile] { + recommendationGroup.members.filter { member in + !recommendationGroup.admins.contains(where: { member.id == $0.id }) + } + } +} -// do { -// recommendationGroups = try await dataService.recommendationGroups() -// } catch { -// networkError = true -// } +private struct SmallUserCard: View { + let data: ProfileCardData + var size: CGFloat - isLoading = false + public init(data: ProfileCardData, size: CGFloat = 36) { + self.data = data + self.size = size + } + + public var body: some View { + HStack(alignment: .center, spacing: 16) { + Group { + AsyncImage( + url: data.imageURL, + content: { $0.resizable() }, + placeholder: { + Image(systemName: "person.crop.circle") + .resizable() + .foregroundColor(.appGrayTextContrast) + } + ) + } + .padding(.leading, 0) + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size, alignment: .center) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(data.name) + .font(.appBody) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("@\(data.username)") + .font(.appCaption) + .foregroundColor(.appGrayText) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.leading) + .padding(0) + } + .frame(maxWidth: .infinity) } } @@ -44,7 +86,6 @@ struct RecommendationGroupView: View { .listStyle(InsetListStyle()) #endif } - .task { await viewModel.loadGroups(dataService: dataService) } } private var shareView: some View { @@ -55,6 +96,30 @@ struct RecommendationGroupView: View { } } + private var adminsSection: some View { + Section("Admins") { + ForEach(viewModel.recommendationGroup.admins) { admin in + SmallUserCard(data: ProfileCardData( + name: admin.name, + username: admin.username, + imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil + )) + } + } + } + + private var membersSection: some View { + Section("Members") { + ForEach(viewModel.nonAdmins) { member in + SmallUserCard(data: ProfileCardData( + name: member.name, + username: member.username, + imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil + )) + } + } + } + private var innerBody: some View { Group { Section("Name") { @@ -66,8 +131,12 @@ struct RecommendationGroupView: View { presentShareSheet = true }, label: { Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))") + .font(.appCaption) }) } + + adminsSection + membersSection } .formSheet(isPresented: $presentShareSheet) { shareView diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index 4164550fb..bc60ce80e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -6,7 +6,7 @@ import Utils import Views @MainActor final class CreateProfileViewModel: ObservableObject { - private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil) + private(set) var initialUserProfile = NewUserProfile(username: "", name: "", bio: nil) var isConfigured = false var hasSuggestedProfile: Bool { @@ -31,7 +31,7 @@ import Views init() {} func submitProfile(name: String, bio: String, authenticator: Authenticator) { - let profileOrError = UserProfile.make( + let profileOrError = NewUserProfile.make( username: potentialUsername, name: name, bio: bio.isEmpty ? nil : bio @@ -77,7 +77,7 @@ import Views } } - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -87,7 +87,7 @@ import Views } } - func configure(profile: UserProfile, dataService: DataService) { + func configure(profile: NewUserProfile, dataService: DataService) { guard !isConfigured else { return } isConfigured = true @@ -104,7 +104,7 @@ import Views } struct CreateProfileView: View { - private let initialUserProfile: UserProfile + private let initialUserProfile: NewUserProfile @State private var isConfigured = false @Environment(\.horizontalSizeClass) var horizontalSizeClass @EnvironmentObject var authenticator: Authenticator @@ -114,7 +114,7 @@ struct CreateProfileView: View { @State private var name = "" @State private var bio = "" - init(userProfile: UserProfile) { + init(userProfile: NewUserProfile) { self.initialUserProfile = userProfile } diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift index 3bb561ac5..3eda742f1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift @@ -9,7 +9,7 @@ import Views init() {} - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -23,7 +23,7 @@ import Views struct NewAppleSignupView: View { @EnvironmentObject var authenticator: Authenticator @StateObject private var viewModel = NewAppleSignupViewModel() - let userProfile: UserProfile + let userProfile: NewUserProfile let showProfileEditView: () -> Void var body: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index 8bd5754ab..db6250caf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -7,8 +7,8 @@ import Views @MainActor final class RegistrationViewModel: ObservableObject { enum RegistrationState { - case createProfile(userProfile: UserProfile) - case newAppleSignUp(userProfile: UserProfile) + case createProfile(userProfile: NewUserProfile) + case newAppleSignUp(userProfile: NewUserProfile) } @Published var loginError: LoginError? @@ -65,7 +65,7 @@ import Views case let .loginError(error): loginError = error case .newOmnivoreUser: - registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) + registrationState = .createProfile(userProfile: NewUserProfile(username: "", name: "")) case .existingOmnivoreUser: break } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 0e08359d7..9dbaf53aa 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -104,6 +104,14 @@ + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift new file mode 100644 index 000000000..a13c52bcb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift @@ -0,0 +1,52 @@ +import Foundation +import Utils + +public struct NewUserProfile: Codable { + public let username: String + public let name: String + public let bio: String? + + public init( + username: String, + name: String, + bio: String? = nil + ) { + self.username = username + self.name = name + self.bio = bio + } +} + +public extension NewUserProfile { + static func make( + username: String, + name: String, + bio: String? + ) -> Either { + let userProfile = NewUserProfile( + username: username, + name: name, + bio: bio + ) + + if let errorMessage = userProfile.validationErrorMessage { + return .right(errorMessage) + } else { + return .left(userProfile) + } + } + + private var validationErrorMessage: String? { + if name.isEmpty { + return "The name field is missing." + } + + return nil + } +} + +public extension Viewer { + var unwrappedUsername: String { username ?? "" } + var unwrappedName: String { name ?? "" } + var unwrappedUserID: String { userID ?? "" } +} diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift index 18e0a3da1..7c655577f 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift @@ -1,52 +1,19 @@ +import CoreData import Foundation -import Utils - -public struct UserProfile: Codable { - public let username: String - public let name: String - public let bio: String? - - public init( - username: String, - name: String, - bio: String? = nil - ) { - self.username = username - self.name = name - self.bio = bio - } -} public extension UserProfile { - static func make( - username: String, - name: String, - bio: String? - ) -> Either { - let userProfile = UserProfile( - username: username, - name: name, - bio: bio + static func lookup(byID userID: String, inContext context: NSManagedObjectContext) -> UserProfile? { + let fetchRequest: NSFetchRequest = UserProfile.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "%K == %@", #keyPath(UserProfile.userID), userID ) - if let errorMessage = userProfile.validationErrorMessage { - return .right(errorMessage) - } else { - return .left(userProfile) - } - } + var result: UserProfile? - private var validationErrorMessage: String? { - if name.isEmpty { - return "The name field is missing." + context.performAndWait { + result = (try? context.fetch(fetchRequest))?.first } - return nil + return result } } - -public extension Viewer { - var unwrappedUsername: String { username ?? "" } - var unwrappedName: String { name ?? "" } - var unwrappedUserID: String { userID ?? "" } -} diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift index 73e31dbb7..1db37f136 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift @@ -5,12 +5,12 @@ public extension Authenticator { func createPendingAccountUsingApple( token: String, name: PersonNameComponents? - ) async throws -> UserProfile { + ) async throws -> NewUserProfile { let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name) return try await createPendingAccount(params: params) } - func createAccount(userProfile: UserProfile) async throws { + func createAccount(userProfile: NewUserProfile) async throws { let params = CreateAccountParams( pendingUserToken: pendingUserToken ?? "", userProfile: userProfile @@ -34,7 +34,7 @@ public extension Authenticator { } extension Authenticator { - func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile { + func createPendingAccount(params: CreatePendingAccountParams) async throws -> NewUserProfile { do { let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams) diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index 454e06b1e..550389c56 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -48,12 +48,12 @@ struct EmailAuthPayload: Decodable { struct CreateAccountParams: Encodable { let pendingUserToken: String - let userProfile: UserProfile + let userProfile: NewUserProfile } struct PendingUserAuthPayload: Decodable { let pendingUserToken: String - let pendingUserProfile: UserProfile + let pendingUserProfile: NewUserProfile } struct PendingEmailVerificationAuthPayload: Decodable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift index 4064c1bf9..7759f9814 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift @@ -5,6 +5,8 @@ let recommendationGroupSelection = Selection.RecommendationGroup { InternalRecommendationGroup( id: try $0.id(), name: try $0.name(), - inviteUrl: try $0.inviteUrl() + inviteUrl: try $0.inviteUrl(), + admins: try $0.admins(selection: userProfileSelection.list), + members: try $0.members(selection: userProfileSelection.list) ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift new file mode 100644 index 000000000..ad9562955 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift @@ -0,0 +1,18 @@ +import Models +import SwiftGraphQL + +let profileSelection = Selection.Profile { + ( + username: try $0.username(), + profileImageURL: try $0.pictureUrl() + ) +} + +let userProfileSelection = Selection.User { + InternalUserProfile( + userID: try $0.id(), + name: try $0.name(), + username: try $0.profile(selection: profileSelection).username, + profileImageURL: try $0.profile(selection: profileSelection).profileImageURL + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift index c787cc1f8..ff90cc253 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift @@ -9,10 +9,12 @@ import CoreData import Foundation import Models -public struct InternalRecommendationGroup: Encodable, Identifiable { +public struct InternalRecommendationGroup: Identifiable { public let id: String public let name: String public let inviteUrl: String + public let admins: [InternalUserProfile] + public let members: [InternalUserProfile] func asManagedObject(inContext context: NSManagedObjectContext) -> RecommendationGroup { let fetchRequest: NSFetchRequest = RecommendationGroup.fetchRequest() @@ -37,7 +39,9 @@ public struct InternalRecommendationGroup: Encodable, Identifiable { return InternalRecommendationGroup( id: id, name: name, - inviteUrl: inviteUrl + inviteUrl: inviteUrl, + admins: InternalUserProfile.make(recommendationGroup.admins), + members: InternalUserProfile.make(recommendationGroup.members) ) } return nil diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift new file mode 100644 index 000000000..75a2924a8 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift @@ -0,0 +1,63 @@ +import CoreData +import Foundation +import Models + +public struct InternalUserProfile: Identifiable { + let userID: String + public let name: String + public let username: String + public let profileImageURL: String? + + public var id: String { + userID + } + + func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { + var objectID: NSManagedObjectID? + + context.performAndWait { + let user = asManagedObject(inContext: context) + + do { + try context.save() + logger.debug("User saved succesfully") + objectID = user.objectID + } catch { + context.rollback() + logger.debug("Failed to save User: \(error.localizedDescription)") + } + } + + return objectID + } + + func asManagedObject(inContext context: NSManagedObjectContext) -> UserProfile { + let existing = UserProfile.lookup(byID: userID, inContext: context) + let userProfile = existing ?? UserProfile(entity: UserProfile.entity(), insertInto: context) + + userProfile.userID = userID + userProfile.name = name + userProfile.username = username + userProfile.profileImageURL = profileImageURL + return userProfile + } + + public static func make(_ users: NSSet?) -> [InternalUserProfile] { + users? + .compactMap { user in + if let user = user as? UserProfile, + let userID = user.userID, + let name = user.name, + let username = user.username + { + return InternalUserProfile( + userID: userID, + name: name, + username: username, + profileImageURL: user.profileImageURL + ) + } + return nil + } ?? [] + } +} diff --git a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift index 9e923a560..dc1dc69aa 100644 --- a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift +++ b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift @@ -1,9 +1,9 @@ import SwiftUI public struct ProfileCardData { - let name: String - let username: String - let imageURL: URL? + public let name: String + public let username: String + public let imageURL: URL? public init(name: String = "", username: String = "", imageURL: URL? = nil) { self.name = name