diff --git a/Spawn-App-iOS-SwiftUI/Services/FormatterService.swift b/Spawn-App-iOS-SwiftUI/Services/FormatterService.swift index ee08b279..9c091368 100644 --- a/Spawn-App-iOS-SwiftUI/Services/FormatterService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/FormatterService.swift @@ -108,4 +108,8 @@ class FormatterService { // If not a valid phone number format, return original (trimmed) return trimmed } + + func format(_ date: Date) -> String { + return timeAgo(from: date) + } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/UserAuthViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/UserAuthViewModel.swift index 1ecadcde..c9a2996c 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/UserAuthViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/UserAuthViewModel.swift @@ -6,14 +6,15 @@ // import AuthenticationServices +import FirebaseMessaging import GoogleSignIn import SwiftUI import UIKit -import FirebaseMessaging class UserAuthViewModel: NSObject, ObservableObject { static let shared: UserAuthViewModel = UserAuthViewModel( - apiService: MockAPIService.isMocking ? MockAPIService() : APIService()) // Singleton instance + apiService: MockAPIService.isMocking ? MockAPIService() : APIService() + ) // Singleton instance @Published var errorMessage: String? @Published var authProvider: AuthProviderType? = nil // Track the auth provider @@ -34,7 +35,7 @@ class UserAuthViewModel: NSObject, ObservableObject { @Published var profilePicUrl: String? @Published var isFormValid: Bool = false - + @Published var shouldNavigateToFeedView: Bool = false @Published var shouldNavigateToUserInfoInputView: Bool = false // New property for navigation @@ -53,23 +54,23 @@ class UserAuthViewModel: NSObject, ObservableObject { @Published var defaultPfpUrlString: String? = nil private init(apiService: IAPIService) { - self.spawnUser = nil + self.spawnUser = nil self.apiService = apiService super.init() // Call super.init() before using `self` - // Attempt quick login - Task { - print("Attempting a quick login with stored tokens") - if MockAPIService.isMocking { - await setMockUser() - } else { - await quickSignIn() - } - await MainActor.run { - self.hasCheckedSpawnUserExistence = true - } - } + // Attempt quick login + Task { + print("Attempting a quick login with stored tokens") + if MockAPIService.isMocking { + await setMockUser() + } else { + await quickSignIn() + } + await MainActor.run { + self.hasCheckedSpawnUserExistence = true + } + } } func resetState() { @@ -103,24 +104,26 @@ class UserAuthViewModel: NSObject, ObservableObject { as? ASAuthorizationAppleIDCredential { // Set user details - let userIdentifier = appleIDCredential.user - if let email = appleIDCredential.email { - self.email = email - } + let userIdentifier = appleIDCredential.user + if let email = appleIDCredential.email { + self.email = email + } if let givenName = appleIDCredential.fullName?.givenName, - let familyName = appleIDCredential.fullName?.familyName { - self.name = "\(givenName) \(familyName)" - } else if let givenName = appleIDCredential.fullName?.givenName { - self.name = givenName - } + let familyName = appleIDCredential.fullName?.familyName + { + self.name = "\(givenName) \(familyName)" + } else if let givenName = appleIDCredential.fullName?.givenName + { + self.name = givenName + } self.isLoggedIn = true self.externalUserId = userIdentifier - guard let idTokenData = appleIDCredential.identityToken else { - print("Error fetching ID Token from Apple ID Credential") - return - } - self.idToken = String(data: idTokenData, encoding: .utf8) - self.authProvider = .apple + guard let idTokenData = appleIDCredential.identityToken else { + print("Error fetching ID Token from Apple ID Credential") + return + } + self.idToken = String(data: idTokenData, encoding: .utf8) + self.authProvider = .apple // Check user existence AFTER setting credentials Task { [weak self] in @@ -155,7 +158,8 @@ class UserAuthViewModel: NSObject, ObservableObject { } _ = GIDConfiguration( - clientID: "822760465266-1dunhm4jgrcg17137rfjo2idu5qefchk.apps.googleusercontent.com" + clientID: + "822760465266-1dunhm4jgrcg17137rfjo2idu5qefchk.apps.googleusercontent.com" ) GIDSignIn.sharedInstance.signIn( @@ -167,26 +171,31 @@ class UserAuthViewModel: NSObject, ObservableObject { return } - guard let signInResult = signInResult else { return } - + guard let signInResult = signInResult else { return } + // Get ID token - signInResult.user.refreshTokensIfNeeded { [weak self] user, error in + signInResult.user.refreshTokensIfNeeded { + [weak self] user, error in guard let self = self else { return } - guard error == nil else { - print("Error refreshing token: \(error?.localizedDescription ?? "Unknown error")") - return + guard error == nil else { + print( + "Error refreshing token: \(error?.localizedDescription ?? "Unknown error")" + ) + return } guard let user = user else { return } - - // Request a higher resolution image (400px instead of 100px) - self.profilePicUrl = user.profile?.imageURL(withDimension: 400)?.absoluteString ?? "" - self.name = user.profile?.name - self.email = user.profile?.email - self.isLoggedIn = true - self.externalUserId = user.userID - self.authProvider = .google - self.idToken = user.idToken?.tokenString - + + // Request a higher resolution image (400px instead of 100px) + self.profilePicUrl = + user.profile?.imageURL(withDimension: 400)? + .absoluteString ?? "" + self.name = user.profile?.name + self.email = user.profile?.email + self.isLoggedIn = true + self.externalUserId = user.userID + self.authProvider = .google + self.idToken = user.idToken?.tokenString + Task { [weak self] in guard let self = self else { return } await self.spawnFetchUserIfAlreadyExists() @@ -219,7 +228,8 @@ class UserAuthViewModel: NSObject, ObservableObject { // Invalidate Apple ID credential state (optional but recommended) let appleIDProvider = ASAuthorizationAppleIDProvider() appleIDProvider.getCredentialState(forUserID: externalUserId) { - credentialState, error in + credentialState, + error in if let error = error { print( "Failed to get Apple ID credential state: \(error.localizedDescription)" @@ -241,7 +251,7 @@ class UserAuthViewModel: NSObject, ObservableObject { } } } - + // Unregister device token from backend Task { // Use the NotificationService to unregister the token @@ -249,7 +259,7 @@ class UserAuthViewModel: NSObject, ObservableObject { } // Clear Keychain - + var success = KeychainService.shared.delete(key: "accessToken") if !success { print("Failed to delete accessToken from Keychain") @@ -262,70 +272,81 @@ class UserAuthViewModel: NSObject, ObservableObject { resetState() } - func spawnFetchUserIfAlreadyExists() async { - - guard let unwrappedIdToken = self.idToken else { - await MainActor.run { - self.errorMessage = "ID Token is missing." - print(self.errorMessage as Any) - } - return - } - - guard let unwrappedProvider = self.authProvider else { - await MainActor.run { - print("Auth provider is missing.") - } - return - } - + func spawnFetchUserIfAlreadyExists() async { + + guard let unwrappedIdToken = self.idToken else { + await MainActor.run { + self.errorMessage = "ID Token is missing." + print(self.errorMessage as Any) + } + return + } + + guard let unwrappedProvider = self.authProvider else { + await MainActor.run { + print("Auth provider is missing.") + } + return + } + // Only proceed with API call if we have email or it's not Apple auth let emailToUse = self.email ?? "" - + if let url = URL(string: APIService.baseURL + "auth/sign-in") { - // First, try to decode as a single user object - do { - let parameters: [String: String] = ["idToken": unwrappedIdToken, "email": emailToUse, "provider": unwrappedProvider.rawValue] - - let fetchedSpawnUser: BaseUserDTO = try await self.apiService - .fetchData( - from: url, - parameters: parameters - ) - - await MainActor.run { - self.spawnUser = fetchedSpawnUser - self.shouldNavigateToUserInfoInputView = false - self.isFormValid = true - self.setShouldNavigateToFeedView() - - // Post notification that user did login successfully - NotificationCenter.default.post(name: .userDidLogin, object: nil) - } - } catch { - await MainActor.run { - self.spawnUser = nil - self.shouldNavigateToUserInfoInputView = true - print("Error fetching user data: \(error.localizedDescription)") - } + // First, try to decode as a single user object + do { + let parameters: [String: String] = [ + "idToken": unwrappedIdToken, "email": emailToUse, + "provider": unwrappedProvider.rawValue, + ] + + let fetchedSpawnUser: BaseUserDTO = try await self.apiService + .fetchData( + from: url, + parameters: parameters + ) + + await MainActor.run { + self.spawnUser = fetchedSpawnUser + self.shouldNavigateToUserInfoInputView = false + self.isFormValid = true + self.setShouldNavigateToFeedView() + + // Post notification that user did login successfully + NotificationCenter.default.post( + name: .userDidLogin, + object: nil + ) } + } catch { + await MainActor.run { + self.spawnUser = nil + self.shouldNavigateToUserInfoInputView = true + print( + "Error fetching user data: \(error.localizedDescription)" + ) + } + } await MainActor.run { self.hasCheckedSpawnUserExistence = true } } } - + // Helper method to handle API errors consistently private func handleApiError(_ error: APIError) { // For 404 errors (user doesn't exist), just direct to user info input without showing an error if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { self.spawnUser = nil self.shouldNavigateToUserInfoInputView = true - print("User does not exist yet in Spawn database - directing to user info input") + print( + "User does not exist yet in Spawn database - directing to user info input" + ) } else { self.spawnUser = nil self.shouldNavigateToUserInfoInputView = true - self.errorMessage = "Failed to fetch user: \(error.localizedDescription)" + self.errorMessage = + "Failed to fetch user: \(error.localizedDescription)" print(self.errorMessage as Any) } } @@ -341,7 +362,7 @@ class UserAuthViewModel: NSObject, ObservableObject { self.shouldNavigateToFeedView = false self.isFormValid = false } - + // Create the DTO let userDTO = UserCreateDTO( username: username, @@ -354,25 +375,30 @@ class UserAuthViewModel: NSObject, ObservableObject { // For Google, use ID token instead of external user ID if let unwrappedIdToken = idToken { parameters["idToken"] = unwrappedIdToken - } else { - print("Error: Missing Id Token") - return - } - + } else { + print("Error: Missing Id Token") + return + } + if let authProvider = self.authProvider { parameters["provider"] = authProvider.rawValue - } - + } + // If we have a profile picture URL from the provider (Google/Apple) and no selected image, // include it in the parameters so it can be used - if profilePicture == nil, let profilePicUrl = self.profilePicUrl, !profilePicUrl.isEmpty { + if profilePicture == nil, let profilePicUrl = self.profilePicUrl, + !profilePicUrl.isEmpty + { parameters["profilePicUrl"] = profilePicUrl - print("Including provider picture URL in parameters: \(profilePicUrl)") + print( + "Including provider picture URL in parameters: \(profilePicUrl)" + ) } do { // Use the new createUser method - let fetchedUser: BaseUserDTO = try await apiService + let fetchedUser: BaseUserDTO = + try await apiService .createUser( userDTO: userDTO, profilePicture: profilePicture, @@ -385,23 +411,31 @@ class UserAuthViewModel: NSObject, ObservableObject { } else { print("No profile picture set in created user") } - + // Only set user and navigate after successful account creation await MainActor.run { self.spawnUser = fetchedUser self.shouldNavigateToUserInfoInputView = false // Don't automatically set navigation flags - leave that to the view - + // Post notification that user did login - NotificationCenter.default.post(name: .userDidLogin, object: nil) + NotificationCenter.default.post( + name: .userDidLogin, + object: nil + ) } } catch let error as APIError { await MainActor.run { - if case .invalidStatusCode(let statusCode) = error, statusCode == 409 { + if case .invalidStatusCode(let statusCode) = error, + statusCode == 409 + { // Check if the error is due to email or username conflict // MySQL error 1062 for duplicate key involves username - if let errorMessage = self.errorMessage, errorMessage.contains("username") || errorMessage.contains("Duplicate") { + if let errorMessage = self.errorMessage, + errorMessage.contains("username") + || errorMessage.contains("Duplicate") + { print("Username is already taken: \(username)") self.authAlert = .usernameAlreadyInUse } else { @@ -436,18 +470,22 @@ class UserAuthViewModel: NSObject, ObservableObject { if let url = URL(string: APIService.baseURL + "users/\(userId)") { do { - await NotificationService.shared.unregisterDeviceToken() - - try await self.apiService.deleteData(from: url, parameters: nil, object: EmptyObject()) - - var success = KeychainService.shared.delete(key: "accessToken") - if !success { - print("Failed to delete accessToken from Keychain") - } - success = KeychainService.shared.delete(key: "refreshToken") - if !success { - print("Failed to delete refreshToken from Keychain") - } + await NotificationService.shared.unregisterDeviceToken() + + try await self.apiService.deleteData( + from: url, + parameters: nil, + object: EmptyObject() + ) + + var success = KeychainService.shared.delete(key: "accessToken") + if !success { + print("Failed to delete accessToken from Keychain") + } + success = KeychainService.shared.delete(key: "refreshToken") + if !success { + print("Failed to delete refreshToken from Keychain") + } await MainActor.run { activeAlert = .deleteSuccess @@ -464,7 +502,8 @@ class UserAuthViewModel: NSObject, ObservableObject { func spawnFetchDefaultProfilePic() async { if let url = URL(string: APIService.baseURL + "users/default-pfp") { do { - let fetchedDefaultPfpUrlString: String = try await self.apiService + let fetchedDefaultPfpUrlString: String = + try await self.apiService .fetchData( from: url, parameters: nil @@ -486,69 +525,99 @@ class UserAuthViewModel: NSObject, ObservableObject { print("Cannot update profile picture: No user ID found") return } - + // Convert image to data with higher quality guard let imageData = image.jpegData(compressionQuality: 0.95) else { print("Failed to convert image to JPEG data") return } - + if let user = spawnUser { - print("Starting profile picture update for user \(userId) (username: \(user.username), name: \(user.name ?? "")) with image data size: \(imageData.count) bytes") + print( + "Starting profile picture update for user \(userId) (username: \(user.username), name: \(user.name ?? "")) with image data size: \(imageData.count) bytes" + ) } else { - print("Starting profile picture update for user \(userId) with image data size: \(imageData.count) bytes") + print( + "Starting profile picture update for user \(userId) with image data size: \(imageData.count) bytes" + ) } - + // Use our new dedicated method for profile picture updates do { // Try to use the new method which has better error handling if let apiService = apiService as? APIService { - let updatedUser = try await apiService.updateProfilePicture(imageData, userId: userId) - + let updatedUser = try await apiService.updateProfilePicture( + imageData, + userId: userId + ) + await MainActor.run { self.spawnUser = updatedUser // Force a UI update self.objectWillChange.send() - print("Profile successfully updated with new picture: \(updatedUser.profilePicture ?? "nil")") + print( + "Profile successfully updated with new picture: \(updatedUser.profilePicture ?? "nil")" + ) } return } - + // Fallback to the old method if needed (only for mock implementation) - if let url = URL(string: APIService.baseURL + "users/update-pfp/\(userId)") { + if let url = URL( + string: APIService.baseURL + "users/update-pfp/\(userId)" + ) { // Create a URLRequest with PATCH method var request = URLRequest(url: url) request.httpMethod = "PATCH" - request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue( + "image/jpeg", + forHTTPHeaderField: "Content-Type" + ) request.httpBody = imageData - - print("Fallback: Sending profile picture update request to: \(url)") - + + print( + "Fallback: Sending profile picture update request to: \(url)" + ) + // Perform the request - let (data, response) = try await URLSession.shared.data(for: request) - + let (data, response) = try await URLSession.shared.data( + for: request + ) + // Check the HTTP response - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { let httpResponse = response as? HTTPURLResponse - print("Error updating profile picture: Invalid HTTP response code: \(httpResponse?.statusCode ?? 0)") + print( + "Error updating profile picture: Invalid HTTP response code: \(httpResponse?.statusCode ?? 0)" + ) return } - + // Decode the response let decoder = JSONDecoder() - if let updatedUser = try? decoder.decode(BaseUserDTO.self, from: data) { + if let updatedUser = try? decoder.decode( + BaseUserDTO.self, + from: data + ) { await MainActor.run { self.spawnUser = updatedUser self.objectWillChange.send() - print("Fallback: Profile picture updated successfully with URL: \(updatedUser.profilePicture ?? "nil")") + print( + "Fallback: Profile picture updated successfully with URL: \(updatedUser.profilePicture ?? "nil")" + ) } } else { - print("Failed to decode user data after profile picture update") + print( + "Failed to decode user data after profile picture update" + ) } } } catch { - print("Error updating profile picture: \(error.localizedDescription)") + print( + "Error updating profile picture: \(error.localizedDescription)" + ) } } @@ -557,34 +626,42 @@ class UserAuthViewModel: NSObject, ObservableObject { print("Cannot edit profile: No user ID found") return } - + // Log user details if let user = spawnUser { - print("Editing profile for user \(userId) (username: \(user.username), name: \(user.name ?? ""))") + print( + "Editing profile for user \(userId) (username: \(user.username), name: \(user.name ?? ""))" + ) } - if let url = URL(string: APIService.baseURL + "users/update/\(userId)") { + if let url = URL(string: APIService.baseURL + "users/update/\(userId)") + { do { let updateDTO = UserUpdateDTO( username: username, name: name ) - print("Updating profile with: username=\(username), name=\(name)") - - let updatedUser: BaseUserDTO = try await self.apiService.patchData( - from: url, - with: updateDTO + print( + "Updating profile with: username=\(username), name=\(name)" ) + let updatedUser: BaseUserDTO = try await self.apiService + .patchData( + from: url, + with: updateDTO + ) + await MainActor.run { // Update the current user object self.spawnUser = updatedUser - + // Ensure UI updates with the latest values self.objectWillChange.send() - - print("Profile updated successfully: \(updatedUser.username)") + + print( + "Profile updated successfully: \(updatedUser.username)" + ) } } catch { print("Error updating profile: \(error.localizedDescription)") @@ -598,22 +675,25 @@ class UserAuthViewModel: NSObject, ObservableObject { print("Cannot fetch user data: No user ID found") return } - + if let url = URL(string: APIService.baseURL + "users/\(userId)") { do { - let updatedUser: BaseUserDTO = try await self.apiService.fetchData( - from: url, - parameters: nil - ) - + let updatedUser: BaseUserDTO = try await self.apiService + .fetchData( + from: url, + parameters: nil + ) + await MainActor.run { // Update the current user object with fresh data self.spawnUser = updatedUser - + // Force UI to update self.objectWillChange.send() - - print("User data refreshed: \(updatedUser.username), \(updatedUser.name ?? "")") + + print( + "User data refreshed: \(updatedUser.username), \(updatedUser.name ?? "")" + ) } } catch { print("Error fetching user data: \(error.localizedDescription)") @@ -621,77 +701,100 @@ class UserAuthViewModel: NSObject, ObservableObject { } } - func changePassword(currentPassword: String, newPassword: String) async throws { + func changePassword(currentPassword: String, newPassword: String) + async throws + { guard let userId = spawnUser?.id else { - throw NSError(domain: "UserAuth", code: 400, userInfo: [NSLocalizedDescriptionKey: "User ID not found"]) + throw NSError( + domain: "UserAuth", + code: 400, + userInfo: [NSLocalizedDescriptionKey: "User ID not found"] + ) } - + if let url = URL(string: APIService.baseURL + "auth/change-password") { let changePasswordDTO = ChangePasswordDTO( userId: userId.uuidString, currentPassword: currentPassword, newPassword: newPassword ) - + do { - let result: Bool = ((try await self.apiService.sendData(changePasswordDTO, - to: url, - parameters: nil - )) != nil) - + let result: Bool = + ((try await self.apiService.sendData( + changePasswordDTO, + to: url, + parameters: nil + )) != nil) + if !result { - throw NSError(domain: "UserAuth", code: 400, userInfo: [NSLocalizedDescriptionKey: "Password change failed"]) + throw NSError( + domain: "UserAuth", + code: 400, + userInfo: [ + NSLocalizedDescriptionKey: "Password change failed" + ] + ) } - + print("Password changed successfully for user \(userId)") } catch { print("Error changing password: \(error.localizedDescription)") throw error } } else { - throw NSError(domain: "UserAuth", code: 400, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) + throw NSError( + domain: "UserAuth", + code: 400, + userInfo: [NSLocalizedDescriptionKey: "Invalid URL"] + ) + } + } + + // Attempts a "quick" sign-in which sends access/refresh tokens to server to verify whether a user is logged in + func quickSignIn() async { + print("Attempting quick sign-in") + do { + if let url: URL = URL( + string: APIService.baseURL + "auth/quick-sign-in" + ) { + let user: BaseUserDTO = try await self.apiService.fetchData( + from: url, + parameters: nil + ) + + print("Quick sign-in successful") + await MainActor.run { + self.spawnUser = user + self.shouldNavigateToFeedView = true + self.isLoggedIn = true + } + } + } catch { + print("Error performing quick-login. Re-login is required") + await MainActor.run { + self.isLoggedIn = false + self.spawnUser = nil + self.shouldNavigateToFeedView = false + self.shouldNavigateToUserInfoInputView = false + } } } - - // Attempts a "quick" sign-in which sends access/refresh tokens to server to verify whether a user is logged in - func quickSignIn() async { - print("Attempting quick sign-in") - do { - if let url: URL = URL(string: APIService.baseURL + "auth/quick-sign-in") { - let user: BaseUserDTO = try await self.apiService.fetchData(from: url, parameters: nil) - - print("Quick sign-in successful") - await MainActor.run { - self.spawnUser = user - self.shouldNavigateToFeedView = true - self.isLoggedIn = true - } - } - } catch { - print("Error performing quick-login. Re-login is required") - await MainActor.run { - self.isLoggedIn = false - self.spawnUser = nil - self.shouldNavigateToFeedView = false - self.shouldNavigateToUserInfoInputView = false - } - } - } - - @MainActor - func setMockUser() async { - // Set mock user details - self.name = "Daniel Agapov" - self.email = "daniel.agapov@gmail.com" - self.isLoggedIn = true - self.externalUserId = "mock_user_id" - self.authProvider = .google - self.idToken = "mock_id_token" - - // Set the mock user directly - self.spawnUser = BaseUserDTO.danielAgapov - self.hasCheckedSpawnUserExistence = true - } + + @MainActor + func setMockUser() async { + // Set mock user details + self.name = "Daniel Agapov" + self.email = "daniel.agapov@gmail.com" + self.isLoggedIn = true + self.externalUserId = "mock_user_id" + self.authProvider = .google + self.idToken = "mock_id_token" + + // Set the mock user directly + self.spawnUser = BaseUserDTO.danielAgapov + self.hasCheckedSpawnUserExistence = true + } } // Conform to ASAuthorizationControllerDelegate @@ -704,7 +807,8 @@ extension UserAuthViewModel: ASAuthorizationControllerDelegate { } func authorizationController( - controller: ASAuthorizationController, didCompleteWithError error: Error + controller: ASAuthorizationController, + didCompleteWithError error: Error ) { handleAppleSignInResult(.failure(error)) } @@ -717,4 +821,3 @@ struct ChangePasswordDTO: Codable { let currentPassword: String let newPassword: String } - diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Events/EventInfoViews/EventCardPopupView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Events/EventInfoViews/EventCardPopupView.swift index f1f78fbe..58893a14 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Events/EventInfoViews/EventCardPopupView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Events/EventInfoViews/EventCardPopupView.swift @@ -6,11 +6,27 @@ // import SwiftUI +import CoreLocation +import MapKit struct EventCardPopupView: View { var event: FullFeedEventDTO var color: Color var userId: UUID + @StateObject private var viewModel: EventDescriptionViewModel + @Environment(\.dismiss) private var dismiss + + init(event: FullFeedEventDTO, color: Color, userId: UUID) { + self.event = event + self.color = color + self.userId = userId + _viewModel = StateObject(wrappedValue: EventDescriptionViewModel( + apiService: MockAPIService.isMocking ? MockAPIService(userId: userId) : APIService(), + event: event, + users: event.participantUsers, + senderUserId: userId + )) + } var body: some View { VStack(spacing: 0) { @@ -21,14 +37,8 @@ struct EventCardPopupView: View { .frame(width: 40, height: 5) .padding(.top, 8) Spacer() - Button(action: {/* Expand/Collapse */}) { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .foregroundColor(.white.opacity(0.7)) - } - .padding(.top, 8) - Button(action: {/* Menu */}) { - Image(systemName: "ellipsis") - .rotationEffect(.degrees(90)) + Button(action: { dismiss() }) { + Image(systemName: "xmark") .foregroundColor(.white.opacity(0.7)) } .padding(.top, 8) @@ -38,10 +48,12 @@ struct EventCardPopupView: View { // Event Title & Time VStack(alignment: .leading, spacing: 4) { - Text("Title") - .font(.onestSemiBold(size: 26)) - .foregroundColor(.white) - Text("In X hours • 6 - 7:30pm") // TODO: Format time + if let title = event.title { + Text(title) + .font(.onestSemiBold(size: 26)) + .foregroundColor(.white) + } + Text(EventInfoViewModel(event: event, eventInfoType: .time).eventInfoDisplayString) .font(.onestRegular(size: 15)) .foregroundColor(.white.opacity(0.85)) } @@ -50,8 +62,12 @@ struct EventCardPopupView: View { // Action Button & Participants HStack { - Button(action: {/* Spawn In! */}) { - Text("Spawn In!") + Button(action: { + Task { + await viewModel.toggleParticipation() + } + }) { + Text(viewModel.isParticipating ? "Spawned In!" : "Spawn In!") .font(.onestSemiBold(size: 17)) .foregroundColor(color) .padding(.horizontal, 32) @@ -60,71 +76,106 @@ struct EventCardPopupView: View { .cornerRadius(22) } Spacer() - // Participants Avatars (placeholder) - HStack(spacing: -10) { - ForEach(0..<3) { i in - Circle().fill(Color.gray).frame(width: 32, height: 32) + // Participants Avatars + if let participants = event.participantUsers { + HStack(spacing: -10) { + ForEach(participants.prefix(3)) { user in + if let profilePicture = user.profilePicture { + AsyncImage(url: URL(string: profilePicture)) { image in + image + .resizable() + .scaledToFill() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } placeholder: { + Circle().fill(Color.gray).frame(width: 32, height: 32) + } + } else { + Circle().fill(Color.gray).frame(width: 32, height: 32) + } + } + if participants.count > 3 { + Circle() + .fill(Color.white.opacity(0.7)) + .frame(width: 32, height: 32) + .overlay( + Text("+\(participants.count - 3)") + .font(.onestSemiBold(size: 15)) + .foregroundColor(color) + ) + } } - Circle().fill(Color.white.opacity(0.7)).frame(width: 32, height: 32).overlay( - Text("+4").font(.onestSemiBold(size: 15)).foregroundColor(color) - ) } } .padding(.horizontal) .padding(.bottom, 8) // Map Placeholder - RoundedRectangle(cornerRadius: 14) - .fill(Color.white.opacity(0.2)) + if let location = event.location { + MapSnapshotView( + coordinate: CLLocationCoordinate2D( + latitude: location.latitude, + longitude: location.longitude + ) + ) .frame(height: 120) - .overlay(Text("[Map goes here]").foregroundColor(.white.opacity(0.7))) + .cornerRadius(14) .padding(.horizontal) .padding(.bottom, 8) + } // Location Row - HStack { - Image(systemName: "mappin.and.ellipse") - .foregroundColor(.white) - Text("7386 Name St... • 2km away") // TODO: Use real data - .foregroundColor(.white) - .font(.onestRegular(size: 13)) - Spacer() - Button(action: {/* View on Map */}) { - Text("View on Map") - .font(.onestSemiBold(size: 14)) - .foregroundColor(color) - .padding(.horizontal, 16) - .padding(.vertical, 6) - .background(Color.white) - .cornerRadius(16) + if let location = event.location { + HStack { + Image(systemName: "mappin.and.ellipse") + .foregroundColor(.white) + Text(location.name) + .foregroundColor(.white) + .font(.onestRegular(size: 13)) + Spacer() } + .padding(.horizontal) + .padding(.bottom, 8) } - .padding(.horizontal) - .padding(.bottom, 8) // Description & Comments VStack(alignment: .leading, spacing: 8) { HStack { - Circle().fill(Color.gray).frame(width: 28, height: 28) - Text("@haley_wong") // TODO: Use real username + if let profilePicture = event.creatorUser.profilePicture { + AsyncImage(url: URL(string: profilePicture)) { image in + image + .resizable() + .scaledToFill() + .frame(width: 28, height: 28) + .clipShape(Circle()) + } placeholder: { + Circle().fill(Color.gray).frame(width: 28, height: 28) + } + } else { + Circle().fill(Color.gray).frame(width: 28, height: 28) + } + Text("@\(event.creatorUser.username)") .font(.onestMedium(size: 14)) .foregroundColor(.white) } - Text("Come grab some dinner with us at Chipotle! Might go study at the library afterwards.") // TODO: Use real description - .font(.onestRegular(size: 14)) - .foregroundColor(.white.opacity(0.95)) - Button(action: {/* View all comments */}) { - Text("View all comments") - .font(.onestRegular(size: 13)) - .foregroundColor(color) + if let note = event.note { + Text(note) + .font(.onestRegular(size: 14)) + .foregroundColor(.white.opacity(0.95)) } - VStack(alignment: .leading, spacing: 4) { - Text("@daniel_lee I can come after my lecture finishes") - .font(.onestRegular(size: 13)) - .foregroundColor(.white.opacity(0.85)) - Text("@d_agapov down!") - .font(.onestRegular(size: 13)) - .foregroundColor(.white.opacity(0.85)) + if let chatMessages = event.chatMessages, !chatMessages.isEmpty { + Button(action: {/* View all comments */}) { + Text("View all comments") + .font(.onestRegular(size: 13)) + .foregroundColor(color) + } + VStack(alignment: .leading, spacing: 4) { + ForEach(chatMessages.prefix(2)) { message in + Text("@\(message.senderUser.username) \(message.content)") + .font(.onestRegular(size: 13)) + .foregroundColor(.white.opacity(0.85)) + } + } } } .padding() @@ -132,9 +183,11 @@ struct EventCardPopupView: View { .cornerRadius(14) .padding(.horizontal) .padding(.bottom, 8) - + + // TODO DANIEL: make real createdAt property on event to show here, in the back-end and in DTOs. + // Timestamp - Text("Posted 7 hours ago") // TODO: Use real timestamp + Text(FormatterService.shared.format(Date.now.advanced(by: -3600))) .font(.onestRegular(size: 13)) .foregroundColor(.white.opacity(0.7)) .padding(.horizontal) @@ -147,6 +200,78 @@ struct EventCardPopupView: View { } } +// MapSnapshotView to show a static map image +struct MapSnapshotView: View { + let coordinate: CLLocationCoordinate2D + + var body: some View { + let region = MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + + let options = MKMapSnapshotter.Options() + options.region = region + options.size = CGSize(width: UIScreen.main.bounds.width - 32, height: 120) + options.showsBuildings = true + + return MapSnapshotterView(options: options, coordinate: coordinate) + } +} + +struct MapSnapshotterView: View { + let options: MKMapSnapshotter.Options + let coordinate: CLLocationCoordinate2D + @State private var snapshot: UIImage? + + var body: some View { + Group { + if let snapshot = snapshot { + Image(uiImage: snapshot) + .resizable() + .scaledToFill() + .overlay( + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + ) + } else { + ProgressView() + } + } + .onAppear { + let snapshotter = MKMapSnapshotter(options: options) + snapshotter.start(with: .main) { snapshot, error in + if let error = error { + print("Failed to generate map snapshot: \(error)") + return + } + + guard let snapshot = snapshot else { + print("No snapshot returned") + return + } + + let image = UIGraphicsImageRenderer(size: snapshot.image.size).image { _ in + snapshot.image.draw(at: .zero) + + let pinView = UIImage(systemName: "mappin.circle.fill")? + .withTintColor(.red) + let pinPoint = snapshot.point(for: coordinate) + let pinRect = CGRect( + x: pinPoint.x - 8, + y: pinPoint.y - 8, + width: 16, + height: 16 + ) + pinView?.draw(in: pinRect) + } + self.snapshot = image + } + } + } +} + #if DEBUG struct EventCardPopupView_Previews: PreviewProvider { static var previews: some View { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/MapView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/MapView.swift index 361c47b4..83fcd973 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/MapView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/MapView.swift @@ -92,12 +92,6 @@ struct MapView: View { } } - print("🗺 DEBUG: Filtered events count: \(filtered.count)") - print("📍 DEBUG: Filtered events with locations: \(filtered.filter { $0.location != nil }.count)") - filtered.forEach { event in - print("📌 DEBUG: Filtered event '\(event.title ?? "Untitled")' location: \(event.location?.latitude ?? 0), \(event.location?.longitude ?? 0)") - } - return filtered } @@ -209,13 +203,12 @@ struct MapView: View { } .sheet(isPresented: $showingEventDescriptionPopup) { if let event = eventInPopup, let color = colorInPopup { - EventDescriptionView( + EventCardPopupView( event: event, - users: event.participantUsers, color: color, userId: user.id ) - .presentationDragIndicator(.visible) + .presentationDetents([.medium, .large]) } } @@ -232,7 +225,7 @@ struct MapView: View { if showFilterOverlay { Rectangle() .fill(.clear) - .background(.ultraThinMaterial) + .background(.ultraThinMaterial) .ignoresSafeArea() .transition(.opacity) .animation(.easeInOut, value: showFilterOverlay) @@ -395,7 +388,6 @@ struct MapView: View { } } -// MARK: - EventMapViewRepresentable struct EventMapViewRepresentable: UIViewRepresentable { @Binding var region: MKCoordinateRegion @Binding var is3DMode: Bool