diff --git a/.DS_Store b/.DS_Store index 7fa1be84..8697ea9b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Development.xcconfig b/Development.xcconfig new file mode 100644 index 00000000..5e42bbba --- /dev/null +++ b/Development.xcconfig @@ -0,0 +1,9 @@ +// +// Development.xcconfig +// MobileAcebook +// +// Created by Sam Quincey on 03/09/2024. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 diff --git a/MobileAcebook.xcodeproj/project.pbxproj b/MobileAcebook.xcodeproj/project.pbxproj index 5506db3b..121a5d2c 100644 --- a/MobileAcebook.xcodeproj/project.pbxproj +++ b/MobileAcebook.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 25F17D2D2C870C9F001CEF06 /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F17D2C2C870C9F001CEF06 /* SignUpView.swift */; }; + 25F17D2F2C870CAD001CEF06 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F17D2E2C870CAD001CEF06 /* LoginView.swift */; }; AE5D85B02AC8A221009680C6 /* MobileAcebookApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5D85AF2AC8A221009680C6 /* MobileAcebookApp.swift */; }; AE5D85B42AC8A224009680C6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE5D85B32AC8A224009680C6 /* Assets.xcassets */; }; AE5D85B72AC8A224009680C6 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE5D85B62AC8A224009680C6 /* Preview Assets.xcassets */; }; @@ -19,6 +21,20 @@ AE5D85E32AC9AFD2009680C6 /* MockAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5D85E22AC9AFD2009680C6 /* MockAuthenticationService.swift */; }; AE5D85E62AC9B077009680C6 /* AuthenticationServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5D85E52AC9B077009680C6 /* AuthenticationServiceProtocol.swift */; }; AE5D85E82AC9B29A009680C6 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5D85E72AC9B29A009680C6 /* User.swift */; }; + F82DA57C2C89BC6800CA8A56 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F82DA57B2C89BC6800CA8A56 /* PhotoPicker.swift */; }; + F8304C5D2C888BF000B4BBC9 /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8304C5C2C888BF000B4BBC9 /* FeedView.swift */; }; + F8304C5F2C888C0500B4BBC9 /* PostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8304C5E2C888C0500B4BBC9 /* PostCardView.swift */; }; + F83545452C875D9300AB9C9E /* FullPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83545442C875D9300AB9C9E /* FullPostViewModel.swift */; }; + F83545472C875DAC00AB9C9E /* FullPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83545462C875DAC00AB9C9E /* FullPostView.swift */; }; + F844A8AC2C874802007EA48A /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8A92C874802007EA48A /* PostService.swift */; }; + F844A8AD2C874802007EA48A /* CommentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8AA2C874802007EA48A /* CommentService.swift */; }; + F844A8AE2C874802007EA48A /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8AB2C874802007EA48A /* UserService.swift */; }; + F844A8B12C87480F007EA48A /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8AF2C87480F007EA48A /* Post.swift */; }; + F844A8B22C87480F007EA48A /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8B02C87480F007EA48A /* Comment.swift */; }; + F844A8B62C874D56007EA48A /* CreatePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8B52C874D56007EA48A /* CreatePostView.swift */; }; + F844A8B82C875538007EA48A /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F844A8B72C875538007EA48A /* DateExtension.swift */; }; + F87BD8272C88AF5E0071F4D3 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87BD8262C88AF5E0071F4D3 /* MainView.swift */; }; + F8A25B612C884FF6009AE361 /* LogoutConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A25B602C884FF6009AE361 /* LogoutConfirmationView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +55,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 25F17D2C2C870C9F001CEF06 /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; + 25F17D2E2C870CAD001CEF06 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; AE5D85AC2AC8A221009680C6 /* MobileAcebook.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileAcebook.app; sourceTree = BUILT_PRODUCTS_DIR; }; AE5D85AF2AC8A221009680C6 /* MobileAcebookApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileAcebookApp.swift; sourceTree = ""; }; AE5D85B32AC8A224009680C6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -54,6 +72,20 @@ AE5D85E22AC9AFD2009680C6 /* MockAuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthenticationService.swift; sourceTree = ""; }; AE5D85E52AC9B077009680C6 /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = ""; }; AE5D85E72AC9B29A009680C6 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + F82DA57B2C89BC6800CA8A56 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; + F8304C5C2C888BF000B4BBC9 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = ""; }; + F8304C5E2C888C0500B4BBC9 /* PostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardView.swift; sourceTree = ""; }; + F83545442C875D9300AB9C9E /* FullPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullPostViewModel.swift; sourceTree = ""; }; + F83545462C875DAC00AB9C9E /* FullPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullPostView.swift; sourceTree = ""; }; + F844A8A92C874802007EA48A /* PostService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = ""; }; + F844A8AA2C874802007EA48A /* CommentService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentService.swift; sourceTree = ""; }; + F844A8AB2C874802007EA48A /* UserService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; + F844A8AF2C87480F007EA48A /* Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + F844A8B02C87480F007EA48A /* Comment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + F844A8B52C874D56007EA48A /* CreatePostView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreatePostView.swift; sourceTree = ""; }; + F844A8B72C875538007EA48A /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; + F87BD8262C88AF5E0071F4D3 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + F8A25B602C884FF6009AE361 /* LogoutConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutConfirmationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -111,6 +143,16 @@ AE5D85B32AC8A224009680C6 /* Assets.xcassets */, AE5D85B52AC8A224009680C6 /* Preview Content */, AE5D85D92AC8A337009680C6 /* WelcomePageView.swift */, + 25F17D2C2C870C9F001CEF06 /* SignUpView.swift */, + 25F17D2E2C870CAD001CEF06 /* LoginView.swift */, + F844A8B52C874D56007EA48A /* CreatePostView.swift */, + F844A8B72C875538007EA48A /* DateExtension.swift */, + F83545462C875DAC00AB9C9E /* FullPostView.swift */, + F8A25B602C884FF6009AE361 /* LogoutConfirmationView.swift */, + F8304C5C2C888BF000B4BBC9 /* FeedView.swift */, + F8304C5E2C888C0500B4BBC9 /* PostCardView.swift */, + F87BD8262C88AF5E0071F4D3 /* MainView.swift */, + F82DA57B2C89BC6800CA8A56 /* PhotoPicker.swift */, ); path = MobileAcebook; sourceTree = ""; @@ -145,6 +187,9 @@ AE5D85DD2AC9AF72009680C6 /* Services */ = { isa = PBXGroup; children = ( + F844A8AA2C874802007EA48A /* CommentService.swift */, + F844A8A92C874802007EA48A /* PostService.swift */, + F844A8AB2C874802007EA48A /* UserService.swift */, AE5D85E02AC9AFA9009680C6 /* AuthenticationService.swift */, ); path = Services; @@ -161,7 +206,10 @@ AE5D85DF2AC9AF83009680C6 /* Models */ = { isa = PBXGroup; children = ( + F844A8B02C87480F007EA48A /* Comment.swift */, + F844A8AF2C87480F007EA48A /* Post.swift */, AE5D85E72AC9B29A009680C6 /* User.swift */, + F83545442C875D9300AB9C9E /* FullPostViewModel.swift */, ); path = Models; sourceTree = ""; @@ -238,7 +286,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1420; + LastUpgradeCheck = 1540; TargetAttributes = { AE5D85AB2AC8A221009680C6 = { CreatedOnToolsVersion = 14.2; @@ -305,9 +353,25 @@ buildActionMask = 2147483647; files = ( AE5D85E12AC9AFA9009680C6 /* AuthenticationService.swift in Sources */, + F83545472C875DAC00AB9C9E /* FullPostView.swift in Sources */, + F844A8B82C875538007EA48A /* DateExtension.swift in Sources */, + F844A8B12C87480F007EA48A /* Post.swift in Sources */, + F844A8B62C874D56007EA48A /* CreatePostView.swift in Sources */, + F8304C5F2C888C0500B4BBC9 /* PostCardView.swift in Sources */, + 25F17D2F2C870CAD001CEF06 /* LoginView.swift in Sources */, + F8A25B612C884FF6009AE361 /* LogoutConfirmationView.swift in Sources */, AE5D85E62AC9B077009680C6 /* AuthenticationServiceProtocol.swift in Sources */, + F844A8AC2C874802007EA48A /* PostService.swift in Sources */, + F844A8AD2C874802007EA48A /* CommentService.swift in Sources */, + F8304C5D2C888BF000B4BBC9 /* FeedView.swift in Sources */, AE5D85B02AC8A221009680C6 /* MobileAcebookApp.swift in Sources */, + F87BD8272C88AF5E0071F4D3 /* MainView.swift in Sources */, + F82DA57C2C89BC6800CA8A56 /* PhotoPicker.swift in Sources */, + F83545452C875D9300AB9C9E /* FullPostViewModel.swift in Sources */, + F844A8B22C87480F007EA48A /* Comment.swift in Sources */, + F844A8AE2C874802007EA48A /* UserService.swift in Sources */, AE5D85E82AC9B29A009680C6 /* User.swift in Sources */, + 25F17D2D2C870C9F001CEF06 /* SignUpView.swift in Sources */, AE5D85DA2AC8A337009680C6 /* WelcomePageView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -383,6 +447,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -443,6 +508,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; diff --git a/MobileAcebook/.DS_Store b/MobileAcebook/.DS_Store new file mode 100644 index 00000000..c9824d14 Binary files /dev/null and b/MobileAcebook/.DS_Store differ diff --git a/MobileAcebook/Assets.xcassets/.DS_Store b/MobileAcebook/Assets.xcassets/.DS_Store new file mode 100644 index 00000000..66338794 Binary files /dev/null and b/MobileAcebook/Assets.xcassets/.DS_Store differ diff --git a/MobileAcebook/CreatePostView.swift b/MobileAcebook/CreatePostView.swift new file mode 100644 index 00000000..a7d76965 --- /dev/null +++ b/MobileAcebook/CreatePostView.swift @@ -0,0 +1,128 @@ +import SwiftUI +import PhotosUI + +struct CreatePostView: View { + @State private var userInput: String = "" + @State private var showAlert: Bool = false + @State private var alertTitle: String = "" + @State private var alertMessage: String = "" + @State private var selectedImage: UIImage? = nil + @State private var showPhotoPicker = false + @State private var isUploadingImage = false + @Environment(\.presentationMode) var presentationMode // Handle modal dismissal + + var body: some View { + VStack(alignment: .center) { + HStack { + // Cancel Button to dismiss CreatePostView + Button(action: { + self.presentationMode.wrappedValue.dismiss() // Dismiss the view + }) { + Text("Cancel") + .foregroundColor(.blue) + } + .padding(.leading, 20) + Spacer() + } + .padding(.top, 20) + + Spacer() + + Text("Make a Post") + .font(.largeTitle) + .bold() + .padding(.bottom, 20) + + // Post Text Field - Centered + TextField( + "Post text, lorem ipsum day...", + text: $userInput, + axis: .vertical + ) + .textFieldStyle(.roundedBorder) + .lineLimit(10, reservesSpace: true) + .multilineTextAlignment(.leading) + .frame(minWidth: 100, maxWidth: 400, minHeight: 100, maxHeight: 250) + .padding(.horizontal, 20) + + // Show selected image preview + if let image = selectedImage { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .cornerRadius(10) + } + + // Action Buttons - Centered + HStack(alignment: .center, spacing: 20) { + Button("Add Image") { + showPhotoPicker = true // Show the photo picker + } + .frame(width: 120, height: 44) + .background(Color.blue) + .cornerRadius(40) + .foregroundColor(.white) + + Button("Create Post") { + Task { + do { + var imageUrl: String? = nil + if let selectedImage = selectedImage { + isUploadingImage = true + // Upload the image to Cloudinary and get the image URL + imageUrl = try await PostService.uploadImageToCloudinary(image: selectedImage) + } + + // Create the post with or without an image URL + _ = try await PostService.createPost(message: userInput, image: selectedImage) + + // Show success alert + alertTitle = "Post Created" + alertMessage = "Your post has been created successfully." + showAlert = true + + } catch { + // Show error alert + alertTitle = "Error" + alertMessage = "Failed to create the post. Please try again." + showAlert = true + } + } + } + .frame(width: 120, height: 44) + .background(Color.blue) + .cornerRadius(40) + .foregroundColor(.white) + .disabled(isUploadingImage) // Disable if image is uploading + } + .padding(.top, 30) + + Spacer() + + // Alert for showing success or error message + .alert(isPresented: $showAlert) { + Alert( + title: Text(alertTitle), + message: Text(alertMessage), + dismissButton: .default(Text("OK"), action: { + if alertTitle == "Post Created" { + // Dismiss the CreatePostView modal and return to MainView + self.presentationMode.wrappedValue.dismiss() + } + }) + ) + } + } + .background(Color(red: 0, green: 0.96, blue: 1).ignoresSafeArea()) // Cover entire screen with background color + .navigationBarHidden(true) // Hide default navigation bar + .sheet(isPresented: $showPhotoPicker) { + // Use SwiftUI's photo picker + PhotoPicker(selectedImage: $selectedImage) + } + } +} + +#Preview { + CreatePostView() +} diff --git a/MobileAcebook/DateExtension.swift b/MobileAcebook/DateExtension.swift new file mode 100644 index 00000000..d06ac05e --- /dev/null +++ b/MobileAcebook/DateExtension.swift @@ -0,0 +1,16 @@ +// +// DateExtension.swift +// MobileAcebook +// +// Created by Sam Quincey on 03/09/2024. +// + +import Foundation + +extension Date { + func iso8601String() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: self) + } +} diff --git a/MobileAcebook/FeedView.swift b/MobileAcebook/FeedView.swift new file mode 100644 index 00000000..6b319a78 --- /dev/null +++ b/MobileAcebook/FeedView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FeedView: View { + @Binding var shouldRefresh: Bool // Use binding to trigger refresh + @State private var posts: [Post] = [] // To store the fetched posts + @State private var isLoading: Bool = true // To show loading state + @State private var errorMessage: String? // To handle and show errors + + var body: some View { + VStack { + if isLoading { + ProgressView("Loading posts...") // Show loading indicator + } else if let errorMessage = errorMessage { + Text("Error: \(errorMessage)") + .foregroundColor(.red) + .padding() + } else if posts.isEmpty { + Text("No posts available.") + .padding() + } else { + ScrollView { + VStack(spacing: 10) { // Add some spacing between posts + // Display the posts in reversed order (newest first) + ForEach(posts.reversed()) { post in + PostView(post: post) + .padding(.bottom, 10) + } + } + .padding(.horizontal) + .padding(.bottom, 100) // Extra padding to avoid overlap with the navigation bar + } + } + } + .onAppear { + fetchPosts() // Fetch posts when the view appears + } + .onChange(of: shouldRefresh) { newValue in + if newValue { + fetchPosts() // Refetch posts when shouldRefresh is true + shouldRefresh = false // Reset the refresh flag + } + } + .background(Color(red: 0, green: 0.48, blue: 1).opacity(0.28)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func fetchPosts() { + Task { + do { + let fetchedPosts = try await PostService.fetchPosts() + DispatchQueue.main.async { + print(fetchedPosts) // Check if posts are being received + self.posts = fetchedPosts + self.isLoading = false + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "Failed to load posts." + self.isLoading = false + print("Error fetching posts: \(error.localizedDescription)") + } + } + } + } +} + +struct PostView: View { + let post: Post + @State private var showFullPostView = false // State to control showing FullPostView + + var body: some View { + ZStack { + // The grey background placeholder or image + if let imgUrl = post.imgUrl, let url = URL(string: imgUrl) { + AsyncImage(url: url) { image in + image + .resizable() + .frame(width: 192, height: 217) // Same size as before + .cornerRadius(48) + .padding(.trailing, 140) // Image aligned to left with padding + .onTapGesture { + showFullPostView.toggle() // Show FullPostView when tapped + } + } placeholder: { + Rectangle() + .foregroundColor(Color.gray.opacity(0.3)) + .frame(width: 192, height: 217) + .cornerRadius(48) + .padding(.trailing, 140) + .onTapGesture { + showFullPostView.toggle() // Show FullPostView even if no image is present + } + } + } else { + Rectangle() + .foregroundColor(Color.gray.opacity(0.3)) + .frame(width: 192, height: 217) + .cornerRadius(48) + .padding(.trailing, 140) + .onTapGesture { + showFullPostView.toggle() // Show FullPostView if no image is present + } + } + + // Post message on the right side + Text("\(post.message)") + .font(Font.custom("SF Pro", size: 17)) + .foregroundColor(.black) + .frame(width: 135, height: 137, alignment: .topLeading) + .padding(.leading, 200) + + // Heart icon to show like status + Image(systemName: checkIfLiked(userId: post.id, post: post) ? "heart.fill" : "heart") + .resizable() + .frame(width: 35, height: 35) + .foregroundColor(checkIfLiked(userId: post.id, post: post) ? .red : .black) + .padding(.top, 200) + .padding(.leading, 200) + } + .frame(width: 393, height: 259) + .background(.white) + .cornerRadius(48) + .fullScreenCover(isPresented: $showFullPostView) { + // Show FullPostView in full screen when triggered + FullPostView(postId: post.id, token: AuthenticationService.shared.getToken() ?? "") + } + } +} + + +// Helper function to check if a post is liked +func checkIfLiked(userId: String, post: Post) -> Bool { + return post.likes.contains(userId) +} + +struct FeedView_Previews: PreviewProvider { + static var previews: some View { + FeedView(shouldRefresh: .constant(false)) + } +} diff --git a/MobileAcebook/FullPostView.swift b/MobileAcebook/FullPostView.swift new file mode 100644 index 00000000..fe861822 --- /dev/null +++ b/MobileAcebook/FullPostView.swift @@ -0,0 +1,225 @@ +import SwiftUI + +struct FullPostView: View { + @StateObject private var viewModel = FullPostViewModel() + let postId: String + let token: String + + @State private var commentText: String = "" // To store the new comment text + @State private var isAddingComment = false // To track comment submission + @State private var submissionError: Bool = false // Handle errors during comment submission + + @Environment(\.dismiss) private var dismiss // For dismissing the view + + var body: some View { + VStack { + // Dismiss button + HStack { + Button(action: { + dismiss() // Close the view when tapped + }) { + Image(systemName: "xmark.circle.fill") + .resizable() + .frame(width: 30, height: 30) + .foregroundColor(.black) + .padding(.leading, 20) + .padding(.top, 10) + } + Spacer() + } + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if viewModel.hasError { + mockPostView // If there's an error, show the mock post + } else if let post = viewModel.post { + // Display the image and message... + if let imageUrl = post.imgUrl { + AsyncImage(url: URL(string: imageUrl)) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(10) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .cornerRadius(10) + } + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .cornerRadius(10) + } + + Text(post.message) + .font(.body) + .padding(.horizontal) + + // Like Button for a real post + likeButton(isMock: false) + + Divider() + + // Comments Section + Text("Comments") + .font(.headline) + .padding(.horizontal) + + if let comments = viewModel.comments { + ForEach(comments) { comment in + VStack(alignment: .leading, spacing: 8) { + Text(comment.createdBy.username) + .font(.caption) + .foregroundColor(.gray) + Text(comment.message) + .font(.body) + Divider() + } + .padding(.horizontal) + } + } else { + Text("No comments yet.") + .padding(.horizontal) + } + } else { + // Loading state (Optional) + Text("Loading...") + .padding(.horizontal) + } + } + } + + // Add Comment Section + VStack { + HStack { + TextField("Add a comment...", text: $commentText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(height: 44) + .padding(.horizontal) + + Button(action: { + if !commentText.isEmpty { + isAddingComment = true + submissionError = false // Reset any previous submission error + CommentService.shared.createComment(message: commentText, forPostId: postId) { success, error in + if success { + // Comment added successfully, now refresh the comments + viewModel.fetchComments(postId: postId, token: token) + commentText = "" // Clear the text field + } else { + // Handle error during comment submission + submissionError = true + } + isAddingComment = false + } + } + }) { + Image(systemName: "paperplane.fill") + .resizable() + .frame(width: 44, height: 44) + .foregroundColor(.blue) + } + .disabled(isAddingComment) // Disable button when adding comment + .padding(.trailing) + } + .padding(.horizontal) + + // Show error message if comment submission fails + if submissionError { + Text("Failed to submit comment. Please try again.") + .foregroundColor(.red) + .font(.caption) + .padding(.horizontal) + } + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + .onAppear { + // Fetch the post and comments when the view appears + viewModel.fetchPost(postId: postId, token: token) + viewModel.fetchComments(postId: postId, token: token) + } + } + + // Mock Post View + private var mockPostView: some View { + VStack(alignment: .leading, spacing: 8) { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .cornerRadius(10) + .padding(.horizontal) + .padding(.top, 16) + + Text("This is a mock post. The original post could not be loaded.") + .font(.body) + .padding(.horizontal) + + // Like Button for the mock post + likeButton(isMock: true) + + Divider() + .padding(.horizontal) + + // Mock Comments Section + Text("Comments") + .font(.headline) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 8) { + Text("This is a mock comment.") + .font(.body) + Divider() + Text("This is another mock comment.") + .font(.body) + } + .padding(.horizontal) + + Spacer() + } + } + + // Like Button logic based on whether it's a mock post or real post + @ViewBuilder + private func likeButton(isMock: Bool) -> some View { + HStack { + Spacer() + Button(action: { + if isMock { + // Toggle like for mock, no server update + viewModel.isLiked.toggle() + } else { + // Toggle like for real post, send server update + viewModel.toggleLike(postId: postId, token: token) + } + }) { + HStack(alignment: .center, spacing: 3) { + Image(systemName: viewModel.isLiked ? "heart.fill" : "heart") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(viewModel.isLiked ? .red : .black) + + // Show the number of likes + Text("\(viewModel.post?.likes.count ?? 0)") + .font(.body) + .foregroundColor(viewModel.isLiked ? .red : .black) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(height: 44, alignment: .center) // Fixed height to prevent resizing + .background(Color.clear) // Transparent background + .cornerRadius(40) + .overlay( + RoundedRectangle(cornerRadius: 40) + .stroke(viewModel.isLiked ? Color.red : Color.black, lineWidth: 2) // Add border to maintain button shape + ) + } + Spacer().frame(width: 20) + } + .padding(.horizontal) + } +} + diff --git a/MobileAcebook/LoginView.swift b/MobileAcebook/LoginView.swift new file mode 100644 index 00000000..881fcdab --- /dev/null +++ b/MobileAcebook/LoginView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct LoginView: View { + @State private var email: String = "" + @State private var password: String = "" + @State private var errorMessage: String? + @State private var isLoggedIn: Bool = false + @State private var isSignUpViewPresented: Bool = false // State to trigger SignUpView presentation + + func submit() { + AuthenticationService.shared.login(email: email.lowercased(), password: password) { success, error in + if success { + DispatchQueue.main.async { + print("User logged in successfully") + isLoggedIn = true + } + } else { + DispatchQueue.main.async { + errorMessage = error + } + } + } + } + + var body: some View { + VStack { + Text("Login!") + .font(.system(size: 40, weight: .bold)) + .multilineTextAlignment(.center) + .foregroundColor(.black) + .frame(width: 288, height: 79, alignment: .center) + + VStack { + VStack { + // Email input field + TextField("Enter Email", text: $email) + .onChange(of: email) { newValue in + email = newValue.lowercased() + } + .padding(.leading, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.95)) + .font(.system(size: 17)) + + // Password input field + SecureField("Enter Password", text: $password) + .padding(.leading, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.95)) + } + .frame(width: 302, height: 180) + .cornerRadius(10) + + // Show error message if any + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + } + + // Login button + Button(action: submit) { + Text("Login!") + .font(.system(size: 20)) + .foregroundColor(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(width: 113, height: 48) + .background(Color.blue) + .cornerRadius(40) + + // Sign-up prompt + Button(action: { + isSignUpViewPresented = true + }) { + Text("Don't have an account? Sign up!") + .font(.system(size: 18)) + .multilineTextAlignment(.center) + .foregroundColor(Color.blue) + } + .padding(.top, 10) + + // Navigation to MainView after login + NavigationLink(destination: MainView(), isActive: $isLoggedIn) { + EmptyView() + } + + // Navigation to SignUpView + NavigationLink(destination: SignUpView(), isActive: $isSignUpViewPresented) { + EmptyView() + } + } + .frame(width: 335, height: 432) + .background(Color.white.opacity(0.75)) + .cornerRadius(48) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0, green: 0.96, blue: 1)) + .navigationBarBackButtonHidden(true) + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + LoginView() + } +} diff --git a/MobileAcebook/LogoutConfirmationView.swift b/MobileAcebook/LogoutConfirmationView.swift new file mode 100644 index 00000000..ed5562e4 --- /dev/null +++ b/MobileAcebook/LogoutConfirmationView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct LogoutConfirmationView: View { + @Binding var isShowing: Bool + let onLogout: () -> Void + + var body: some View { + VStack { + Text("Are you sure about logging out?") + .font(.headline) + .padding(.top, 20) + + Spacer() + + HStack { + Button(action: { + // Dismiss the pop-up + isShowing = false + }) { + Text("No") + .foregroundColor(.blue) + .padding() + } + + Spacer() + + Button(action: { + // Perform the logout action + onLogout() // Log out the user and navigate back + }) { + Text("Log me out") + .foregroundColor(.blue) + .padding() + } + } + .padding([.leading, .trailing, .bottom], 20) + } + .frame(width: 300, height: 150) + .background(Color.white.opacity(0.85)) + .cornerRadius(10) + .shadow(radius: 10) + } +} + +struct LogoutConfirmationView_Previews: PreviewProvider { + @State static var isShowing = true + static var previews: some View { + LogoutConfirmationView(isShowing: $isShowing, onLogout: { + print("Logged out") + }) + } +} diff --git a/MobileAcebook/MainView.swift b/MobileAcebook/MainView.swift new file mode 100644 index 00000000..2f5e6184 --- /dev/null +++ b/MobileAcebook/MainView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct MainView: View { + @State private var isLogoutPopupShowing = false // Control logout pop-up visibility + @State private var showCreatePostView = false // Control showing the Create Post view + @State private var navigateToWelcome = false // Handle navigation to WelcomePageView after logout + @State private var shouldRefreshFeed = false // Trigger feed refresh + + init() { + // Configure tab bar appearance + let tabBarAppearance = UITabBarAppearance() + tabBarAppearance.backgroundColor = UIColor.white + tabBarAppearance.stackedLayoutAppearance.normal.iconColor = UIColor.systemBlue + tabBarAppearance.stackedLayoutAppearance.selected.iconColor = UIColor.systemBlue + + UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance + UITabBar.appearance().standardAppearance = tabBarAppearance + } + + var body: some View { + ZStack { + // Show Feed and pass in the refresh control + FeedView(shouldRefresh: $shouldRefreshFeed) + + VStack { + Spacer() // Pushes the tab bar to the bottom + + // TabView-style bar + HStack { + Spacer() + + // Logout Button (Triggers logout popup) + Button(action: { + isLogoutPopupShowing = true + }) { + VStack { + Image(systemName: "person.slash.fill") + Text("Logout") + } + } + Spacer() + + // Create Post Button (Navigates to Create Post view using fullScreenCover) + Button(action: { + showCreatePostView = true + }) { + VStack { + Image(systemName: "plus.circle.fill") + Text("Create Post") + } + } + Spacer() + + // Refresh Button (Triggers feed refresh) + Button(action: { + print("Refreshing feed...") + shouldRefreshFeed = true // Set refresh flag to true + }) { + VStack { + Image(systemName: "arrow.clockwise") + Text("Refresh") + } + } + Spacer() + } + .padding() + .background(Color.white) + } + + // Show logout confirmation popup + if isLogoutPopupShowing { + LogoutConfirmationView(isShowing: $isLogoutPopupShowing, onLogout: { + AuthenticationService.shared.logout() // Perform logout + navigateToWelcome = true // Navigate to WelcomePageView + }) + .transition(.opacity) + .animation(.easeInOut, value: isLogoutPopupShowing) + } + } + // Present CreatePostView in a full screen mode without NavigationView + .fullScreenCover(isPresented: $showCreatePostView) { + CreatePostView() + } + // Navigate to the Welcome screen after logout + .fullScreenCover(isPresented: $navigateToWelcome) { + WelcomePageView() // Assume WelcomePageView exists + } + // Ensure the navigation bar is hidden + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) + } +} diff --git a/MobileAcebook/Models/.DS_Store b/MobileAcebook/Models/.DS_Store new file mode 100644 index 00000000..87967912 Binary files /dev/null and b/MobileAcebook/Models/.DS_Store differ diff --git a/MobileAcebook/Models/Comment.swift b/MobileAcebook/Models/Comment.swift new file mode 100644 index 00000000..d5c753c7 --- /dev/null +++ b/MobileAcebook/Models/Comment.swift @@ -0,0 +1,40 @@ +// +// Comment.swift +// MobileAcebook +// +// Created by Sam Quincey on 03/09/2024. +// + +import Foundation + +struct Comment: Codable, Identifiable { + let id: String + let message: String // The text content of the comment + let createdAt: String // The creation date of the comment + let createdBy: User // The user who created the comment + + enum CodingKeys: String, CodingKey { + case id = "_id" // Map MongoDB _id to id in Swift + case message + case createdAt + case createdBy + } + +// // Custom initializer to decode the `createdAt` field as a Date from a String +// init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// +// id = try container.decode(String.self, forKey: .id) +// message = try container.decode(String.self, forKey: .message) +// createdBy = try container.decode(User.self, forKey: .createdBy) +// +// // Decode `createdAt` as a string and convert it to a `Date` +// let createdAtString = try container.decode(String.self, forKey: .createdAt) +// let formatter = ISO8601DateFormatter() +// if let date = formatter.date(from: createdAtString) { +// createdAt = date +// } else { +// throw DecodingError.dataCorruptedError(forKey: .createdAt, in: container, debugDescription: "Date string does not match format expected by formatter.") +// } +// } +} diff --git a/MobileAcebook/Models/FullPostViewModel.swift b/MobileAcebook/Models/FullPostViewModel.swift new file mode 100644 index 00000000..66d4458c --- /dev/null +++ b/MobileAcebook/Models/FullPostViewModel.swift @@ -0,0 +1,74 @@ +import Foundation +import Combine + +class FullPostViewModel: ObservableObject { + @Published var post: Post? + @Published var comments: [Comment]? + @Published var isLiked: Bool = false + @Published var hasError: Bool = false // Add this property to track errors + + func fetchPost(postId: String, token: String) { + Task { + do { + let posts = try await PostService.fetchPosts() // Static call + if let fetchedPost = posts.first(where: { $0.id == postId }) { + DispatchQueue.main.async { + self.post = fetchedPost + self.isLiked = fetchedPost.likes.contains(token) // Check if user has already liked the post + self.hasError = false // Reset the error state + } + } else { + DispatchQueue.main.async { + self.hasError = true // Set error state if no post found + } + } + } catch { + print("Error fetching post: \(error)") + DispatchQueue.main.async { + self.hasError = true // Set error state if there's an error + } + } + } + } + + func fetchComments(postId: String, token: String) { + CommentService.shared.fetchComments(forPostId: postId) { [weak self] comments, error in + guard let self = self else { return } + + if let error = error { + DispatchQueue.main.async { + print("Error fetching comments: \(error)") + self.hasError = true + } + return + } + + DispatchQueue.main.async { + self.comments = comments ?? [] + self.hasError = false + } + } + } + + func toggleLike(postId: String, token: String) { + guard post != nil else { return } + + isLiked.toggle() // Toggle the isLiked state locally + + Task { + do { + let success = try await PostService.updateLikes(postId: postId) // Static call + if !success { + DispatchQueue.main.async { + self.isLiked.toggle() // Revert the isLiked state on failure + } + } + } catch { + print("Error updating likes: \(error)") + DispatchQueue.main.async { + self.isLiked.toggle() // Revert the isLiked state on error + } + } + } + } +} diff --git a/MobileAcebook/Models/Post.swift b/MobileAcebook/Models/Post.swift new file mode 100644 index 00000000..0a213557 --- /dev/null +++ b/MobileAcebook/Models/Post.swift @@ -0,0 +1,27 @@ +// +// Post.swift +// MobileAcebook +// +// Created by Sam Quincey on 03/09/2024. +// + +import Foundation + +struct Post: Codable, Identifiable { + let id: String + let message: String + let createdAt: String + let createdBy: User // The user data associated with the post + let imgUrl: String? + let likes: [String] + + enum CodingKeys: String, CodingKey { + case id = "_id" // Map MongoDB _id to id in Swift + case message + case createdAt + case createdBy + case imgUrl + case likes + } +} + diff --git a/MobileAcebook/Models/User.swift b/MobileAcebook/Models/User.swift index ea748dd0..4c3343c5 100644 --- a/MobileAcebook/Models/User.swift +++ b/MobileAcebook/Models/User.swift @@ -4,8 +4,18 @@ // // Created by Josué Estévez Fernández on 01/10/2023. // +import Foundation -public struct User { +struct User: Codable, Identifiable { + let id: String + let email: String let username: String - let password: String + let imgUrl: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" // Map the MongoDB _id to id in Swift + case email + case username + case imgUrl + } } diff --git a/MobileAcebook/PhotoPicker.swift b/MobileAcebook/PhotoPicker.swift new file mode 100644 index 00000000..4428b8bb --- /dev/null +++ b/MobileAcebook/PhotoPicker.swift @@ -0,0 +1,48 @@ +// +// PhotoPicker.swift +// MobileAcebook +// +// Created by Sam Quincey on 05/09/2024. +// + +import SwiftUI +import PhotosUI + +struct PhotoPicker: UIViewControllerRepresentable { + @Binding var selectedImage: UIImage? + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + var parent: PhotoPicker + + init(parent: PhotoPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + if let result = results.first { + result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self, let uiImage = image as? UIImage, error == nil else { return } + DispatchQueue.main.async { + self.parent.selectedImage = uiImage + } + } + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} +} diff --git a/MobileAcebook/PostCardView.swift b/MobileAcebook/PostCardView.swift new file mode 100644 index 00000000..f35e3267 --- /dev/null +++ b/MobileAcebook/PostCardView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct PostCardView: View { + let post: Post + let userId: String // This is the logged-in user's ID + + @State private var isLiked: Bool + @State private var likesCount: Int + + init(post: Post, userId: String) { + self.post = post + self.userId = userId + _isLiked = State(initialValue: post.likes.contains(userId)) + _likesCount = State(initialValue: post.likes.count) + } + + var body: some View { + VStack(alignment: .leading) { + // Display image (if any) + if let imgUrl = post.imgUrl, let url = URL(string: imgUrl) { + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .cornerRadius(10) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 150) + .cornerRadius(10) + } + } + + // Display message + Text(post.message) + .lineLimit(3) // Limit the message to prevent the card from being too big + .truncationMode(.tail) + .padding(.vertical, 10) + + HStack { + // Like button and count + Button(action: toggleLike) { + HStack { + Image(systemName: isLiked ? "heart.fill" : "heart") + .foregroundColor(isLiked ? .red : .black) + Text("\(likesCount)") // Display the number of likes + } + } + Spacer() + // Show when the post was created + Text(formatDate(post.createdAt)) + .font(.footnote) + .foregroundColor(.gray) + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(radius: 3) + } + + // Handle like toggling + private func toggleLike() { + Task { + do { + let success = try await PostService.updateLikes(postId: post.id) + if success { + isLiked.toggle() + likesCount += isLiked ? 1 : -1 + } + } catch { + print("Error updating likes: \(error)") + } + } + } + + // Helper function to format date string + private func formatDate(_ dateString: String) -> String { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: dateString) { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + return displayFormatter.string(from: date) + } + return dateString + } +} diff --git a/MobileAcebook/Protocols/AuthenticationServiceProtocol.swift b/MobileAcebook/Protocols/AuthenticationServiceProtocol.swift index ae012f49..dad84533 100644 --- a/MobileAcebook/Protocols/AuthenticationServiceProtocol.swift +++ b/MobileAcebook/Protocols/AuthenticationServiceProtocol.swift @@ -2,9 +2,9 @@ // AuthenticationServiceProtocol.swift // MobileAcebook // -// Created by Josué Estévez Fernández on 01/10/2023. +//// Created by Josué Estévez Fernández on 01/10/2023. +//// // - -public protocol AuthenticationServiceProtocol { - func signUp(user: User) -> Bool -} +//public protocol AuthenticationServiceProtocol { +// func signUp(user: User) -> Bool +//} diff --git a/MobileAcebook/Services/AuthenticationService.swift b/MobileAcebook/Services/AuthenticationService.swift index 9f7181c3..4cf0bc0a 100644 --- a/MobileAcebook/Services/AuthenticationService.swift +++ b/MobileAcebook/Services/AuthenticationService.swift @@ -2,12 +2,199 @@ // AuthenticationService.swift // MobileAcebook // -// Created by Josué Estévez Fernández on 01/10/2023. +// Created by Sam Quincey on 03/09/2024. // -class AuthenticationService: AuthenticationServiceProtocol { - func signUp(user: User) -> Bool { - // Logic to call the backend API for signing up - return true // placeholder +import Foundation + +class AuthenticationService { + static let shared = AuthenticationService() + + private let baseURL = "http://localhost:3000" + + private init() {} + + // "Local Storage" and Authentication frontend + private let jwtTokenKey = "jwtToken" + + // Save token in UserDefaults + func saveToken(_ token: String) { + UserDefaults.standard.set(token, forKey: jwtTokenKey) + } + + // Retrieve token from UserDefaults + func getToken() -> String? { + return UserDefaults.standard.string(forKey: jwtTokenKey) + } + + // Check if the user is logged in based on the token + func isLoggedIn() -> Bool { + return getToken() != nil + } + + // Log out the user by removing the token + func logout() { + UserDefaults.standard.removeObject(forKey: jwtTokenKey) + } + + // MARK: - JWT Decoding Helper + + // Decode the JWT token to extract payload + func decodeJWT(_ token: String) -> [String: Any]? { + let segments = token.split(separator: ".") + guard segments.count == 3 else { return nil } + + let base64String = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + guard let decodedData = Data(base64Encoded: base64String, options: .ignoreUnknownCharacters) else { + return nil + } + + return try? JSONSerialization.jsonObject(with: decodedData, options: []) as? [String: Any] + } + + // Retrieve the user ID from the JWT token payload + func getUserId() -> String? { + guard let token = getToken(), let payload = decodeJWT(token) else { return nil } + return payload["user_id"] as? String // Adjust this key based on your JWT structure + } + + // MARK: - Login + + func login(email: String, password: String, completion: @escaping (Bool, String?) -> Void) { + guard let url = URL(string: "\(baseURL)/tokens") else { + completion(false, "Invalid URL") + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "email": email, + "password": password + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + } catch { + completion(false, "Error encoding login details") + return + } + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + completion(false, "Login error: \(error.localizedDescription)") + } + return + } + + guard let data = data, let httpResponse = response as? HTTPURLResponse else { + DispatchQueue.main.async { + completion(false, "No data received") + } + return + } + + if (200...299).contains(httpResponse.statusCode) { + // Handle success response + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let token = json["token"] as? String { + self.saveToken(token) + DispatchQueue.main.async { + completion(true, nil) + } + } else { + DispatchQueue.main.async { + completion(false, "Invalid login response") + } + } + } catch { + DispatchQueue.main.async { + completion(false, "Error parsing response") + } + } + } else { + // Handle HTTP error responses (e.g. 401 Unauthorized) + DispatchQueue.main.async { + completion(false, "Login failed with status code: \(httpResponse.statusCode)") + } + } + }.resume() + } + + // MARK: - Sign Up + + func signUp(username: String, email: String, password: String, completion: @escaping (Bool, String?) -> Void) { + guard let url = URL(string: "\(baseURL)/users") else { + completion(false, "Invalid URL") + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "username": username, + "email": email, + "password": password + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + } catch { + completion(false, "Error encoding sign-up details") + return + } + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + completion(false, "Sign-up error: \(error.localizedDescription)") + } + return + } + + guard let data = data, let httpResponse = response as? HTTPURLResponse else { + DispatchQueue.main.async { + completion(false, "No data received") + } + return + } + + if (200...299).contains(httpResponse.statusCode) { + // Handle success response + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let message = json["message"] as? String, message.contains("created") { + DispatchQueue.main.async { + completion(true, nil) + } + } else { + DispatchQueue.main.async { + completion(false, "Invalid sign-up response") + } + } + } catch { + DispatchQueue.main.async { + completion(false, "Error parsing response") + } + } + } else { + // Handle HTTP error responses (e.g. 400 Bad Request) + DispatchQueue.main.async { + completion(false, "Sign-up failed with status code: \(httpResponse.statusCode)") + } + } + }.resume() } } + diff --git a/MobileAcebook/Services/CommentService.swift b/MobileAcebook/Services/CommentService.swift new file mode 100644 index 00000000..2b5ac9a2 --- /dev/null +++ b/MobileAcebook/Services/CommentService.swift @@ -0,0 +1,98 @@ +import Foundation + +class CommentService { + static let shared = CommentService() + private let baseURL = "http://localhost:3000" + + private init() {} + + // Fetch comments for a specific post + func fetchComments(forPostId postId: String, completion: @escaping ([Comment]?, Error?) -> Void) { + guard let url = URL(string: "\(baseURL)/comments/\(postId)") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + // Add token if available + if let token = AuthenticationService.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // Define the CommentResponse structure within the function + struct CommentResponse: Codable { + let message: String + let comments: [Comment] + let token: String + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + // Handle network error + if let error = error { + completion(nil, error) + return + } + + // Handle HTTP error + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode != 200 { + let statusError = NSError(domain: "HTTPError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Server returned status code \(httpResponse.statusCode)"]) + completion(nil, statusError) + return + } + } + + // Ensure there's valid data + guard let data = data else { + completion(nil, NSError(domain: "DataError", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data returned"])) + return + } + + // Try decoding the response + do { + let commentResponse = try JSONDecoder().decode(CommentResponse.self, from: data) + completion(commentResponse.comments, nil) // Pass comments array to the completion handler + } catch let jsonError { + completion(nil, jsonError) + } + } + + task.resume() + } + + + // Create a new comment for a specific post + func createComment(message: String, forPostId postId: String, completion: @escaping (Bool, Error?) -> Void) { + guard let url = URL(string: "\(baseURL)/comments/\(postId)") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = AuthenticationService.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let body: [String: Any] = [ + "message": message, + "createdAt": Date().iso8601String() + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + } catch let encodingError { + completion(false, encodingError) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(false, error) + return + } + + completion(true, nil) + } + + task.resume() + } +} diff --git a/MobileAcebook/Services/PostService.swift b/MobileAcebook/Services/PostService.swift new file mode 100644 index 00000000..21769663 --- /dev/null +++ b/MobileAcebook/Services/PostService.swift @@ -0,0 +1,178 @@ +import UIKit +import Foundation + +// PostService to handle network requests related to posts +class PostService { + static let shared = PostService() + private static let baseURL = "http://localhost:3000" + private static let CLOUDINARY_CLOUD_NAME = "dq51orqba" + private static let CLOUDINARY_UPLOAD_PRESET = "jr6ol490" + + private init() {} + + // Response struct to decode backend response that contains posts + struct PostResponse: Codable { + let posts: [Post] + let token: String? + } + + // Fetch all posts + static func fetchPosts() async throws -> [Post] { + guard let url = URL(string: "\(baseURL)/posts") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + if let token = AuthenticationService.shared.getToken() { + print("Token: \(token)") // Debug: Token output + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + if let jsonString = String(data: data, encoding: .utf8) { + print("Response JSON: \(jsonString)") // Debug: Response JSON output + } + + let decodedResponse = try JSONDecoder().decode(PostResponse.self, from: data) + return decodedResponse.posts + } else { + let errorMessage = "Failed to fetch posts: HTTP \(httpResponse.statusCode)" + print(errorMessage) + throw NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage]) + } + } else { + throw NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response from server"]) + } + } catch { + print("Error fetching posts: \(error)") // Debug: Error fetching posts + throw error + } + } + + // Create a new post with optional image + static func createPost(message: String, image: UIImage?) async throws -> Bool { + if let image = image { + print("Image selected for upload.") // Debug: Image selected + let url = try await uploadImageToCloudinary(image: image) + print("Image uploaded to Cloudinary: \(url)") // Debug: Cloudinary image URL + return try await createPostWithImage(message: message, imgUrl: url) + } else { + print("No image selected for upload.") // Debug: No image selected + return try await createPostWithImage(message: message, imgUrl: nil) + } + } + + // Helper function to create post with or without image URL + static private func createPostWithImage(message: String, imgUrl: String?) async throws -> Bool { + guard let url = URL(string: "\(baseURL)/posts") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = AuthenticationService.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let body: [String: Any] = [ + "message": message, + "imgUrl": imgUrl ?? NSNull() + ] + + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } else { + throw NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response from server"]) + } + } catch { + print("Error creating post: \(error)") + throw error + } + } + + // Upload image to Cloudinary + static internal func uploadImageToCloudinary(image: UIImage) async throws -> String { + // Directly use the constants since they are not optional + let cloudName = CLOUDINARY_CLOUD_NAME + let uploadPreset = CLOUDINARY_UPLOAD_PRESET + + let url = URL(string: "https://api.cloudinary.com/v1_1/\(cloudName)/image/upload")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let boundary = UUID().uuidString + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var data = Data() + + data.append("--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"upload_preset\"\r\n\r\n".data(using: .utf8)!) + data.append("\(uploadPreset)\r\n".data(using: .utf8)!) + + if let imageData = image.jpegData(compressionQuality: 0.7) { + print("Image data size: \(imageData.count) bytes") // Debug: Image data size + data.append("--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"file\"; filename=\"image.jpg\"\r\n".data(using: .utf8)!) + data.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!) + data.append(imageData) + data.append("\r\n".data(using: .utf8)!) + } else { + throw NSError(domain: "ImageError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert UIImage to JPEG data"]) + } + + data.append("--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = data + + do { + let (responseData, _) = try await URLSession.shared.data(for: request) + if let json = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any], + let url = json["secure_url"] as? String { + return url + } else { + throw NSError(domain: "CloudinaryError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to upload image."]) + } + } catch { + print("Error uploading image to Cloudinary: \(error)") // Debug: Error uploading image + throw error + } + } + + // Update likes for a post + static func updateLikes(postId: String) async throws -> Bool { + guard let url = URL(string: "\(baseURL)/posts/\(postId)") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token = AuthenticationService.shared.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let body: [String: Any] = ["postId": postId] + + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + + let (_, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } else { + throw NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response from server"]) + } + } +} diff --git a/MobileAcebook/Services/UserService.swift b/MobileAcebook/Services/UserService.swift new file mode 100644 index 00000000..f461c050 --- /dev/null +++ b/MobileAcebook/Services/UserService.swift @@ -0,0 +1,109 @@ +// +// UserService.swift +// MobileAcebook +// +// Created by Sam Quincey on 03/09/2024. +// + +import Foundation + +class UserService { + static let shared = UserService() + private let baseURL = "http://localhost:3000" + + private init() {} + + func createUser(email: String, password: String, username: String, completion: @escaping (User?, Error?) -> Void) { + guard let url = URL(string: "\(baseURL)/users") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = ["email": email, "password": password, "username": username] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + } catch let encodingError { + completion(nil, encodingError) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(nil, error) + return + } + + guard let data = data else { return } + + do { + let user = try JSONDecoder().decode(User.self, from: data) + completion(user, nil) + } catch let jsonError { + completion(nil, jsonError) + } + } + + task.resume() + } + + func getUserDetails(token: String, completion: @escaping (User?, Error?) -> Void) { + guard let url = URL(string: "\(baseURL)/users") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(nil, error) + return + } + + guard let data = data else { return } + + do { + let user = try JSONDecoder().decode(User.self, from: data) + completion(user, nil) + } catch let jsonError { + completion(nil, jsonError) + } + } + + task.resume() + } + + func updateProfilePicture(token: String, imgUrl: String, completion: @escaping (Bool, Error?) -> Void) { + guard let url = URL(string: "\(baseURL)/users") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = ["imgUrl": imgUrl] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: body, options: []) + request.httpBody = jsonData + } catch let encodingError { + completion(false, encodingError) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(false, error) + return + } + + completion(true, nil) + } + + task.resume() + } +} + diff --git a/MobileAcebook/SignUpView.swift b/MobileAcebook/SignUpView.swift new file mode 100644 index 00000000..bc6ebd5f --- /dev/null +++ b/MobileAcebook/SignUpView.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct SignUpView: View { + @State private var username: String = "" // Adding username field + @State private var email: String = "" + @State private var password: String = "" + @State private var errorMessage: String? + @State private var isSignUpSuccessful: Bool = false + @State private var isLoginViewPresented: Bool = false // State to trigger LoginView presentation + + // Function to handle sign-up + func submitSignUp() { + AuthenticationService.shared.signUp(username: username, email: email.lowercased(), password: password) { success, error in // Ensure email is passed in lowercase + if success { + // Navigate to MainView after successful sign-up + DispatchQueue.main.async { + print("User signed up successfully") + isSignUpSuccessful = true // This triggers the NavigationLink + } + } else { + // Show error message + DispatchQueue.main.async { + errorMessage = error + } + } + } + } + + var body: some View { + NavigationStack { + VStack { + Spacer() + + Text("Sign Up!") + .font(.system(size: 40, weight: .bold, design: .default)) + .multilineTextAlignment(.center) + .foregroundColor(.black) + .frame(width: 288, height: 79, alignment: .center) + + VStack { + VStack { + // Username input field + TextField("Enter Username", text: $username) + .padding(.leading, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.95)) + .font(Font.custom("SF Pro", size: 17)) + + // Email input field + TextField("Enter Email", text: $email) + .onChange(of: email) { newValue in + // Force the email text to lowercase + email = newValue.lowercased() + } + .padding(.leading, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.95)) + .font(Font.custom("SF Pro", size: 17)) + + // Password input field + SecureField("Enter Password", text: $password) + .padding(.leading, 16) + .padding(.vertical, 15) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.95)) + } + .padding(0) + .padding(.bottom) + .frame(width: 302, height: 240, alignment: .center) + .cornerRadius(10) + + // Show error message if any + if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + } + + // Sign Up button + HStack(alignment: .center, spacing: 3) { + Button(action: submitSignUp) { + Text("Sign Up!") + .font(Font.custom("SF Pro", size: 20)) + .foregroundColor(.white) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(width: 113, height: 48, alignment: .center) + .background(Color.blue) + .cornerRadius(40) + + // Already have an account? Log in prompt + HStack(alignment: .center, spacing: 0) { + Button(action: { + isLoginViewPresented = true // Trigger LoginView presentation + }) { + Text("Already have an account? Log in") + .font(Font.custom("SF Pro", size: 18)) + .multilineTextAlignment(.center) + .foregroundColor(Color(red: 0, green: 0.48, blue: 1)) + .frame(width: 272, height: 43, alignment: .top) + } + } + .padding(0) + .frame(width: 272, height: 43, alignment: .center) + + // NavigationLink to MainView, activated when signed up + NavigationLink(destination: MainView(), isActive: $isSignUpSuccessful) { + EmptyView() + } + + // NavigationLink back to LoginView + NavigationLink(destination: LoginView(), isActive: $isLoginViewPresented) { + EmptyView() + } + } + .frame(width: 335, height: 432) + .background(Color.white.opacity(0.75)) + .cornerRadius(48) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0, green: 0.96, blue: 1)) + .statusBar(hidden: false) + } + } +} + +struct SignUpView_Previews: PreviewProvider { + static var previews: some View { + SignUpView() + } +} diff --git a/MobileAcebook/WelcomePageView.swift b/MobileAcebook/WelcomePageView.swift index 96006af9..d75d9329 100644 --- a/MobileAcebook/WelcomePageView.swift +++ b/MobileAcebook/WelcomePageView.swift @@ -1,40 +1,65 @@ -// -// WelcomePageView.swift -// MobileAcebook -// -// Created by Josué Estévez Fernández on 30/09/2023. -// - import SwiftUI struct WelcomePageView: View { + @State private var navigateToLogin = false + @State private var navigateToSignUp = false + var body: some View { - ZStack { + NavigationStack { VStack { Spacer() - Text("Welcome to Acebook!") + Text("Acebook") .font(.largeTitle) - .padding(.bottom, 20) - .accessibilityIdentifier("welcomeText") + .fontWeight(.bold) + .foregroundColor(.black) Spacer() - Image("makers-logo") - .resizable() - .scaledToFit() - .frame(width: 200, height: 200) - .accessibilityIdentifier("makers-logo") - - Spacer() + Text("You are not logged in.\nPlease login or sign up") + .multilineTextAlignment(.center) + .padding() + .background(Color.white.opacity(0.8)) + .cornerRadius(10) + .padding(.horizontal) - Button("Sign Up") { - // TODO: sign up logic + HStack { + // Sign Up Button with NavigationLink + NavigationLink(destination: SignUpView(), isActive: $navigateToSignUp) { + Button(action: { + navigateToSignUp = true + }) { + Text("Sign Up") + .foregroundColor(.blue) + .padding() + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.8)) + .cornerRadius(10) + .padding(.horizontal, 5) + } + } + + // Login Button with NavigationLink + NavigationLink(destination: LoginView(), isActive: $navigateToLogin) { + Button(action: { + navigateToLogin = true + }) { + Text("Login") + .foregroundColor(.blue) + .padding() + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.8)) + .cornerRadius(10) + .padding(.horizontal, 5) + } + } } - .accessibilityIdentifier("signUpButton") - + .padding() + Spacer() } + .background(Color.cyan) + .navigationBarHidden(true) // Hide navigation bar for welcome screen } } } diff --git a/MobileAcebookTests/Services/MockAuthenticationService.swift b/MobileAcebookTests/Services/MockAuthenticationService.swift index 29a608e0..8d75d02d 100644 --- a/MobileAcebookTests/Services/MockAuthenticationService.swift +++ b/MobileAcebookTests/Services/MockAuthenticationService.swift @@ -1,15 +1,15 @@ // // MockAuthenticationService.swift // MobileAcebookTests +//// +//// Created by Josué Estévez Fernández on 01/10/2023. +//// // -// Created by Josué Estévez Fernández on 01/10/2023. +//@testable import MobileAcebook // - -@testable import MobileAcebook - -class MockAuthenticationService: AuthenticationServiceProtocol { - func signUp(user: User) -> Bool { - // Mocked logic for unit tests - return true // placeholder - } -} +//class MockAuthenticationService: AuthenticationServiceProtocol { +// func signUp(user: User) -> Bool { +// // Mocked logic for unit tests +// return true // placeholder +// } +//} diff --git a/README.md b/README.md index 8fc42aff..f459ff99 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,41 @@ -# SwiftUI Project - -As part of this project, you will create a new SwiftUI app that integrates with -[this Acebook backend](https://github.com/makersacademy/acebook-mobile-express-mongo-backend/tree/main). +# Mobile Acebook SwiftUI +Front-end application for Mobile Acebook project - by: +Karina, Maz, Robert, Sam and Will ## This Repo -This repo contains the seed project for your SwiftUI front-end application. +This repo contains the project for a Swift-UI frontend application consuming an Express backend that lives [here](https://github.com/QS-Coding/Mobile-Acebook) + +## Installation + 1. Clone the front-end and [back-end](https://github.com/QS-Coding/Mobile-Acebook) repos to your local machine + 2. Navigate to /api in the backend and run `npm install` + 3. Install MongoDB (ONLY IF YOU HAVEN'T ALREADY - skip this step if so, to avoid running into issues) + ``` + brew tap mongodb/brew + brew install mongodb-community@6.0 + ``` + *Note:* If you see a message that says `If you need to have + mongodb-community@5.0 first in your PATH, run:`, follow the instruction. + Restart your terminal after this. + + 4. Start MongoDB + ``` + brew services start mongodb-community@6.0 + ``` + 5. Create .env file in /api and add `MONGODB_URL` and `JWT_SECRET` environment variables + 6. Start backend server with command `npm run dev` + 7. Open frontend folder with XCode + 8. Build Swift app using XCode's top menu -> Product -> Build + 9. Run Swift app using XCode's top menu -> Product -> Run + 10. Simulator window will appear with app running + +## Usage +1. App allows user to Signup / Login +2. User navigated to Feed upon successful Login +3. User can create new posts (with an optional image) by clicking "Create Post" on navbar +4. User navigated to Feed upon successful Post creation +5. User can click on a post in Feed and is navigated to Full Post View. +6. User can read and add comments to post in Full Post View. +7. User can refresh Feed by clicking "Refresh" on navbar +8. User can logout by clicking "Logout" on navbar and confirming on alert/popup.