From 2f0e0622c29e2d053189a4d0df8959123910fb21 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sun, 8 Feb 2026 01:00:45 -0800 Subject: [PATCH 01/53] fix api call for empty response post request --- Spawn-App-iOS-SwiftUI/Services/API/APIService.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift index ac2c3126..d92b9eb0 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift @@ -378,10 +378,12 @@ final class APIService: IAPIService, @unchecked Sendable { return nil } if !data.isEmpty { + // When expecting EmptyResponse (e.g. writeWithoutResponse), backend may return 200/201 with a body + // (e.g. POST interests returns 201 with the interest name string). Treat as success without decoding. + if U.self == EmptyResponse.self { + return EmptyResponse() as? U + } do { - // if let responseString = String(data: data, encoding: .utf8) { - // print("πŸ”„ DEBUG: Raw response data: \(responseString)") - // } let decoder = APIService.makeDecoder() let decodedData = try decoder.decode(U.self, from: data) return decodedData From 7e97d3fbd4ec7b3042896e7af1032d9cf82e84de Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sun, 8 Feb 2026 01:01:04 -0800 Subject: [PATCH 02/53] fmt --- .../Pages/Activities/ActivityCard/ActivityCardView.swift | 6 +++--- .../Pages/Profile/UserProfile/UserActivitiesSection.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift index 105dbe4a..d27f59c9 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift @@ -6,7 +6,7 @@ struct ActivityCardView: View { @ObservedObject var locationManager: LocationManager var color: Color var callback: (FullFeedActivityDTO, Color) -> Void - var horizontalPadding: CGFloat + var horizontalPadding: CGFloat @Environment(\.colorScheme) private var colorScheme // Optional binding to control tab selection for current user navigation @@ -25,7 +25,7 @@ struct ActivityCardView: View { locationManager: LocationManager, callback: @escaping (FullFeedActivityDTO, Color) -> Void, selectedTab: Binding = .constant(nil), - horizontalPadding: CGFloat = 32 + horizontalPadding: CGFloat = 32 ) { self.activity = activity self.color = color @@ -35,7 +35,7 @@ struct ActivityCardView: View { activity: activity) self.callback = callback self._selectedTab = selectedTab - self.horizontalPadding = horizontalPadding + self.horizontalPadding = horizontalPadding } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift index b544aa93..59a4022d 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift @@ -141,7 +141,7 @@ struct UserActivitiesSection: View { } else { // Vertical stack of activity cards (max 2) - per Figma design VStack(spacing: 12) { - ForEach(Array(sortedActivities.prefix(2))) { activity in + ForEach(Array(sortedActivities.prefix(2))) { activity in let fullFeedActivity = activity.toFullFeedActivityDTO() ActivityCardView( userId: UserAuthViewModel.shared.spawnUser?.id ?? UUID(), @@ -152,7 +152,7 @@ struct UserActivitiesSection: View { profileViewModel.selectedActivity = selectedActivity showActivityDetails = true }, - horizontalPadding: 0 + horizontalPadding: 0 ) } } From 1f0e231b667db39bb816b0a32c5cec9fcfa992d3 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Mon, 9 Feb 2026 21:21:18 -0800 Subject: [PATCH 03/53] perf: profileview: only call relevant api calls when editing --- .../EditProfile/EditProfileView.swift | 62 ++++++++++--------- .../Profile/MyProfile/MyProfileView.swift | 31 ++++++---- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift index c28d76b6..428199b3 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift @@ -168,44 +168,48 @@ struct EditProfileView: View { isSaving = true Task { - // Check if there's a new profile picture - _ = selectedImage != nil - - // Update profile info first - await userAuth.spawnEditProfile( - username: username, - name: name - ) - - // Force UI update by triggering objectWillChange - await MainActor.run { - userAuth.objectWillChange.send() + // Only update profile info (name/username) if it actually changed + let currentName = await MainActor.run { + userAuth.spawnUser.flatMap { FormatterService.shared.formatName(user: $0) } ?? "" + } + let currentUsername = await MainActor.run { userAuth.spawnUser?.username ?? "" } + if username != currentUsername || name != currentName { + await userAuth.spawnEditProfile( + username: username, + name: name + ) + await MainActor.run { userAuth.objectWillChange.send() } + await userAuth.fetchUserData() } - // Explicitly fetch updated user data - await userAuth.fetchUserData() - - // Format social media links properly before saving + // Format social media links for comparison and API let formattedWhatsapp = FormatterService.shared.formatWhatsAppLink(whatsappLink) let formattedInstagram = FormatterService.shared.formatInstagramLink(instagramLink) + let newWhatsapp = formattedWhatsapp.isEmpty ? nil : formattedWhatsapp + let newInstagram = formattedInstagram.isEmpty ? nil : formattedInstagram + let oldWhatsapp = profileViewModel.userSocialMedia?.whatsappNumber + let oldInstagram = profileViewModel.userSocialMedia?.instagramUsername + let socialMediaChanged = + (newWhatsapp ?? "") != (oldWhatsapp ?? "") || (newInstagram ?? "") != (oldInstagram ?? "") + + // Only PUT social media when whatsapp or instagram actually changed + if socialMediaChanged { + await profileViewModel.updateSocialMedia( + userId: userId, + whatsappLink: newWhatsapp, + instagramLink: newInstagram + ) + } - print("Saving whatsapp: \(formattedWhatsapp), instagram: \(formattedInstagram)") - - // Update social media links - await profileViewModel.updateSocialMedia( - userId: userId, - whatsappLink: formattedWhatsapp.isEmpty ? nil : formattedWhatsapp, - instagramLink: formattedInstagram.isEmpty ? nil : formattedInstagram - ) - - // Handle interest changes + // Only run interest add/remove for interests that changed (saveInterestChanges already does this) await saveInterestChanges() - // Add an explicit delay and refresh to ensure data is properly updated try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds delay - // Specifically fetch social media again to ensure it's updated - await profileViewModel.fetchUserSocialMedia(userId: userId) + // Only refetch social media if we updated it + if socialMediaChanged { + await profileViewModel.fetchUserSocialMedia(userId: userId) + } // Update profile picture if selected if let newImage = selectedImage { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift index ad7873c0..b4448892 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift @@ -383,18 +383,27 @@ struct MyProfileView: View { // Create a local copy of the selected image before starting async task let imageToUpload = selectedImage - // Update profile info first - await userAuth.spawnEditProfile( - username: username, - name: name - ) + // Only update profile info (name/username) if it actually changed + let currentName = userAuth.spawnUser?.name ?? "" + let currentUsername = userAuth.spawnUser?.username ?? "" + if username != currentUsername || name != currentName { + await userAuth.spawnEditProfile( + username: username, + name: name + ) + } - // Update social media links - await profileViewModel.updateSocialMedia( - userId: userId, - whatsappLink: whatsappLink.isEmpty ? nil : whatsappLink, - instagramLink: instagramLink.isEmpty ? nil : instagramLink - ) + // Only PUT social media when whatsapp or instagram actually changed + let currentWhatsapp = profileViewModel.userSocialMedia?.whatsappLink ?? "" + let currentInstagram = profileViewModel.userSocialMedia?.instagramLink ?? "" + let socialMediaChanged = whatsappLink != currentWhatsapp || instagramLink != currentInstagram + if socialMediaChanged { + await profileViewModel.updateSocialMedia( + userId: userId, + whatsappLink: whatsappLink.isEmpty ? nil : whatsappLink, + instagramLink: instagramLink.isEmpty ? nil : instagramLink + ) + } // Small delay before processing image update to ensure the text updates are complete try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds From 55c1f6f37254e6b9799b86de0991218b40bd09c7 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 27 Feb 2026 20:18:00 -0800 Subject: [PATCH 04/53] error handling --- .../Services/UI/ErrorFormattingService.swift | 18 +++++ .../AuthFlow/UserAuthViewModel.swift | 69 +++++++++++-------- .../Registration/VerificationCodeView.swift | 5 +- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift b/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift index 29b4021b..938a9dea 100644 --- a/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift @@ -142,7 +142,15 @@ final class ErrorFormattingService: Sendable { case "phone verification", "verification": return "We're having trouble verifying your phone number. Please check the number and try again." case "profile setup": + if isNetworkRelatedMessage(baseMessage) || isAuthRelatedMessage(baseMessage) { + return baseMessage + } return "We're having trouble saving your profile information. Please check your details and try again." + case "apple sign-in", "google sign-in", "sign in", "authentication": + if isNetworkRelatedMessage(baseMessage) { + return baseMessage + } + return "We're having trouble signing you in. Please try again." default: break } @@ -150,6 +158,16 @@ final class ErrorFormattingService: Sendable { return baseMessage } + private func isNetworkRelatedMessage(_ message: String) -> Bool { + let lowercased = message.lowercased() + return lowercased.contains("connect") || lowercased.contains("internet") || lowercased.contains("network") + } + + private func isAuthRelatedMessage(_ message: String) -> Bool { + let lowercased = message.lowercased() + return lowercased.contains("sign in") || lowercased.contains("session") || lowercased.contains("authentication") + } + /// Formats error messages with resource and operation context /// - Parameters: /// - error: The error to format diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift index 20075072..fa61a360 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift @@ -395,9 +395,11 @@ final class UserAuthViewModel: NSObject, ObservableObject { } case .failure(let error): Task { @MainActor in - self.errorMessage = - "Apple Sign-In failed: \(error.localizedDescription)" - print(self.errorMessage as Any) + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "apple sign-in") + self.errorMessage = userFriendlyMessage + self.authAlert = .unknownError(userFriendlyMessage) + print("Apple Sign-In failed: \(error.localizedDescription)") } } } @@ -445,9 +447,8 @@ final class UserAuthViewModel: NSObject, ObservableObject { let presentingViewController = windowScene.windows.first? .rootViewController else { - self.errorMessage = - "Error: Unable to get the presenting view controller." - print(self.errorMessage as Any) + self.errorMessage = "Unable to start Google Sign-In. Please try again." + print("Error: Unable to get the presenting view controller.") return } @@ -584,8 +585,8 @@ final class UserAuthViewModel: NSObject, ObservableObject { guard let unwrappedIdToken = self.idToken else { await MainActor.run { - self.errorMessage = "ID Token is missing." - print(self.errorMessage as Any) + self.errorMessage = "Authentication information is missing. Please try signing in again." + print("Error: ID Token is missing.") } return } @@ -1708,7 +1709,8 @@ final class UserAuthViewModel: NSObject, ObservableObject { guard let url = URL(string: APIService.baseURL + "auth/sign-in") else { await MainActor.run { - self.errorMessage = "Failed to create sign-in URL" + self.errorMessage = "Unable to connect to the server. Please try again." + print("Error: Failed to create sign-in URL") } return } @@ -1785,23 +1787,27 @@ final class UserAuthViewModel: NSObject, ObservableObject { } } catch let error as APIError { await MainActor.run { - // Handle specific API errors if case .invalidStatusCode(let statusCode) = error { switch statusCode { case 400: - self.errorMessage = "Invalid verification code" + self.errorMessage = "Invalid verification code. Please check the code and try again." case 404: - self.errorMessage = "Verification code not found" + self.errorMessage = "This verification code has expired. Please request a new one." + case 429: + self.errorMessage = "Too many attempts. Please wait a few minutes and try again." default: - self.errorMessage = "Failed to verify code" + self.errorMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "verification") } } else { - self.errorMessage = "Failed to verify code" + self.errorMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "verification") } } } catch { await MainActor.run { - self.errorMessage = "Failed to verify code" + self.errorMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "verification") } } } @@ -1849,27 +1855,33 @@ final class UserAuthViewModel: NSObject, ObservableObject { } catch let error as APIError { await MainActor.run { switch error { - case .failedHTTPRequest(let description): - self.errorMessage = description case .invalidStatusCode(let statusCode): if statusCode == 401 { - // Authentication failed - tokens may be invalid print("πŸ”„ Authentication failed during user details update. Attempting re-authentication...") self.handleAuthenticationFailure() + } else if statusCode == 409 { + self.errorMessage = + "This username or phone number is already in use. Please try different details." } else { - self.errorMessage = "Server error (\(statusCode))." + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "profile setup") + self.errorMessage = userFriendlyMessage } case .failedTokenSaving(let tokenType): self.errorMessage = "Authentication error. Please try signing in again." print("πŸ”„ Token saving failed for \(tokenType). Logging out user.") self.signOut() default: - self.errorMessage = error.localizedDescription + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "profile setup") + self.errorMessage = userFriendlyMessage } } } catch { await MainActor.run { - self.errorMessage = "Failed to update user details." + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "profile setup") + self.errorMessage = userFriendlyMessage } } } @@ -1952,18 +1964,15 @@ final class UserAuthViewModel: NSObject, ObservableObject { } } catch let error as APIError { await MainActor.run { - switch error { - case .failedHTTPRequest(let description): - self.errorMessage = description - case .invalidStatusCode(let statusCode): - self.errorMessage = "Server error (\(statusCode))." - default: - self.errorMessage = error.localizedDescription - } + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "profile setup") + self.errorMessage = userFriendlyMessage } } catch { await MainActor.run { - self.errorMessage = "Failed to update optional details." + let userFriendlyMessage = ErrorFormattingService.shared.formatOnboardingError( + error, context: "profile setup") + self.errorMessage = userFriendlyMessage } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift index 523d07dd..0cb42dba 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift @@ -203,10 +203,11 @@ struct VerificationCodeView: View { @ViewBuilder private var errorSection: some View { - if userAuthViewModel.errorMessage != nil { - Text("Invalid code. Try again.") + if let errorMessage = userAuthViewModel.errorMessage { + Text(errorMessage) .font(Font.custom("Onest", size: 14).weight(.medium)) .foregroundColor(Color(red: 0.92, green: 0.26, blue: 0.21)) + .multilineTextAlignment(.center) .padding(.top, 8) } } From d0e2adbdd13a00ddc8488075c98ea6245117cd37 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 27 Feb 2026 20:18:09 -0800 Subject: [PATCH 05/53] map centering on user location --- .../Views/Pages/FeedAndMap/Map/MapView.swift | 89 ++++++------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift index d115b42d..c17879da 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift @@ -32,6 +32,7 @@ struct MapView: View { @State private var filteredActivities: [FullFeedActivityDTO] = [] @State private var isMapLoaded = false @State private var hasInitialized = false + @State private var hasCenteredOnUser = false @State private var mapInitializationTask: Task? @State private var viewLifecycleState: ViewLifecycleState = .notAppeared @@ -145,6 +146,19 @@ struct MapView: View { if !hasInitialized { setInitialRegion() hasInitialized = true + } else if !hasCenteredOnUser, let userLocation = locationManager.userLocation { + // Handle case where view reappears after location became available + withAnimation { + region = MKCoordinateRegion( + center: userLocation, + span: MKCoordinateSpan( + latitudeDelta: 0.01, + longitudeDelta: 0.01 + ) + ) + } + hasCenteredOnUser = true + print("πŸ“ MapView: Centered on user location (on reappear)") } // Start location updates @@ -212,82 +226,35 @@ struct MapView: View { longitudeDelta: 0.01 ) ) + hasCenteredOnUser = true print( "πŸ“ MapView: Set initial region to user location (\(userLocation.latitude), \(userLocation.longitude))") return } - // Priority 2: Activities location - if !viewModel.activities.isEmpty { - fitRegionToActivities() - print("πŸ“ MapView: Set initial region to fit activities") - return - } - - // Priority 3: Default location (already set in @State) + // Priority 2: Default location - wait for user location rather than zooming to activities + // This prevents the zoomed-out view when activities are spread across the map print( "πŸ“ MapView: Using default region (user location not yet available, authorization: \(locationManager.authorizationStatus.rawValue))" ) } private func handleUserLocationUpdate() { - // Only auto-center if still at default location + // Auto-center on user location if we haven't done so yet guard let userLocation = locationManager.userLocation else { return } + guard !hasCenteredOnUser else { return } - let isStillAtDefault = - abs(region.center.latitude - defaultMapLatitude) < 0.001 - && abs(region.center.longitude - defaultMapLongitude) < 0.001 - - if isStillAtDefault { - withAnimation { - region = MKCoordinateRegion( - center: userLocation, - span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 - ) + withAnimation { + region = MKCoordinateRegion( + center: userLocation, + span: MKCoordinateSpan( + latitudeDelta: 0.01, + longitudeDelta: 0.01 ) - } - print("πŸ“ Auto-centered to user location") - } - } - - private func fitRegionToActivities() { - let activitiesWithLocation = viewModel.activities.filter { - $0.location != nil - } - guard !activitiesWithLocation.isEmpty else { return } - - let latitudes = activitiesWithLocation.compactMap { - $0.location?.latitude - } - let longitudes = activitiesWithLocation.compactMap { - $0.location?.longitude - } - - guard let minLat = latitudes.min(), - let maxLat = latitudes.max(), - let minLon = longitudes.min(), - let maxLon = longitudes.max() - else { - return - } - - let centerLat = (minLat + maxLat) / 2 - let centerLon = (minLon + maxLon) / 2 - let latDelta = max((maxLat - minLat) * 1.5, 0.01) - let lonDelta = max((maxLon - minLon) * 1.5, 0.01) - - region = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: centerLat, - longitude: centerLon - ), - span: MKCoordinateSpan( - latitudeDelta: latDelta, - longitudeDelta: lonDelta ) - ) + } + hasCenteredOnUser = true + print("πŸ“ Auto-centered to user location") } // MARK: - Activity Filtering From 3c4ed54d17e3d6b7bfb823f8820293bc472104a7 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 27 Feb 2026 20:20:27 -0800 Subject: [PATCH 06/53] feat (toasts): for errors and successes --- .../Activity/ActivityCreationViewModel.swift | 8 +++---- .../ActivityCardViewModel.swift | 21 ++++++++++--------- .../ActivityDescriptionViewModel.swift | 2 +- .../Activity/ActivityTypeViewModel.swift | 2 ++ .../Friends/FriendRequestViewModel.swift | 10 +++++++-- .../Friends/FriendRequestsViewModel.swift | 3 +++ .../Friends/FriendsTabViewModel.swift | 15 +++++++++++-- .../Profile/BlockedUsersViewModel.swift | 1 + .../Profile/FeedbackViewModel.swift | 1 + .../ViewModels/Profile/ProfileViewModel.swift | 8 +++++-- 10 files changed, 49 insertions(+), 22 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityCreationViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityCreationViewModel.swift index 03bc6c3a..b0555961 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityCreationViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityCreationViewModel.swift @@ -585,14 +585,12 @@ final class ActivityCreationViewModel { switch result { case .success(let response, _): - // Notify about successful creation NotificationCenter.default.post(name: .activityCreated, object: response.activity) - await setCreationMessage("Activity created successfully!") - print("πŸ” DEBUG: Activity creation successful") + notificationService.showSuccess(.activityCreated) case .failure(let error): - print("πŸ” DEBUG: Activity creation failed: \(error)") + print("Activity creation failed: \(error)") notificationService.showError(error, resource: .activity, operation: .create) await setCreationMessage("Failed to create activity. Please try again.") } @@ -628,10 +626,10 @@ final class ActivityCreationViewModel { switch result { case .success(let updatedActivity, _): await MainActor.run { - // Notify about successful update NotificationCenter.default.post(name: .activityUpdated, object: updatedActivity) creationMessage = "Activity updated successfully!" } + notificationService.showSuccess(.activityUpdated) case .failure(let error): print("Error updating activity: \(error)") diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityCardViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityCardViewModel.swift index 4da8901e..16325ef6 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityCardViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityCardViewModel.swift @@ -68,11 +68,13 @@ final class ActivityCardViewModel { .reportActivity(report: reportDTO) ) + let notificationService = InAppNotificationService.shared + switch result { case .success: - print("Activity reported successfully") + notificationService.showSuccess(.reportSubmitted) case .failure(let error): - print("Error reporting activity: \(ErrorFormattingService.shared.formatError(error))") + notificationService.showError(error, resource: .activity, operation: .report) } } @@ -94,20 +96,18 @@ final class ActivityCardViewModel { updateActivityAfterAPISuccess(updatedActivity) case .failure(let error): - // Handle specific API errors if let apiError = error as? APIError, case .invalidStatusCode(let statusCode) = apiError { if statusCode == 400 { - // Activity is full handleActivityFullError() } else { - print( - "Error toggling participation (status \(statusCode)): \(ErrorFormattingService.shared.formatAPIError(apiError))" - ) + InAppNotificationService.shared.showError( + error, resource: .activity, operation: .join) } } else { - print("Error toggling participation: \(ErrorFormattingService.shared.formatError(error))") + InAppNotificationService.shared.showError( + error, resource: .activity, operation: .join) } } } @@ -119,15 +119,16 @@ final class ActivityCardViewModel { .deleteActivity(activityId: activity.id) ) - // Handle the result switch result { case .success: - // Post notification for activity deletion NotificationCenter.default.post( name: .activityDeleted, object: activity.id ) + InAppNotificationService.shared.showSuccess(.activityDeleted) case .failure(let error): + InAppNotificationService.shared.showError( + error, resource: .activity, operation: .delete) throw error } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityDescriptionViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityDescriptionViewModel.swift index 1224ffdf..dc4021a1 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityDescriptionViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityListing/ActivityDescriptionViewModel.swift @@ -231,7 +231,7 @@ final class ActivityDescriptionViewModel { switch result { case .success: - print("Activity reported successfully") + notificationService.showSuccess(.reportSubmitted) case .failure(let error): errorMessage = notificationService.handleError( diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift index 610bff72..8c61afd0 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift @@ -195,6 +195,7 @@ final class ActivityTypeViewModel { switch result { case .success(let updatedActivityTypes, _): updateStateAfterAPISuccess(updatedActivityTypes) + notificationService.showSuccess(.activityTypeDeleted) case .failure(let error): print("❌ Error deleting activity type: \(error)") @@ -221,6 +222,7 @@ final class ActivityTypeViewModel { switch result { case .success(let updatedActivityTypes, _): updateStateAfterAPISuccess(updatedActivityTypes) + notificationService.showSuccess(.activityTypeCreated) case .failure(let error): print("❌ Error creating activity type: \(error)") diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestViewModel.swift index c8f00e8e..111c8049 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestViewModel.swift @@ -45,6 +45,8 @@ final class FriendRequestViewModel { result = await dataService.writeWithoutResponse(operation) } + let notificationService = InAppNotificationService.shared + // Handle the result switch result { case .success: @@ -60,12 +62,16 @@ final class FriendRequestViewModel { // Notify other views to refresh NotificationCenter.default.post(name: .friendsDidChange, object: nil) + notificationService.showSuccess(.friendRequestAccepted) + } else if action == .decline { + notificationService.showSuccess(.friendRequestDeclined) } NotificationCenter.default.post(name: .friendRequestsDidChange, object: nil) case .failure(let error): - creationMessage = - "There was an error \(action == .accept ? "accepting" : action == .cancel ? "canceling" : "declining") the friend request. Please try again" + let operation: OperationContext = action == .accept ? .accept : action == .cancel ? .cancel : .reject + creationMessage = notificationService.handleError( + error, resource: .friendRequest, operation: operation) print("Error processing friend request: \(error)") } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestsViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestsViewModel.swift index 357e9759..2892d4d9 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestsViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendRequestsViewModel.swift @@ -130,6 +130,9 @@ final class FriendRequestsViewModel { let _: DataResult<[FetchFriendRequestDTO]> = await dataService.read( .friendRequests(userId: userId), cachePolicy: .apiOnly) NotificationCenter.default.post(name: .friendsDidChange, object: nil) + notificationService.showSuccess(.friendRequestAccepted) + } else if action == .decline { + notificationService.showSuccess(.friendRequestDeclined) } case .failure(let error): diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendsTabViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendsTabViewModel.swift index d1d01449..68aaa7e9 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendsTabViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Friends/FriendsTabViewModel.swift @@ -681,8 +681,15 @@ final class FriendsTabViewModel { let _: DataResult<[FullFriendUserDTO]> = await dataService.read( .friends(userId: userId), cachePolicy: .apiOnly) + await MainActor.run { + InAppNotificationService.shared.showSuccess(.friendRemoved) + } + case .failure(let error): - print("Error removing friend: \(ErrorFormattingService.shared.formatError(error))") + await MainActor.run { + InAppNotificationService.shared.showError( + error, resource: .friend, operation: .remove) + } } await MainActor.run { @@ -839,10 +846,14 @@ final class FriendsTabViewModel { await MainActor.run { self.friends.removeAll { $0.id == blockedId } self.filteredFriends.removeAll { $0.id == blockedId } + InAppNotificationService.shared.showSuccess(.userBlocked) } case .failure(let error): - print("Failed to block user: \(ErrorFormattingService.shared.formatError(error))") + await MainActor.run { + InAppNotificationService.shared.showError( + error, resource: .user, operation: .block) + } } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/BlockedUsersViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/BlockedUsersViewModel.swift index b82b7990..a8c8cb67 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/BlockedUsersViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/BlockedUsersViewModel.swift @@ -47,6 +47,7 @@ final class BlockedUsersViewModel { print("βœ… [BlockedUsersViewModel] Removed user from local list, remaining: \(blockedUsers.count)") errorMessage = nil + notificationService.showSuccess(.userUnblocked) } catch let error as APIError { print("❌ [BlockedUsersViewModel] APIError: \(error)") errorMessage = notificationService.handleError( diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/FeedbackViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/FeedbackViewModel.swift index a081d49b..9dd5aa52 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/FeedbackViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/FeedbackViewModel.swift @@ -59,6 +59,7 @@ final class FeedbackViewModel { case .success: isSubmitting = false successMessage = "Thank you for your feedback!" + notificationService.showSuccess(.feedbackSent) case .failure(let error): let formattedError = notificationService.handleError( diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift index 7b617ac1..51dd032d 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift @@ -826,6 +826,7 @@ final class ProfileViewModel { .friendRequests(userId: userId), cachePolicy: .apiOnly) } NotificationCenter.default.post(name: .friendsDidChange, object: nil) + notificationService.showSuccess(.friendRequestAccepted) case .failure(let error): self.errorMessage = notificationService.handleError( @@ -849,8 +850,7 @@ final class ProfileViewModel { switch result { case .success: - // Successfully declined - break + notificationService.showSuccess(.friendRequestDeclined) case .failure(let error): self.errorMessage = notificationService.handleError( @@ -932,6 +932,7 @@ final class ProfileViewModel { // Refresh friends list let _: DataResult<[FullFriendUserDTO]> = await dataService.read( .friends(userId: currentUserId), cachePolicy: .apiOnly) + notificationService.showSuccess(.friendRemoved) case .failure(let error): self.errorMessage = notificationService.handleError( @@ -956,6 +957,7 @@ final class ProfileViewModel { switch result { case .success: self.errorMessage = nil + notificationService.showSuccess(.userReported) case .failure(let error): self.errorMessage = notificationService.handleError( error, resource: .user, operation: .report) @@ -991,6 +993,7 @@ final class ProfileViewModel { let _: DataResult<[FullFriendUserDTO]> = await dataService.read( .friends(userId: userId), cachePolicy: .apiOnly) } + notificationService.showSuccess(.userBlocked) case .failure(let error): self.errorMessage = notificationService.handleError( @@ -1014,6 +1017,7 @@ final class ProfileViewModel { let _: DataResult<[FullFriendUserDTO]> = await dataService.read( .friends(userId: userId), cachePolicy: .apiOnly) } + notificationService.showSuccess(.userUnblocked) case .failure(let error): self.errorMessage = notificationService.handleError( From b0870f041f0f55b6eef848795347813537f8eb39 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 03:22:38 -0800 Subject: [PATCH 07/53] Fix map view zoom issue --- .../Views/Pages/FeedAndMap/Map/MapView.swift | 58 ++++++++++++++----- .../Views/Shared/Map/UnifiedMapView.swift | 27 +++++++-- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift index c17879da..8ece9f33 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/Map/MapView.swift @@ -126,6 +126,13 @@ struct MapView: View { } } + // MARK: - Constants + + /// Maximum reasonable span for the map (anything larger is likely a bug) + private static let maxReasonableSpan: Double = 0.5 + /// Default span for user-centered view + private static let defaultSpan: Double = 0.01 + // MARK: - Lifecycle Methods private func handleViewAppeared() { @@ -146,19 +153,40 @@ struct MapView: View { if !hasInitialized { setInitialRegion() hasInitialized = true - } else if !hasCenteredOnUser, let userLocation = locationManager.userLocation { - // Handle case where view reappears after location became available - withAnimation { - region = MKCoordinateRegion( - center: userLocation, - span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 - ) + } else { + // On subsequent appearances, check if region is unreasonably zoomed out + // This can happen due to MKMapView state issues during tab switching + let isZoomedOutTooFar = + region.span.latitudeDelta > Self.maxReasonableSpan + || region.span.longitudeDelta > Self.maxReasonableSpan + + if isZoomedOutTooFar, let userLocation = locationManager.userLocation { + print( + "⚠️ MapView: Detected unreasonable zoom level (span: \(region.span.latitudeDelta)), re-centering on user" ) + withAnimation { + region = MKCoordinateRegion( + center: userLocation, + span: MKCoordinateSpan( + latitudeDelta: Self.defaultSpan, + longitudeDelta: Self.defaultSpan + ) + ) + } + } else if !hasCenteredOnUser, let userLocation = locationManager.userLocation { + // Handle case where view reappears after location became available + withAnimation { + region = MKCoordinateRegion( + center: userLocation, + span: MKCoordinateSpan( + latitudeDelta: Self.defaultSpan, + longitudeDelta: Self.defaultSpan + ) + ) + } + hasCenteredOnUser = true + print("πŸ“ MapView: Centered on user location (on reappear)") } - hasCenteredOnUser = true - print("πŸ“ MapView: Centered on user location (on reappear)") } // Start location updates @@ -222,8 +250,8 @@ struct MapView: View { region = MKCoordinateRegion( center: userLocation, span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 + latitudeDelta: Self.defaultSpan, + longitudeDelta: Self.defaultSpan ) ) hasCenteredOnUser = true @@ -248,8 +276,8 @@ struct MapView: View { region = MKCoordinateRegion( center: userLocation, span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 + latitudeDelta: Self.defaultSpan, + longitudeDelta: Self.defaultSpan ) ) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/Map/UnifiedMapView.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/Map/UnifiedMapView.swift index bed3eabe..645b375e 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/Map/UnifiedMapView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/Map/UnifiedMapView.swift @@ -61,11 +61,12 @@ struct UnifiedMapView: UIViewRepresentable { mapView.preferredConfiguration = configuration // Set initial region + let regionToSet: MKCoordinateRegion if isValidRegion(region) { - mapView.setRegion(region, animated: false) + regionToSet = region } else { // Fallback to Vancouver if region is invalid - let fallbackRegion = MKCoordinateRegion( + regionToSet = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: 49.2827, longitude: -123.1207 @@ -75,27 +76,41 @@ struct UnifiedMapView: UIViewRepresentable { longitudeDelta: 0.01 ) ) - mapView.setRegion(fallbackRegion, animated: false) } + mapView.setRegion(regionToSet, animated: false) + context.coordinator.lastSetRegion = regionToSet // Force initial render and tile loading DispatchQueue.main.async { [weak mapView] in guard let mapView = mapView else { return } mapView.layoutIfNeeded() - // Force a small region change to trigger tile loading - let currentRegion = mapView.region - mapView.setRegion(currentRegion, animated: false) } return mapView } + /// Maximum reasonable span - anything larger suggests a bug/reset + private static let maxReasonableSpan: Double = 0.5 + func updateUIView(_ mapView: MKMapView, context: Context) { // Update parent on main thread only DispatchQueue.main.async { context.coordinator.parent = self } + // CRITICAL: Detect if MKMapView has zoomed out to an unreasonable level + // This can happen during tab switching or system memory pressure + let currentMapSpan = mapView.region.span.latitudeDelta + let isMapZoomedOutTooFar = currentMapSpan > Self.maxReasonableSpan + + if isMapZoomedOutTooFar && isValidRegion(region) && region.span.latitudeDelta <= Self.maxReasonableSpan { + // Force reset to the binding's region + print("⚠️ UnifiedMapView: Detected MKMapView zoom anomaly (span: \(currentMapSpan)), forcing reset") + mapView.setRegion(region, animated: false) + context.coordinator.lastSetRegion = region + return + } + // Update region if significantly changed and valid if shouldUpdateRegion(mapView: mapView, context: context) { mapView.setRegion(region, animated: true) From 09c55c1d3b82ca8d72b7a73224d6792dc8732180 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 03:50:06 -0800 Subject: [PATCH 08/53] dark mode friends page text styling --- .../Views/Helpers/Constants.swift | 4 +++ .../Components/ProfileInterestsView.swift | 29 +++++++++++---- .../Shared/Components/ProfileNameView.swift | 36 +++++++++++-------- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift index 99d168af..f62bb863 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift @@ -321,6 +321,10 @@ let figmaBittersweetOrange: Color = Color(hex: figmaOrangeHex) let figmaGreyHex: String = colorsGray50 let figmaGrey: Color = Color(hex: figmaGreyHex) +// Interests + Hobbies section container (Figma: dark ~#282828, light subtle gray) +let colorsInterestsSectionDark: String = "#282828" +let colorsInterestsSectionLight: String = "#F5F3F3" + let figmaLightGreyHex: String = colorsGray100 let figmaLightGrey: Color = Color(hex: figmaLightGreyHex) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileInterestsView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileInterestsView.swift index bc1c3af3..630b4661 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileInterestsView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileInterestsView.swift @@ -16,6 +16,21 @@ struct ProfileInterestsView: View { var openSocialMediaLink: (String, String) -> Void var removeInterest: (String) -> Void + @Environment(\.colorScheme) private var colorScheme + + // Figma: Interests container dark ~#282828, light subtle gray + private var interestsSectionBackground: Color { + colorScheme == .dark + ? Color(hex: colorsInterestsSectionDark) + : Color(hex: colorsInterestsSectionLight) + } + + // Primary text on orange pill (white in dark, dark in light) + private var interestsPillTextColor: Color { universalAccentColor } + + // Muted text for empty state (Figma: secondary/muted) + private var emptyStateTextColor: Color { universalPlaceHolderTextColor } + // Check if this is the current user's profile var isCurrentUserProfile: Bool { if MockAPIService.isMocking { @@ -50,14 +65,14 @@ struct ProfileInterestsView: View { .background( RoundedRectangle(cornerRadius: 15) .stroke(figmaBittersweetOrange, lineWidth: 1) - .background(universalBackgroundColor.opacity(0.5).cornerRadius(15)) + .background(interestsSectionBackground.cornerRadius(15)) ) .overlay(alignment: .topLeading) { - // Header positioned on the border + // Header positioned on the border (Figma: primary text on orange pill) HStack { Text("Interests + Hobbies") .font(.onestBold(size: 14)) - .foregroundColor(.black) + .foregroundColor(interestsPillTextColor) .padding(.vertical, 8) .padding(.horizontal, 12) .background(figmaBittersweetOrange) @@ -135,14 +150,14 @@ struct ProfileInterestsView: View { .background( RoundedRectangle(cornerRadius: 15) .stroke(figmaBittersweetOrange, lineWidth: 1) - .background(universalBackgroundColor.opacity(0.5).cornerRadius(15)) + .background(interestsSectionBackground.cornerRadius(15)) ) .overlay(alignment: .topLeading) { - // Header positioned on the border + // Header positioned on the border (Figma: primary text on orange pill) HStack { Text("Interests + Hobbies") .font(.onestBold(size: 14)) - .foregroundColor(Color(hex: colorsGray900)) + .foregroundColor(interestsPillTextColor) .padding(.vertical, 8) .padding(.horizontal, 12) .background(figmaBittersweetOrange) @@ -163,7 +178,7 @@ struct ProfileInterestsView: View { private var emptyInterestsView: some View { Text("No interests added yet.") .frame(maxWidth: .infinity) - .foregroundColor(.secondary) + .foregroundColor(emptyStateTextColor) .italic() .font(.onestRegular(size: 14)) .padding(.horizontal, 16) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileNameView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileNameView.swift index 30b694ce..ead55a49 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileNameView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Components/ProfileNameView.swift @@ -12,6 +12,20 @@ struct ProfileNameView: View { @ObservedObject var userAuth = UserAuthViewModel.shared @Binding var refreshFlag: Bool + // Figma: secondary text (e.g. @handle) β€” light gray in dark, gray700 in light + private var usernameColor: Color { + Color( + UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return UIColor(Color(hex: colorsGray300)) + default: + return UIColor(Color(hex: colorsGray700)) + } + } + ) + } + // Check if this is the current user's profile var isCurrentUserProfile: Bool { if MockAPIService.isMocking { @@ -22,8 +36,7 @@ struct ProfileNameView: View { } var body: some View { - // Name and Username - make this more reactive to changes - Group { + VStack(spacing: 4) { if isCurrentUserProfile, let currentUser = userAuth.spawnUser { @@ -32,30 +45,25 @@ struct ProfileNameView: View { user: currentUser ) ) - .font(.title3) - .bold() + .font(.onestBold(size: 24)) .foregroundColor(universalAccentColor) Text("@\(currentUser.username ?? "username")") - .font(.subheadline) - .foregroundColor(Color.gray) - .padding(.bottom, 5) + .font(.onestRegular(size: 16)) + .foregroundColor(usernameColor) } else { - // For other users, use the passed-in user Text( FormatterService.shared.formatName( user: user ) ) - .font(.title3) - .bold() + .font(.onestBold(size: 24)) .foregroundColor(universalAccentColor) Text("@\(user.username ?? "username")") - .font(.subheadline) - .foregroundColor(Color.gray) - .padding(.bottom, 5) + .font(.onestRegular(size: 16)) + .foregroundColor(usernameColor) } } - .id(refreshFlag) // Force refresh when flag changes + .id(refreshFlag) } } From a032c480aaf8debdee0015edf62cd750d9f0f082 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 03:50:22 -0800 Subject: [PATCH 09/53] add to activity type view styling fixed --- .../Components/AddToActivityTypeView.swift | 159 +++++------------- 1 file changed, 43 insertions(+), 116 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift index fdefb1bd..6b254553 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift @@ -14,13 +14,37 @@ struct AddToActivityTypeView: View { .ignoresSafeArea() VStack(spacing: 0) { + // Custom header + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(universalAccentColor) + } + + Spacer() + + Text("Add to Activity Type") + .font(.onestMedium(size: 20)) + .foregroundColor(universalAccentColor) + + Spacer() + + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.clear) + } + .padding(.horizontal, 24) + .padding(.top, 8) + .padding(.bottom, 8) + // Main content ScrollView { VStack(spacing: 24) { - // Profile section with glow effect profileSection - // Activity type grid or loading state if viewModel.isLoading { ProgressView("Loading activity types...") .font(.onestRegular(size: 16)) @@ -30,7 +54,6 @@ struct AddToActivityTypeView: View { activityTypeGrid } - // Error message display if let errorMessage = viewModel.errorMessage { Text(errorMessage) .font(.onestRegular(size: 14)) @@ -39,7 +62,6 @@ struct AddToActivityTypeView: View { .padding(.horizontal) } - // Spacer to push save button to bottom Spacer(minLength: 100) } .padding(.horizontal, 16) @@ -49,29 +71,11 @@ struct AddToActivityTypeView: View { // Save button at bottom saveButton .padding(.horizontal, 16) - .padding(.bottom, 34) // Account for tab bar + .padding(.bottom, 100) } } } - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(universalAccentColor) - } - } - - ToolbarItem(placement: .principal) { - Text("Add to Activity Type") - .font(.onestMedium(size: 20)) - .foregroundColor(universalAccentColor) - } - } + .navigationBarHidden(true) .task { await viewModel.loadActivityTypes() } @@ -162,14 +166,22 @@ struct AddToActivityTypeView: View { } private var activityTypeGrid: some View { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 3), spacing: 16) { + LazyVGrid( + columns: [ + GridItem(.fixed(116), spacing: 8), + GridItem(.fixed(116), spacing: 8), + GridItem(.fixed(116), spacing: 8), + ], + spacing: 10 + ) { ForEach(viewModel.activityTypes, id: \.id) { activityType in - ActivityTypeSelectionCard( - activityType: activityType, - isSelected: selectedActivityTypes.contains(activityType.id) - ) { - toggleSelection(for: activityType) - } + ActivityTypeCard( + activityTypeDTO: activityType, + isSelected: selectedActivityTypes.contains(activityType.id), + onTap: { + toggleSelection(for: activityType) + } + ) } } } @@ -214,91 +226,6 @@ struct AddToActivityTypeView: View { } } -struct ActivityTypeSelectionCard: View { - let activityType: ActivityTypeDTO - let isSelected: Bool - let onTap: () -> Void - @Environment(\.colorScheme) var colorScheme - - // Dynamic colors based on selection state - private var iconColor: Color { - if isSelected { - return colorScheme == .dark ? .white : Color(red: 0.07, green: 0.07, blue: 0.07) - } else { - return colorScheme == .dark - ? Color.white.opacity(0.5) : Color(red: 0.07, green: 0.07, blue: 0.07).opacity(0.4) - } - } - - private var titleColor: Color { - if isSelected { - return colorScheme == .dark ? .white : Color(red: 0.07, green: 0.07, blue: 0.07) - } else { - return colorScheme == .dark - ? Color.white.opacity(0.5) : Color(red: 0.07, green: 0.07, blue: 0.07).opacity(0.4) - } - } - - private var peopleCountColor: Color { - if isSelected { - return Color(red: 0.52, green: 0.49, blue: 0.49) - } else { - return Color(red: 0.52, green: 0.49, blue: 0.49).opacity(0.4) - } - } - - private var backgroundColor: Color { - if isSelected { - return colorScheme == .dark - ? Color(red: 0.24, green: 0.23, blue: 0.23) : Color(red: 0.95, green: 0.93, blue: 0.93) - } else { - return colorScheme == .dark - ? Color(red: 0.24, green: 0.23, blue: 0.23).opacity(0.5) - : Color(red: 0.95, green: 0.93, blue: 0.93).opacity(0.5) - } - } - - var body: some View { - Button(action: onTap) { - VStack(spacing: 4) { - // Icon - Text(activityType.icon) - .font(.onestBold(size: 34)) - .foregroundColor(iconColor) - - VStack(spacing: 2) { - // Title - Text(activityType.title) - .font(.onestSemiBold(size: 16)) - .foregroundColor(titleColor) - - // People count - Text("\(activityType.associatedFriends.count) people") - .font(.onestRegular(size: 13)) - .foregroundColor(peopleCountColor) - } - } - .padding(16) - .frame(width: 111, height: 111) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(backgroundColor) - ) - .shadow( - color: isSelected - ? (colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.1)) : Color.clear, - radius: isSelected ? 4 : 0, - x: 0, - y: isSelected ? 2 : 0 - ) - .scaleEffect(isSelected ? 1.02 : 1.0) - .opacity(isSelected ? 1.0 : 0.6) - } - .buttonStyle(PlainButtonStyle()) - .animation(.easeInOut(duration: 0.2), value: isSelected) - } -} - // ViewModel for managing activity types @MainActor final class AddToActivityTypeViewModel: ObservableObject { From d2eef92f1774f9cb242d94dd49f9196dbe24b315 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 03:54:57 -0800 Subject: [PATCH 10/53] proper add to see spawns in unadded friend page --- .../AddToSeeStarsIcon.imageset/Contents.json | 23 +++ .../stars-01 (1).png | Bin 0 -> 893 bytes .../stars-01 (2).png | Bin 0 -> 1533 bytes .../stars-01 (3).png | Bin 0 -> 2106 bytes .../Views/Helpers/Constants.swift | 17 ++ .../UserProfile/UserActivitiesSection.swift | 194 +++++------------- 6 files changed, 92 insertions(+), 142 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/Contents.json create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/stars-01 (1).png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/stars-01 (2).png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/stars-01 (3).png diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/Contents.json b/Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/Contents.json new file mode 100644 index 00000000..2d5ac510 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stars-01 (1).png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stars-01 (2).png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stars-01 (3).png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/stars-01 (1).png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/AddToSeeStarsIcon.imageset/stars-01 (1).png new file mode 100644 index 0000000000000000000000000000000000000000..0dc85238f3ace12a1ad89d1b961ecdecf2fae336 GIT binary patch literal 893 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 literal 0 HcmV?d00001 diff --git a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift index f62bb863..24c43201 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift @@ -286,6 +286,20 @@ var universalPlaceHolderTextColor: Color { }) } +/// Figma --text/secondary: outline button text and border (Cancel, Add +). Light: #262424, Dark: lighter gray for contrast. +@available(iOS 14.0, *) +var universalSecondaryTextColor: Color { + Color( + UIColor { traitCollection in + switch traitCollection.userInterfaceStyle { + case .dark: + return UIColor(Color(hex: colorsGray300)) + default: + return UIColor(Color(hex: colorsGray700)) + } + }) +} + // MARK: - Static Colors (Theme-independent) - Updated to use new design system let universalSecondaryColorHexCode: String = colorsIndigo500 let universalSecondaryColor: Color = Color(hex: universalSecondaryColorHexCode) @@ -310,6 +324,9 @@ let figmaGray700: Color = Color(hex: figmaGray700Hex) let figmaBlack300Hex: String = colorsGray400 let figmaBlack300: Color = Color(hex: figmaBlack300Hex) +// Figma "Add to see" section: border and text (--black-300 #8e8484) +let colorsAddToSeeMutedHex: String = "#8e8484" + let figmaGreen: Color = Color(hex: colorsGreen500) let figmaBlack400Hex: String = colorsGray400 diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift index 59a4022d..1c76750d 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift @@ -6,8 +6,8 @@ struct UserActivitiesSection: View { @ObservedObject private var locationManager = LocationManager.shared @Binding var showActivityDetails: Bool @State private var showFriendActivities: Bool = false - - @Environment(\.colorScheme) private var colorScheme + @State private var showDayActivitiesFromFriend: Bool = false + @State private var selectedDayActivities: [CalendarActivityDTO] = [] // Adaptive colors for dark mode support private var secondaryTextColor: Color { @@ -46,28 +46,54 @@ struct UserActivitiesSection: View { }) } - // Theme-aware empty day cell color - private var emptyDayCellColor: Color { - colorScheme == .dark ? Color(hex: colorsGray700) : Color(hex: colorsGray200) - } + // Figma "Add to see" section: 1px dashed border and text (#8e8484) + private var addToSeeMutedColor: Color { Color(hex: colorsAddToSeeMutedHex) } var body: some View { VStack(alignment: .leading, spacing: 32) { - // Only show activities section if they are friends if profileViewModel.friendshipStatus == .friends { friendActivitiesSection - calendarSection } addToSeeActivitiesSection } .navigationDestination(isPresented: $showFriendActivities) { - FriendActivitiesShowAllView( - user: user, + ActivityCalendarView( profileViewModel: profileViewModel, - showActivityDetails: $showActivityDetails + userCreationDate: profileViewModel.userProfileInfo?.dateCreated, + calendarOwnerName: FormatterService.shared.formatFirstName(user: user), + onDismiss: { showFriendActivities = false }, + onActivitySelected: { handleFriendActivitySelection($0) }, + onDayActivitiesSelected: { activities in + selectedDayActivities = activities + showDayActivitiesFromFriend = true + } ) } + .navigationDestination(isPresented: $showDayActivitiesFromFriend) { + DayActivitiesPageView( + date: selectedDayActivities.first?.dateAsDate ?? Date(), + initialActivities: selectedDayActivities, + onDismiss: { showDayActivitiesFromFriend = false }, + onActivitySelected: { activity in + showDayActivitiesFromFriend = false + handleFriendActivitySelection(activity) + } + ) + } + } + + /// Fetches full activity details and shows the global activity popup (same as own profile). + private func handleFriendActivitySelection(_ activity: CalendarActivityDTO) { + Task { + if let activityId = activity.activityId, + await profileViewModel.fetchActivityDetails(activityId: activityId) != nil + { + await MainActor.run { + showActivityDetails = true + } + } + } } // Computed property to sort activities as specified @@ -109,7 +135,7 @@ struct UserActivitiesSection: View { Button(action: { showFriendActivities = true }) { - Text("Show All") + Text("See More") .font(.onestMedium(size: 14)) .foregroundColor(universalSecondaryColor) } @@ -160,149 +186,33 @@ struct UserActivitiesSection: View { } } - // Calendar section showing friend's activities - per Figma design - private var calendarSection: some View { - VStack(spacing: 16) { - // Days of the week header - HStack(spacing: 6.618) { - ForEach(Array(["S", "M", "T", "W", "T", "F", "S"].enumerated()), id: \.offset) { _, day in - Text(day) - .font(.onestMedium(size: 13)) - .foregroundColor(universalAccentColor) - .frame(width: 46.33) - } - } - - if profileViewModel.isLoadingCalendar { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 150) - } else { - // Calendar grid - 5 rows x 7 days - VStack(spacing: 6.618) { - ForEach(0..<5, id: \.self) { row in - HStack(spacing: 6.618) { - ForEach(0..<7, id: \.self) { col in - calendarDayCell(row: row, col: col) - } - } - } - } - } - } - } - - // Calendar day cell - shows activity or empty state - @ViewBuilder - private func calendarDayCell(row: Int, col: Int) -> some View { - if row < profileViewModel.calendarActivities.count, - col < profileViewModel.calendarActivities[row].count, - let activity = profileViewModel.calendarActivities[row][col] - { - // Day cell with activity data - FriendCalendarDayCell(activity: activity) - .onTapGesture { - showFriendActivities = true - } - } else { - // Empty day cell - use dashed border for outside month, solid for in month - emptyDayCell(row: row, col: col) - .onTapGesture { - showFriendActivities = true - } - } - } - - // Empty day cell with appropriate styling per Figma - @ViewBuilder - private func emptyDayCell(row: Int, col: Int) -> some View { - let isOutsideMonth = isDayOutsideCurrentMonth(row: row, col: col) - - if isOutsideMonth { - RoundedRectangle(cornerRadius: 6.618) - .stroke(style: StrokeStyle(lineWidth: 1.655, dash: [6, 6])) - .foregroundColor(universalAccentColor.opacity(0.1)) - .frame(width: 46.33, height: 46.33) - } else { - ZStack { - RoundedRectangle(cornerRadius: 6.618) - .fill(emptyDayCellColor) - .frame(width: 46.33, height: 46.33) - .shadow(color: Color.black.opacity(0.1), radius: 6.618, x: 0, y: 1.655) - - // Inner highlight effect per Figma - RoundedRectangle(cornerRadius: 6.618) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.5), Color.clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 46.33, height: 46.33) - .allowsHitTesting(false) - } - } - } - - // Helper function to determine if a cell is outside the current month - private func isDayOutsideCurrentMonth(row: Int, col: Int) -> Bool { - let calendar = Calendar.current - let now = Date() - let currentMonth = calendar.component(.month, from: now) - let currentYear = calendar.component(.year, from: now) - - // Calculate first day offset (0-6, where 0 = Sunday) - var components = DateComponents() - components.year = currentYear - components.month = currentMonth - components.day = 1 - - guard let firstOfMonth = calendar.date(from: components) else { - return false - } - - let weekday = calendar.component(.weekday, from: firstOfMonth) - let firstDayOffset = weekday - 1 // Convert from 1-7 to 0-6 - - // Calculate days in month - guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { - return false - } - let daysInMonth = range.count - - // Calculate day index (0-34) - let dayIndex = row * 7 + col - - // Day is outside month if it's before the first day or after the last day - return dayIndex < firstDayOffset || dayIndex >= firstDayOffset + daysInMonth - } - - // "Add to see activities" section for non-friends + // "Add to see activities" section for non-friends (Figma: stars icon, 16px gap, 32px padding, 8px radius, 1px dashed #8e8484) private var addToSeeActivitiesSection: some View { VStack(alignment: .leading, spacing: 12) { if profileViewModel.friendshipStatus != .friends { - VStack(alignment: .center, spacing: 12) { - Image(systemName: "location.fill") - .font(.system(size: 32)) - .foregroundColor(dashedBorderColor) + VStack(alignment: .center, spacing: 16) { + // Figma: stars-01 icon 32Γ—32 + Image("AddToSeeStarsIcon") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) Text("Add \(FormatterService.shared.formatFirstName(user: user)) to see their upcoming spawns!") - .font(.onestSemiBold(size: 16)) - .foregroundColor(.primary) + .font(.onestMedium(size: 16)) + .foregroundColor(addToSeeMutedColor) .multilineTextAlignment(.center) Text("Connect with them to discover what they're up to!") .font(.onestRegular(size: 14)) - .foregroundColor(secondaryTextColor) + .foregroundColor(addToSeeMutedColor) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) - .padding(.horizontal, 24) - .padding(.vertical, 32) + .padding(32) .background( - RoundedRectangle(cornerRadius: 12) - .stroke(style: StrokeStyle(lineWidth: 2, dash: [8, 4])) - .foregroundColor(dashedBorderColor) + RoundedRectangle(cornerRadius: 8) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [6, 4])) + .foregroundColor(addToSeeMutedColor) ) } } From 32ea1ca1f58aa5e6e3725b5520c183eabd1f0a18 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 03:56:10 -0800 Subject: [PATCH 11/53] rm tests --- .../project.pbxproj | 18 ------------------ .../UserProfile/UserActivitiesSection.swift | 5 ----- 2 files changed, 23 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj b/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj index fca286f2..68111632 100644 --- a/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj +++ b/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj @@ -61,16 +61,6 @@ path = "Spawn-App-iOS-SwiftUI"; sourceTree = ""; }; - 65D503712CD86EB600923A01 /* Spawn-App-iOS-SwiftUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "Spawn-App-iOS-SwiftUITests"; - sourceTree = ""; - }; - 65D5037B2CD86EB600923A01 /* Spawn-App-iOS-SwiftUIUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "Spawn-App-iOS-SwiftUIUITests"; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,8 +102,6 @@ isa = PBXGroup; children = ( 65D5035B2CD86EB300923A01 /* Spawn-App-iOS-SwiftUI */, - 65D503712CD86EB600923A01 /* Spawn-App-iOS-SwiftUITests */, - 65D5037B2CD86EB600923A01 /* Spawn-App-iOS-SwiftUIUITests */, 65D5035A2CD86EB300923A01 /* Products */, ); sourceTree = ""; @@ -176,9 +164,6 @@ dependencies = ( 65D503702CD86EB600923A01 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - 65D503712CD86EB600923A01 /* Spawn-App-iOS-SwiftUITests */, - ); name = "Spawn-App-iOS-SwiftUITests"; packageProductDependencies = ( ); @@ -199,9 +184,6 @@ dependencies = ( 65D5037A2CD86EB600923A01 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - 65D5037B2CD86EB600923A01 /* Spawn-App-iOS-SwiftUIUITests */, - ); name = "Spawn-App-iOS-SwiftUIUITests"; packageProductDependencies = ( ); diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift index 1c76750d..77b261ec 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift @@ -201,11 +201,6 @@ struct UserActivitiesSection: View { .font(.onestMedium(size: 16)) .foregroundColor(addToSeeMutedColor) .multilineTextAlignment(.center) - - Text("Connect with them to discover what they're up to!") - .font(.onestRegular(size: 14)) - .foregroundColor(addToSeeMutedColor) - .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(32) From 45afabc12ae31f6a900460eb73b184fce7587016 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:01:21 -0800 Subject: [PATCH 12/53] add to activity type view: proper list formatting + proper pinned sorting --- .../Components/AddToActivityTypeView.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift index 6b254553..b8b38acb 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift @@ -158,11 +158,17 @@ struct AddToActivityTypeView: View { } private var selectedActivityTypesText: String { - let selectedTypes = viewModel.activityTypes.filter { selectedActivityTypes.contains($0.id) } - if selectedTypes.isEmpty { + let titles = viewModel.sortedActivityTypes + .filter { selectedActivityTypes.contains($0.id) } + .map(\.title) + if titles.isEmpty { return "No activity types selected" } - return selectedTypes.map { $0.title }.joined(separator: " & ") + switch titles.count { + case 1: return titles[0] + case 2: return "\(titles[0]) and \(titles[1])" + default: return titles.dropLast().joined(separator: ", ") + ", and " + (titles.last ?? "") + } } private var activityTypeGrid: some View { @@ -174,7 +180,7 @@ struct AddToActivityTypeView: View { ], spacing: 10 ) { - ForEach(viewModel.activityTypes, id: \.id) { activityType in + ForEach(viewModel.sortedActivityTypes, id: \.id) { activityType in ActivityTypeCard( activityTypeDTO: activityType, isSelected: selectedActivityTypes.contains(activityType.id), @@ -262,6 +268,14 @@ final class AddToActivityTypeViewModel: ObservableObject { } } + /// Activity types sorted with pinned first, then alphabetically by title. + var sortedActivityTypes: [ActivityTypeDTO] { + activityTypes.sorted { first, second in + if first.isPinned != second.isPinned { return first.isPinned } + return first.title.localizedCaseInsensitiveCompare(second.title) == .orderedAscending + } + } + func addUserToActivityTypes(_ userToAdd: Nameable, selectedActivityTypeIds: Set) async -> Bool { isLoading = true errorMessage = nil From a87e2ca929a832e19cb804326c3c2ccb678877aa Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:01:49 -0800 Subject: [PATCH 13/53] More error alerts --- .../ActivityTypeManagementView.swift | 6 +++++- .../ActivityTypeView.swift | 18 +++++++++++++----- .../ActivityDetail/ActivityEditView.swift | 6 +++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift index 6ef381ef..15e0a140 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift @@ -9,6 +9,7 @@ struct ActivityTypeManagementView: View { @State private var showingEditView = false @State private var navigateToProfile = false @State private var selectedUserForProfile: MinimalFriendDTO? + @State private var showErrorAlert = false // Store background refresh task so we can cancel it on disappear @State private var backgroundRefreshTask: Task? @@ -174,7 +175,7 @@ struct ActivityTypeManagementView: View { } } } - .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + .alert("Error", isPresented: $showErrorAlert) { Button("OK") { viewModel.clearError() } @@ -183,6 +184,9 @@ struct ActivityTypeManagementView: View { Text(errorMessage) } } + .onChange(of: viewModel.errorMessage) { _, newValue in + showErrorAlert = newValue != nil + } // Custom popup overlay if showingOptions { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift index 5eac101f..0e87efeb 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift @@ -9,10 +9,12 @@ struct ActivityTypeView: View { @State private var navigateToManageType = false @State private var navigateToCreateType = false @State private var selectedActivityTypeForManagement: ActivityTypeDTO? + @State private var newActivityTypeDTO: ActivityTypeDTO = ActivityTypeDTO.createNew() // Delete confirmation state @State private var showDeleteConfirmation = false @State private var activityTypeToDelete: ActivityTypeDTO? + @State private var showErrorAlert = false // Store background refresh task so we can cancel it on disappear @State private var backgroundRefreshTask: Task? @@ -98,7 +100,7 @@ struct ActivityTypeView: View { backgroundRefreshTask?.cancel() backgroundRefreshTask = nil } - .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + .alert("Error", isPresented: $showErrorAlert) { Button("OK") { viewModel.clearError() } @@ -107,6 +109,9 @@ struct ActivityTypeView: View { Text(errorMessage) } } + .onChange(of: viewModel.errorMessage) { _, newValue in + showErrorAlert = newValue != nil + } .alert("Delete Activity Type", isPresented: $showDeleteConfirmation) { Button("Cancel", role: .cancel) { activityTypeToDelete = nil @@ -136,14 +141,14 @@ struct ActivityTypeView: View { .sheet( isPresented: $navigateToCreateType, onDismiss: { - // Refresh the activity types list when the create sheet is dismissed + newActivityTypeDTO = ActivityTypeDTO.createNew() Task { await viewModel.fetchActivityTypes(forceRefresh: true) } }, content: { NavigationStack { - ActivityTypeEditView(activityTypeDTO: ActivityTypeDTO.createNew()) + ActivityTypeEditView(activityTypeDTO: newActivityTypeDTO) } } ) @@ -231,7 +236,10 @@ extension ActivityTypeView { private func activityTypeCardView(for activityTypeDTO: ActivityTypeDTO) -> some View { ActivityTypeCard( activityTypeDTO: activityTypeDTO, - selectedActivityType: $selectedActivityType, + isSelected: selectedActivityType?.id == activityTypeDTO.id, + onTap: { + selectedActivityType = activityTypeDTO + }, onPin: { Task { await viewModel.togglePin(for: activityTypeDTO) @@ -244,7 +252,7 @@ extension ActivityTypeView { onManage: { selectedActivityTypeForManagement = activityTypeDTO navigateToManageType = true - }, + } ) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift index 0d4b4a8d..0c1a4934 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift @@ -13,6 +13,7 @@ struct ActivityEditView: View { @State private var hasChanges: Bool = false @State private var showSuccessMessage: Bool = false @State private var showSaveConfirmation: Bool = false + @State private var showErrorAlert: Bool = false @FocusState private var isTitleFieldFocused: Bool private var adaptiveBackgroundColor: Color { @@ -172,7 +173,7 @@ struct ActivityEditView: View { .sheet(isPresented: $showEmojiPicker) { ElegantEmojiPickerView(selectedEmoji: $editedIcon, isPresented: $showEmojiPicker) } - .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + .alert("Error", isPresented: $showErrorAlert) { Button("OK") { viewModel.clearError() } @@ -181,6 +182,9 @@ struct ActivityEditView: View { Text(errorMessage) } } + .onChange(of: viewModel.errorMessage) { _, newValue in + showErrorAlert = newValue != nil + } .alert("Save All Changes?", isPresented: $showSaveConfirmation) { Button("Don't Save", role: .destructive) { // Reset to original values and dismiss From c4912e52a0b463fdf7a3cc546d70a76aea74097e Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:02:15 -0800 Subject: [PATCH 14/53] profile image styling --- .../Images/CachedAsyncImage/CachedProfileImage.swift | 10 +++++----- .../Views/ViewModifiers/ProfileImages.swift | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/Images/CachedAsyncImage/CachedProfileImage.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/Images/CachedAsyncImage/CachedProfileImage.swift index d81188dd..6a98114a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/Images/CachedAsyncImage/CachedProfileImage.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/Images/CachedAsyncImage/CachedProfileImage.swift @@ -49,7 +49,7 @@ struct CachedProfileImage: View { case .participantsDrawer: return 36 case .profilePage: - return 150 + return 128 case .feedCardParticipants: return 34 } @@ -57,22 +57,22 @@ struct CachedProfileImage: View { private var strokeColor: Color { switch imageType { - case .feedPage, .profilePage: + case .feedPage: return universalAccentColor case .activityParticipants, .chatMessage: return .white - case .friendsListView, .participantsPopup, .participantsDrawer, .feedCardParticipants: + case .friendsListView, .participantsPopup, .participantsDrawer, .feedCardParticipants, .profilePage: return .clear } } private var strokeLineWidth: CGFloat { switch imageType { - case .feedPage, .profilePage: + case .feedPage: return 2 case .activityParticipants, .chatMessage: return 1 - case .friendsListView, .participantsPopup, .participantsDrawer, .feedCardParticipants: + case .friendsListView, .participantsPopup, .participantsDrawer, .feedCardParticipants, .profilePage: return 0 } } diff --git a/Spawn-App-iOS-SwiftUI/Views/ViewModifiers/ProfileImages.swift b/Spawn-App-iOS-SwiftUI/Views/ViewModifiers/ProfileImages.swift index b296b146..299037eb 100644 --- a/Spawn-App-iOS-SwiftUI/Views/ViewModifiers/ProfileImages.swift +++ b/Spawn-App-iOS-SwiftUI/Views/ViewModifiers/ProfileImages.swift @@ -32,7 +32,9 @@ extension Image { strokeColor = .clear lineWidth = 0 case .profilePage: - imageSize = 150 + imageSize = 128 + strokeColor = .clear + lineWidth = 0 case .feedCardParticipants: imageSize = 34 // Approximate min(width: 33.53, height: 34.26) strokeColor = .clear From 838c43f2b64c2db189b1dd91944ee03d414c6b22 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:02:40 -0800 Subject: [PATCH 15/53] clean activity type card styling --- .../ActivityTypeCard.swift | 139 ++++++------------ 1 file changed, 44 insertions(+), 95 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift index 4b471f0d..af8d7d3a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift @@ -2,21 +2,14 @@ import SwiftUI struct ActivityTypeCard: View { let activityTypeDTO: ActivityTypeDTO - @Binding var selectedActivityType: ActivityTypeDTO? - let onPin: () -> Void - let onDelete: () -> Void - let onManage: () -> Void - - // Add state to track button interaction - @State private var isPressed = false + let isSelected: Bool + let onTap: () -> Void + var onPin: (() -> Void)? = nil + var onDelete: (() -> Void)? = nil + var onManage: (() -> Void)? = nil @Environment(\.colorScheme) private var colorScheme - private var isSelected: Bool { - selectedActivityType?.id == activityTypeDTO.id - } - - // Adaptive background color for card private var adaptiveBackgroundColor: Color { switch colorScheme { case .dark: @@ -28,7 +21,6 @@ struct ActivityTypeCard: View { } } - // Adaptive text colors private var adaptiveTitleColor: Color { switch colorScheme { case .dark: @@ -51,7 +43,6 @@ struct ActivityTypeCard: View { } } - // Computed properties for dynamic styling private var backgroundFillColor: Color { if isSelected { return Color.blue.opacity(0.1) @@ -60,78 +51,32 @@ struct ActivityTypeCard: View { } } - private var borderColor: Color { - if isSelected { - return Color.clear - } else { - return Color.clear - } - } - - private var borderWidth: CGFloat { - if isSelected { - return 2 - } else { - return 0 - } - } - - private var shadowColor: Color { - if isSelected { - return Color.blue.opacity(0.3) - } else { - return Color.black.opacity(0.1) - } - } - - private var shadowRadius: CGFloat { - if isSelected { - return 4 - } else { - return 2 - } - } - - private var shadowOffset: CGFloat { - if isSelected { - return 2 - } else { - return 1 - } - } - var body: some View { - Button(action: { - // Haptic feedback + let card = Button(action: { let impactGenerator = UIImpactFeedbackGenerator(style: .medium) impactGenerator.impactOccurred() - // Execute action with slight delay for animation Task { @MainActor in try? await Task.sleep(for: .seconds(0.1)) - selectedActivityType = activityTypeDTO + onTap() } }) { ZStack { - VStack(spacing: 10) { - // Icon - ZStack { - Text(activityTypeDTO.icon) - .font(.system(size: 24)) - } - .frame(width: 32, height: 32) + VStack(spacing: 12) { + Text(activityTypeDTO.icon) + .font(.system(size: 24)) + .frame(width: 32, height: 32) - // Title and people count - VStack { + VStack(spacing: 8) { Text(activityTypeDTO.title) - .font(Font.custom("Onest", size: 14).weight(.medium)) + .font(.onestMedium(size: 16)) .foregroundColor(adaptiveTitleColor) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.center) Text("\(activityTypeDTO.associatedFriends.count) people") - .font(Font.custom("Onest", size: 12)) + .font(.onestRegular(size: 12)) .foregroundColor(adaptiveSecondaryTextColor) } } @@ -140,21 +85,22 @@ struct ActivityTypeCard: View { .background( RoundedRectangle(cornerRadius: 12) .fill(backgroundFillColor) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(borderColor, lineWidth: borderWidth) - ) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(Color(red: 0.95, green: 0.93, blue: 0.93), lineWidth: 1) // "border" - .shadow(color: Color.black.opacity(0.25), radius: 3, x: 0, y: -2) // dark shadow top - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: Color.white.opacity(0.7), radius: 4, x: 0, y: 4) // light shadow bottom - .clipShape(RoundedRectangle(cornerRadius: 12)) + .fill( + LinearGradient( + stops: [ + .init(color: Color.black.opacity(0.05), location: 0), + .init(color: Color.clear, location: 0.3), + ], + startPoint: .bottom, + endPoint: .top + ) + ) ) + .clipShape(RoundedRectangle(cornerRadius: 12)) - // Pin icon overlay if activityTypeDTO.isPinned { VStack { HStack { @@ -167,7 +113,6 @@ struct ActivityTypeCard: View { .clipShape(Circle()) Spacer() } - Spacer() } .padding(8) @@ -175,22 +120,26 @@ struct ActivityTypeCard: View { } } .buttonStyle(PlainButtonStyle()) - .contextMenu { - Button(action: onPin) { - Label( - activityTypeDTO.isPinned ? "Unpin" : "Pin", - systemImage: activityTypeDTO.isPinned ? "pin.slash" : "pin" - ) - } - - Button(action: onManage) { - Label("Manage", systemImage: "gear") - } - Button(action: onDelete) { - Label("Delete", systemImage: "trash") - } - .foregroundColor(.red) + if let onPin = onPin, let onDelete = onDelete, let onManage = onManage { + card + .contextMenu { + Button(action: onPin) { + Label( + activityTypeDTO.isPinned ? "Unpin" : "Pin", + systemImage: activityTypeDTO.isPinned ? "pin.slash" : "pin" + ) + } + Button(action: onManage) { + Label("Manage", systemImage: "gear") + } + Button(action: onDelete) { + Label("Delete", systemImage: "trash") + } + .foregroundColor(.red) + } + } else { + card } } } From e14516d5649bef7b9bf1ffc257893831652b1e16 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:03:03 -0800 Subject: [PATCH 16/53] fix back button navigation from activity type editing view --- .../DTOs/Activity/ActivityTypeDTO.swift | 2 +- .../ActivityTypeEditView.swift | 39 ++++++------------- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Models/DTOs/Activity/ActivityTypeDTO.swift b/Spawn-App-iOS-SwiftUI/Models/DTOs/Activity/ActivityTypeDTO.swift index 25c32c9f..4d68906a 100644 --- a/Spawn-App-iOS-SwiftUI/Models/DTOs/Activity/ActivityTypeDTO.swift +++ b/Spawn-App-iOS-SwiftUI/Models/DTOs/Activity/ActivityTypeDTO.swift @@ -11,7 +11,7 @@ import Foundation /// Note: associatedFriends uses MinimalFriendDTO instead of BaseUserDTO to reduce memory usage. /// MinimalFriendDTO only contains essential fields (id, username, name, profilePicture) /// needed for displaying friends in activity type selection UI. -struct ActivityTypeDTO: Identifiable, Codable, Equatable, Sendable { +struct ActivityTypeDTO: Identifiable, Codable, Equatable, Hashable, Sendable { var id: UUID var title: String var associatedFriends: [MinimalFriendDTO] diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift index fdfc53df..44b7b119 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift @@ -1,19 +1,5 @@ import SwiftUI -// MARK: - Lazy View Wrapper -// Prevents eager evaluation of navigation destinations, avoiding re-render loops -private struct LazyView: View { - private let build: () -> Content - - init(_ build: @autoclosure @escaping () -> Content) { - self.build = build - } - - var body: some View { - build() - } -} - struct ActivityTypeEditView: View { let activityTypeDTO: ActivityTypeDTO let onBack: (() -> Void)? @@ -23,8 +9,9 @@ struct ActivityTypeEditView: View { @State private var editedTitle: String = "" @State private var editedIcon: String = "" @State private var hasChanges: Bool = false - @State private var navigateToFriendSelection: Bool = false + @State private var friendSelectionActivityType: ActivityTypeDTO? @State private var showEmojiPicker: Bool = false + @State private var showErrorAlert: Bool = false @FocusState private var isTitleFieldFocused: Bool @State private var viewModel: ActivityTypeViewModel @@ -67,18 +54,14 @@ struct ActivityTypeEditView: View { .onChange(of: editedIcon) { _, _ in updateHasChanges() } - .navigationDestination(isPresented: $navigateToFriendSelection) { - // Use LazyView to prevent re-evaluation on every parent re-render - // This breaks the cycle where AppCache updates cause infinite view recreation - LazyView( - ActivityTypeFriendSelectionView( - activityTypeDTO: createUpdatedActivityType(), - onComplete: handleFriendSelectionComplete - ) - .environmentObject(AppCache.shared) + .navigationDestination(item: $friendSelectionActivityType) { activityType in + ActivityTypeFriendSelectionView( + activityTypeDTO: activityType, + onComplete: handleFriendSelectionComplete ) + .environmentObject(AppCache.shared) } - .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + .alert("Error", isPresented: $showErrorAlert) { Button("OK") { viewModel.clearError() } @@ -87,6 +70,9 @@ struct ActivityTypeEditView: View { Text(errorMessage) } } + .onChange(of: viewModel.errorMessage) { _, newValue in + showErrorAlert = newValue != nil + } .overlay(loadingOverlay) } @@ -286,12 +272,11 @@ struct ActivityTypeEditView: View { } private func navigateToNextStep() { - // Validate input before proceeding guard !editedTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - navigateToFriendSelection = true + friendSelectionActivityType = createUpdatedActivityType() } private func createUpdatedActivityType() -> ActivityTypeDTO { From b0b898022c8ff061f803ca8b5bdea05078661737 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:03:13 -0800 Subject: [PATCH 17/53] day activities padding fix --- .../DayActivities/DayActivitiesPageView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift index 9e1b5850..e328dcc1 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift @@ -100,8 +100,8 @@ struct DayActivitiesPageView: View { // Invisible button for spacing balance Color.clear.frame(width: 24, height: 24) } - .padding(.horizontal, 20) - .padding(.vertical, 16) + .padding(.horizontal, 16) + .padding(.vertical, 12) } // MARK: - Content View @@ -128,7 +128,7 @@ struct DayActivitiesPageView: View { private var activitiesListView: some View { ScrollView { - LazyVStack(spacing: 16) { + LazyVStack(spacing: 14) { ForEach(activities, id: \.id) { activity in if let activityId = activity.activityId, let fullActivity = fullActivities[activityId] @@ -141,7 +141,8 @@ struct DayActivitiesPageView: View { locationManager: locationManager, callback: { _, _ in onActivitySelected(activity) - } + }, + horizontalPadding: 16 ) } else { // Show loading placeholder while activity details are being fetched @@ -152,8 +153,8 @@ struct DayActivitiesPageView: View { } } } - .padding(.horizontal, 20) - .padding(.top, 8) + .padding(.horizontal, 16) + .padding(.top, 4) } } From f15e27af10d43d4dbc9e38a4ee69380d753edd63 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:03:38 -0800 Subject: [PATCH 18/53] friend profile styling fixes --- .../Profile/UserProfile/UserProfileView.swift | 30 +++++++++++-------- .../Shared/UI/AnimatedActionButton.swift | 12 ++++---- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 59ece934..55f3e3a3 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -40,6 +40,14 @@ struct UserProfileView: View { // Add environment object for navigation @Environment(\.presentationMode) var presentationMode + @Environment(\.colorScheme) private var colorScheme + + // Figma: Request Sent border --text/secondary (#262424) light; visible gray in dark + private var requestSentBorderColor: Color { + colorScheme == .dark + ? Color(hex: colorsGray400) + : Color(hex: colorsGray700) + } init(user: Nameable) { self.user = user @@ -219,11 +227,9 @@ struct UserProfileView: View { Button(action: { presentationMode.wrappedValue.dismiss() }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text("Back") - } - .foregroundColor(universalAccentColor) + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(universalAccentColor) } } @@ -258,7 +264,7 @@ struct UserProfileView: View { private var profileInnerComponentsView: some View { VStack(alignment: .center, spacing: 10) { // Profile Header (Profile Picture + Name) - read-only for other users - VStack(spacing: 10) { + VStack(spacing: 16) { // Profile Picture ZStack(alignment: .bottomTrailing) { if let pfpUrl = user.profilePicture { @@ -275,7 +281,7 @@ struct UserProfileView: View { } else { Circle() .fill(Color.gray) - .frame(width: 150, height: 150) + .frame(width: 128, height: 128) } } @@ -370,10 +376,10 @@ struct UserProfileView: View { .foregroundColor(.white) } else { Image(systemName: "person.badge.clock") - .foregroundColor(Color.gray) + .foregroundColor(universalPlaceHolderTextColor) Text("Request Sent") .bold() - .foregroundColor(Color.gray) + .foregroundColor(universalPlaceHolderTextColor) } } .font(.onestMedium(size: 16)) @@ -393,9 +399,9 @@ struct UserProfileView: View { RoundedRectangle(cornerRadius: 12) .stroke( profileViewModel.friendshipStatus == .requestSent - ? Color.gray + ? requestSentBorderColor : Color.clear, - lineWidth: 1 + lineWidth: 2 ) ) .scaleEffect(addFriendScale) @@ -442,7 +448,6 @@ struct UserProfileView: View { openSocialMediaLink: openSocialMediaLink, removeInterest: { _ in } // No-op for other users ) - .padding(.horizontal, 16) .padding(.top, 20) .padding(.bottom, 8) @@ -452,7 +457,6 @@ struct UserProfileView: View { profileViewModel: profileViewModel, showActivityDetails: $showActivityDetails ) - .padding(.horizontal, 16) .padding(.bottom, 100) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift index efff8aa8..c65f8412 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift @@ -26,8 +26,8 @@ enum FriendActionButtonStyle { var normalColor: Color { switch self { case .accept: return .white - case .remove, .cancel: return figmaGray700 - case .add: return .white + case .remove, .cancel: return universalSecondaryTextColor + case .add: return universalSecondaryTextColor } } @@ -46,7 +46,7 @@ enum FriendActionButtonStyle { case .remove, .cancel: return Color.clear case .add: - return universalSecondaryColor + return Color.clear } } } @@ -54,10 +54,12 @@ enum FriendActionButtonStyle { var borderColor: (_ isActive: Bool) -> Color { return { isActive in switch self { - case .accept, .add: + case .accept: return Color.clear + case .add: + return isActive ? figmaGreen : universalSecondaryTextColor case .remove, .cancel: - return isActive ? figmaGreen : figmaGray700 + return isActive ? figmaGreen : universalSecondaryTextColor } } } From 75449a9381d12f25e4cf10fc1fda9b1974c28327 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:03:42 -0800 Subject: [PATCH 19/53] Create FriendActivitiesCalendarView.swift --- .../Shared/FriendActivitiesCalendarView.swift | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift new file mode 100644 index 00000000..c1797fb7 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift @@ -0,0 +1,260 @@ +import SwiftUI + +struct FriendActivitiesCalendarView: View { + let user: Nameable + var profileViewModel: ProfileViewModel + @Binding var showActivityDetails: Bool + /// When set to false by the calendar view (e.g. back tapped), parent pops this screen so we return to the profile. + @Binding var isPresented: Bool + + @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var locationManager = LocationManager.shared + + @State private var showFullActivityList: Bool = false + + private var emptyDayCellColor: Color { + colorScheme == .dark ? Color(hex: colorsGray700) : Color(hex: colorsGray200) + } + + private var sortedActivities: [ProfileActivityDTO] { + let upcomingActivities = profileViewModel.profileActivities + .filter { !$0.isPastActivity } + .sorted { activity1, activity2 in + guard let start1 = activity1.startTime, let start2 = activity2.startTime else { + return false + } + return start1 < start2 + } + + let pastActivities = profileViewModel.profileActivities + .filter { $0.isPastActivity } + .sorted { activity1, activity2 in + guard let start1 = activity1.startTime, let start2 = activity2.startTime else { + return false + } + return start1 > start2 + } + + return upcomingActivities + pastActivities + } + + var body: some View { + ZStack { + universalBackgroundColor + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + activitiesSection + calendarSection + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 100) + } + } + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + isPresented = false + }) { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(universalAccentColor) + } + } + } + .navigationDestination(isPresented: $showFullActivityList) { + FriendActivitiesShowAllView( + user: user, + profileViewModel: profileViewModel, + showActivityDetails: $showActivityDetails + ) + } + } + + // MARK: - Activities Section + private var activitiesSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Activities by \(FormatterService.shared.formatFirstName(user: user))") + .font(.onestSemiBold(size: 16)) + .foregroundColor(universalAccentColor) + Spacer() + Button(action: { + showFullActivityList = true + }) { + Text("Show All") + .font(.onestMedium(size: 14)) + .foregroundColor(universalSecondaryColor) + } + } + + if profileViewModel.isLoadingUserActivities { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if profileViewModel.profileActivities.isEmpty { + emptyActivitiesView + } else { + VStack(spacing: 12) { + ForEach(Array(sortedActivities.prefix(2))) { activity in + let fullFeedActivity = activity.toFullFeedActivityDTO() + ActivityCardView( + userId: UserAuthViewModel.shared.spawnUser?.id ?? UUID(), + activity: fullFeedActivity, + color: getActivityColor(for: activity.id), + locationManager: locationManager, + callback: { selectedActivity, color in + profileViewModel.selectedActivity = selectedActivity + showActivityDetails = true + }, + horizontalPadding: 0 + ) + } + } + } + } + } + + private var emptyActivitiesView: some View { + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 32)) + .foregroundColor(Color.gray.opacity(0.6)) + + Text("\(FormatterService.shared.formatFirstName(user: user)) hasn't spawned any activities yet!") + .font(.onestMedium(size: 16)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(32) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + } + + // MARK: - Calendar Section + private var calendarSection: some View { + VStack(spacing: 16) { + HStack(spacing: 6.618) { + ForEach(Array(["S", "M", "T", "W", "T", "F", "S"].enumerated()), id: \.offset) { _, day in + Text(day) + .font(.onestMedium(size: 13)) + .foregroundColor(universalAccentColor) + .frame(width: 46.33) + } + } + + if profileViewModel.isLoadingCalendar { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 150) + } else { + VStack(spacing: 6.618) { + ForEach(0..<5, id: \.self) { row in + HStack(spacing: 6.618) { + ForEach(0..<7, id: \.self) { col in + calendarDayCell(row: row, col: col) + } + } + } + } + } + } + } + + @ViewBuilder + private func calendarDayCell(row: Int, col: Int) -> some View { + if row < profileViewModel.calendarActivities.count, + col < profileViewModel.calendarActivities[row].count, + let activity = profileViewModel.calendarActivities[row][col] + { + FriendCalendarDayCell(activity: activity) + } else { + emptyDayCell(row: row, col: col) + } + } + + @ViewBuilder + private func emptyDayCell(row: Int, col: Int) -> some View { + let isOutsideMonth = isDayOutsideCurrentMonth(row: row, col: col) + + if isOutsideMonth { + RoundedRectangle(cornerRadius: 6.618) + .stroke(style: StrokeStyle(lineWidth: 1.655, dash: [6, 6])) + .foregroundColor(universalAccentColor.opacity(0.1)) + .frame(width: 46.33, height: 46.33) + } else { + ZStack { + RoundedRectangle(cornerRadius: 6.618) + .fill(emptyDayCellColor) + .frame(width: 46.33, height: 46.33) + .shadow(color: Color.black.opacity(0.1), radius: 6.618, x: 0, y: 1.655) + + RoundedRectangle(cornerRadius: 6.618) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.5), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 46.33, height: 46.33) + .allowsHitTesting(false) + } + } + } + + private func isDayOutsideCurrentMonth(row: Int, col: Int) -> Bool { + let calendar = Calendar.current + let now = Date() + let currentMonth = calendar.component(.month, from: now) + let currentYear = calendar.component(.year, from: now) + + var components = DateComponents() + components.year = currentYear + components.month = currentMonth + components.day = 1 + + guard let firstOfMonth = calendar.date(from: components) else { + return false + } + + let weekday = calendar.component(.weekday, from: firstOfMonth) + let firstDayOffset = weekday - 1 + + guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { + return false + } + let daysInMonth = range.count + let dayIndex = row * 7 + col + + return dayIndex < firstDayOffset || dayIndex >= firstDayOffset + daysInMonth + } +} + +// MARK: - Preview +@available(iOS 17, *) +#Preview { + let viewModel: ProfileViewModel = { + let vm = ProfileViewModel() + vm.friendshipStatus = .friends + vm.profileActivities = ProfileActivityDTO.mockActivities + return vm + }() + + NavigationStack { + FriendActivitiesCalendarView( + user: BaseUserDTO.danielAgapov, + profileViewModel: viewModel, + showActivityDetails: .constant(false), + isPresented: .constant(true) + ) + } +} From 432eedddaa65795b7037f5e732053897c5347485 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:14:38 -0800 Subject: [PATCH 20/53] perf: fix only one profileviewmodel per user --- Spawn-App-iOS-SwiftUI/ContentView.swift | 2 +- .../Services/Cache/CacheCoordinator.swift | 1 + .../Profile/ProfileViewModelCache.swift | 46 +++++++++++++++++++ .../Profile/UserProfile/UserProfileView.swift | 20 +++----- 4 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModelCache.swift diff --git a/Spawn-App-iOS-SwiftUI/ContentView.swift b/Spawn-App-iOS-SwiftUI/ContentView.swift index 8eef721e..2937814a 100644 --- a/Spawn-App-iOS-SwiftUI/ContentView.swift +++ b/Spawn-App-iOS-SwiftUI/ContentView.swift @@ -183,7 +183,7 @@ struct ContentView: View { friendsViewModel = FriendsTabViewModel(userId: user.id) } if profileViewModel == nil { - profileViewModel = ProfileViewModel(userId: user.id) + profileViewModel = ProfileViewModelCache.shared.viewModel(for: user.id) } // CRITICAL FIX: Load cached activities immediately to unblock UI diff --git a/Spawn-App-iOS-SwiftUI/Services/Cache/CacheCoordinator.swift b/Spawn-App-iOS-SwiftUI/Services/Cache/CacheCoordinator.swift index 38ac1cb5..9c9dac21 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Cache/CacheCoordinator.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Cache/CacheCoordinator.swift @@ -52,6 +52,7 @@ final class CacheCoordinator: ObservableObject { activityCache.clearAllCaches() friendshipCache.clearAllCaches() profileCache.clearAllCaches() + ProfileViewModelCache.shared.clear() Task { await profilePictureCache.clearAllCache() diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModelCache.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModelCache.swift new file mode 100644 index 00000000..018b6b0c --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModelCache.swift @@ -0,0 +1,46 @@ +// +// ProfileViewModelCache.swift +// Spawn-App-iOS-SwiftUI +// +// Ensures exactly one ProfileViewModel per userId across the app. +// Prevents duplicate VMs when viewing same profile from multiple entry points +// (e.g. MyProfileView + UserProfileView for own profile, or revisiting a friend). +// + +import SwiftUI + +/// Cache ensuring one ProfileViewModel per userId. +/// Used by MyProfileView, UserProfileView, and any view needing profile data. +@MainActor +final class ProfileViewModelCache { + static let shared = ProfileViewModelCache() + + private var cache: [UUID: ProfileViewModel] = [:] + private let maxCachedProfiles = 20 + + private init() {} + + /// Returns the ProfileViewModel for the given userId. Creates and caches if needed. + func viewModel(for userId: UUID) -> ProfileViewModel { + if let existing = cache[userId] { + return existing + } + let vm = ProfileViewModel(userId: userId) + cache[userId] = vm + evictIfNeeded() + return vm + } + + /// Evicts entries when cache exceeds max size. Keeps current user's VM. + private func evictIfNeeded() { + let currentUserId = UserAuthViewModel.shared.spawnUser?.id + while cache.count > maxCachedProfiles, let keyToRemove = cache.keys.first(where: { $0 != currentUserId }) { + cache.removeValue(forKey: keyToRemove) + } + } + + /// Call on logout to clear cached view models. + func clear() { + cache.removeAll() + } +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 55f3e3a3..0183f880 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -40,26 +40,18 @@ struct UserProfileView: View { // Add environment object for navigation @Environment(\.presentationMode) var presentationMode - @Environment(\.colorScheme) private var colorScheme - - // Figma: Request Sent border --text/secondary (#262424) light; visible gray in dark - private var requestSentBorderColor: Color { - colorScheme == .dark - ? Color(hex: colorsGray400) - : Color(hex: colorsGray700) - } init(user: Nameable) { self.user = user self._profileViewModel = State( - wrappedValue: ProfileViewModel(userId: user.id) + wrappedValue: ProfileViewModelCache.shared.viewModel(for: user.id) ) } /// Preview-only initializer that allows setting a specific friendship status init(user: Nameable, previewFriendshipStatus: FriendshipStatus) { self.user = user - let viewModel = ProfileViewModel(userId: user.id) + let viewModel = ProfileViewModelCache.shared.viewModel(for: user.id) viewModel.friendshipStatus = previewFriendshipStatus self._profileViewModel = State(wrappedValue: viewModel) } @@ -67,7 +59,7 @@ struct UserProfileView: View { /// Preview-only initializer that allows setting friendship status and mock activities init(user: Nameable, previewFriendshipStatus: FriendshipStatus, previewActivities: [ProfileActivityDTO]) { self.user = user - let viewModel = ProfileViewModel(userId: user.id) + let viewModel = ProfileViewModelCache.shared.viewModel(for: user.id) viewModel.friendshipStatus = previewFriendshipStatus viewModel.profileActivities = previewActivities self._profileViewModel = State(wrappedValue: viewModel) @@ -376,10 +368,10 @@ struct UserProfileView: View { .foregroundColor(.white) } else { Image(systemName: "person.badge.clock") - .foregroundColor(universalPlaceHolderTextColor) + .foregroundColor(Color(hex: colorsGray200)) Text("Request Sent") .bold() - .foregroundColor(universalPlaceHolderTextColor) + .foregroundColor(Color(hex: colorsGray200)) } } .font(.onestMedium(size: 16)) @@ -399,7 +391,7 @@ struct UserProfileView: View { RoundedRectangle(cornerRadius: 12) .stroke( profileViewModel.friendshipStatus == .requestSent - ? requestSentBorderColor + ? Color(hex: colorsGray200) : Color.clear, lineWidth: 2 ) From c4dae514292e5bb3fe2c79fff20032933eac85b8 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:34:37 -0800 Subject: [PATCH 21/53] Proper user activities pages order --- .../Calendar/ActivityCalendarView.swift | 3 -- .../UserProfile/UserActivitiesSection.swift | 40 ++----------------- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift index 0907f7ee..81989c76 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift @@ -98,9 +98,6 @@ struct ActivityCalendarView: View { isReturningFromNavigation = true // Reset the scroll flag so we scroll properly when coming back hasPerformedInitialScroll = false - // Reset navigation state when leaving the calendar view - // This prevents the NavigationLink from getting stuck in active state - onDismiss?() } .onChange(of: profileViewModel.allCalendarActivities) { oldActivities, newActivities in // When activities are first loaded (or change significantly), scroll to current month diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift index 77b261ec..a2937623 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserActivitiesSection.swift @@ -6,8 +6,6 @@ struct UserActivitiesSection: View { @ObservedObject private var locationManager = LocationManager.shared @Binding var showActivityDetails: Bool @State private var showFriendActivities: Bool = false - @State private var showDayActivitiesFromFriend: Bool = false - @State private var selectedDayActivities: [CalendarActivityDTO] = [] // Adaptive colors for dark mode support private var secondaryTextColor: Color { @@ -58,42 +56,12 @@ struct UserActivitiesSection: View { addToSeeActivitiesSection } .navigationDestination(isPresented: $showFriendActivities) { - ActivityCalendarView( + FriendActivitiesCalendarView( + user: user, profileViewModel: profileViewModel, - userCreationDate: profileViewModel.userProfileInfo?.dateCreated, - calendarOwnerName: FormatterService.shared.formatFirstName(user: user), - onDismiss: { showFriendActivities = false }, - onActivitySelected: { handleFriendActivitySelection($0) }, - onDayActivitiesSelected: { activities in - selectedDayActivities = activities - showDayActivitiesFromFriend = true - } + showActivityDetails: $showActivityDetails ) } - .navigationDestination(isPresented: $showDayActivitiesFromFriend) { - DayActivitiesPageView( - date: selectedDayActivities.first?.dateAsDate ?? Date(), - initialActivities: selectedDayActivities, - onDismiss: { showDayActivitiesFromFriend = false }, - onActivitySelected: { activity in - showDayActivitiesFromFriend = false - handleFriendActivitySelection(activity) - } - ) - } - } - - /// Fetches full activity details and shows the global activity popup (same as own profile). - private func handleFriendActivitySelection(_ activity: CalendarActivityDTO) { - Task { - if let activityId = activity.activityId, - await profileViewModel.fetchActivityDetails(activityId: activityId) != nil - { - await MainActor.run { - showActivityDetails = true - } - } - } } // Computed property to sort activities as specified @@ -135,7 +103,7 @@ struct UserActivitiesSection: View { Button(action: { showFriendActivities = true }) { - Text("See More") + Text("Show All") .font(.onestMedium(size: 14)) .foregroundColor(universalSecondaryColor) } From b9af8394e7616e85862cf3a0c170e781ce33e833 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:34:45 -0800 Subject: [PATCH 22/53] activity type card styling fixed for dark mode --- .../ActivityTypeCard.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift index af8d7d3a..0ae365a9 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeCard.swift @@ -10,6 +10,10 @@ struct ActivityTypeCard: View { @Environment(\.colorScheme) private var colorScheme + private var hasContextMenu: Bool { + onPin != nil && onDelete != nil && onManage != nil + } + private var adaptiveBackgroundColor: Color { switch colorScheme { case .dark: @@ -43,14 +47,6 @@ struct ActivityTypeCard: View { } } - private var backgroundFillColor: Color { - if isSelected { - return Color.blue.opacity(0.1) - } else { - return adaptiveBackgroundColor - } - } - var body: some View { let card = Button(action: { let impactGenerator = UIImpactFeedbackGenerator(style: .medium) @@ -84,7 +80,7 @@ struct ActivityTypeCard: View { .frame(width: 116, height: 116) .background( RoundedRectangle(cornerRadius: 12) - .fill(backgroundFillColor) + .fill(adaptiveBackgroundColor) ) .overlay( RoundedRectangle(cornerRadius: 12) @@ -99,9 +95,16 @@ struct ActivityTypeCard: View { ) ) ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? Color(hex: colorsIndigo500) : Color.clear, + lineWidth: isSelected ? 2.5 : 0 + ) + ) .clipShape(RoundedRectangle(cornerRadius: 12)) - if activityTypeDTO.isPinned { + if activityTypeDTO.isPinned && hasContextMenu { VStack { HStack { Image(systemName: "pin.fill") From e51295b7eeb1ad1dc2194b88b1a1e720f328867b Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:35:46 -0800 Subject: [PATCH 23/53] calendar view navigation fixed --- .../Views/Pages/Profile/MyProfile/MyProfileView.swift | 4 ---- .../Profile/Shared/FriendActivitiesCalendarView.swift | 8 +++----- .../UserProfile/Components/AddToActivityTypeView.swift | 1 + .../Views/Pages/Profile/UserProfile/UserProfileView.swift | 6 +++--- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift index b4448892..8e97af96 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift @@ -278,10 +278,6 @@ struct MyProfileView: View { profileViewModel: profileViewModel, userCreationDate: profileViewModel.userProfileInfo?.dateCreated, calendarOwnerName: nil, - onDismiss: { - // Reset navigation state when calendar view is dismissed - navigateToCalendar = false - }, onActivitySelected: { activity in // Handle single activity - fetch details and show popup directly handleActivitySelection(activity) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift index c1797fb7..ae0389ad 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift @@ -4,9 +4,8 @@ struct FriendActivitiesCalendarView: View { let user: Nameable var profileViewModel: ProfileViewModel @Binding var showActivityDetails: Bool - /// When set to false by the calendar view (e.g. back tapped), parent pops this screen so we return to the profile. - @Binding var isPresented: Bool + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @ObservedObject private var locationManager = LocationManager.shared @@ -58,7 +57,7 @@ struct FriendActivitiesCalendarView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button(action: { - isPresented = false + dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .semibold)) @@ -253,8 +252,7 @@ struct FriendActivitiesCalendarView: View { FriendActivitiesCalendarView( user: BaseUserDTO.danielAgapov, profileViewModel: viewModel, - showActivityDetails: .constant(false), - isPresented: .constant(true) + showActivityDetails: .constant(false) ) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift index b8b38acb..ac044f71 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/Components/AddToActivityTypeView.swift @@ -141,6 +141,7 @@ struct AddToActivityTypeView: View { ) } } + .frame(width: 120, height: 120) // User info text VStack(spacing: 2) { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 0183f880..15a8dae4 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -368,10 +368,10 @@ struct UserProfileView: View { .foregroundColor(.white) } else { Image(systemName: "person.badge.clock") - .foregroundColor(Color(hex: colorsGray200)) + .foregroundColor(Color(hex: colorsWhite)) Text("Request Sent") .bold() - .foregroundColor(Color(hex: colorsGray200)) + .foregroundColor(Color(hex: colorsWhite)) } } .font(.onestMedium(size: 16)) @@ -391,7 +391,7 @@ struct UserProfileView: View { RoundedRectangle(cornerRadius: 12) .stroke( profileViewModel.friendshipStatus == .requestSent - ? Color(hex: colorsGray200) + ? Color(hex: colorsWhite) : Color.clear, lineWidth: 2 ) From d075a2832bf1ec5fdaae49d23bd85687dd6e4d6a Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 04:59:30 -0800 Subject: [PATCH 24/53] screen edge padding to ensure reasonable padding for smaller iphones --- Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift | 4 ++++ .../Activities/ActivityCard/ActivityCardView.swift | 2 +- .../ActivityTypeSelection/ActivityTypeView.swift | 10 +++++----- .../Confirmation/ActivityConfirmationView.swift | 4 ++-- .../Confirmation/ActivityPreConfirmationView.swift | 8 ++++---- .../DateTimeSelection/ActivityDateTimeView.swift | 12 ++++++------ .../ActivityCreationLocationView.swift | 8 ++++---- .../Views/Pages/FeedAndMap/ActivityFeedView.swift | 10 +++++----- .../FeedAndMap/FullscreenActivityListView.swift | 2 +- 9 files changed, 32 insertions(+), 28 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift index 24c43201..56720d6f 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift @@ -15,6 +15,10 @@ let dimensionMD: CGFloat = 16 let dimensionLG: CGFloat = 32 let dimensionXL: CGFloat = 64 +/// Horizontal padding from screen edges for main content (home feed, activity creation). +/// Increased for physical devices (e.g. iPhone 11) where 32pt felt cramped. +let screenEdgePadding: CGFloat = 40 + let spacingXS: CGFloat = dimensionXS let spacingSM: CGFloat = dimensionSM let spacingMD: CGFloat = dimensionMD diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift index d27f59c9..e9b41af5 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift @@ -25,7 +25,7 @@ struct ActivityCardView: View { locationManager: LocationManager, callback: @escaping (FullFeedActivityDTO, Color) -> Void, selectedTab: Binding = .constant(nil), - horizontalPadding: CGFloat = 32 + horizontalPadding: CGFloat = screenEdgePadding ) { self.activity = activity self.color = color diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift index 0e87efeb..c7b83583 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift @@ -43,11 +43,11 @@ struct ActivityTypeView: View { .font(.caption) .foregroundColor(.red) } - .padding(.horizontal) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 8) .background(Color.red.opacity(0.1)) .cornerRadius(8) - .padding(.horizontal) + .padding(.horizontal, screenEdgePadding) } if viewModel.isLoading { @@ -178,7 +178,7 @@ extension ActivityTypeView { .font(.title3) .foregroundColor(.clear) } - .padding(.horizontal) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) } @@ -203,7 +203,7 @@ extension ActivityTypeView { .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + .padding(screenEdgePadding) } private var activityTypeGrid: some View { @@ -215,7 +215,7 @@ extension ActivityTypeView { createNewActivityButton } - .padding() + .padding(screenEdgePadding) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift index 18299b60..c78738bc 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift @@ -95,7 +95,7 @@ struct ActivityConfirmationView: View { .font(Font.custom("Onest", size: 16).weight(.medium)) .foregroundColor(adaptiveSecondaryTextColor) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, screenEdgePadding) } .padding(.bottom, 30) @@ -200,7 +200,7 @@ struct ActivityConfirmationView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift index a63e65d4..3bb38c8c 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift @@ -60,7 +60,7 @@ struct ActivityPreConfirmationView: View { // Activity card activityCardView - .padding(.horizontal, 24) + .padding(.horizontal, screenEdgePadding) // Activity title Text( @@ -87,7 +87,7 @@ struct ActivityPreConfirmationView: View { .font(.onestMedium(size: 20)) .foregroundColor(adaptiveSecondaryTextColor) .padding(.top, 12) - .padding(.horizontal, 16) + .padding(.horizontal, screenEdgePadding) Spacer() @@ -130,7 +130,7 @@ struct ActivityPreConfirmationView: View { } } } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 80) // Standard bottom padding .background(adaptiveBackgroundColor) } @@ -159,7 +159,7 @@ struct ActivityPreConfirmationView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift index 8165490a..0efa9408 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift @@ -380,7 +380,7 @@ struct ActivityDateTimeView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) } else { HStack { @@ -397,7 +397,7 @@ struct ActivityDateTimeView: View { .font(.onestSemiBold(size: 20)) .foregroundColor(.clear) } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) } Text("Set a time for your Activity") @@ -488,7 +488,7 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, 50) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 24) // Activity Duration Section @@ -508,14 +508,14 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, 50) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 50) if !viewModel.timeValidationMessage.isEmpty { Text(viewModel.timeValidationMessage) .font(.custom("Onest", size: 12)) .foregroundColor(.red) - .padding(.horizontal, 20) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 8) } @@ -549,7 +549,7 @@ struct ActivityDateTimeView: View { } } } - .padding(.horizontal, 50) + .padding(.horizontal, screenEdgePadding) // Step indicators StepIndicatorView(currentStep: 1, totalSteps: 3) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift index b8c8f5d6..2d96fb9b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift @@ -184,7 +184,7 @@ struct ActivityCreationLocationView: View { } } .frame(height: 24) - .padding(.horizontal, 26) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 6) // Search bar @@ -202,7 +202,7 @@ struct ActivityCreationLocationView: View { RoundedRectangle(cornerRadius: 8) .stroke(figmaBlack300, lineWidth: 1) ) - .padding(.horizontal, 26) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 2) // Location list @@ -263,7 +263,7 @@ struct ActivityCreationLocationView: View { } } } - .padding(.horizontal, 26) + .padding(.horizontal, screenEdgePadding) Spacer() } Spacer() @@ -437,7 +437,7 @@ struct ActivityCreationLocationView: View { .padding(.bottom, 8) } } - .padding(.horizontal, 26) + .padding(.horizontal, screenEdgePadding) .padding(.bottom, 10) .background( universalBackgroundColor diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift index 7db7ff54..d7f88709 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift @@ -15,7 +15,7 @@ struct ActivityFeedView: View { @State private var activityInPopup: FullFeedActivityDTO? @State private var colorInPopup: Color? @Binding private var selectedTab: TabType - private let horizontalSubHeadingPadding: CGFloat = 32 + private let horizontalSubHeadingPadding: CGFloat = screenEdgePadding private let bottomSubHeadingPadding: CGFloat = 14 @State private var showFullActivitiesList: Bool = false @Environment(\.dismiss) private var dismiss @@ -63,7 +63,7 @@ struct ActivityFeedView: View { HeaderView(user: user) .padding(.bottom, 30) .padding(.top, 60) - .padding(.horizontal, 32) + .padding(.horizontal, screenEdgePadding) // Spawn In! row HStack { @@ -74,12 +74,12 @@ struct ActivityFeedView: View { seeAllActivityTypesButton } .padding(.bottom, 20) - .padding(.horizontal, 32) + .padding(.horizontal, screenEdgePadding) // Activity Types row activityTypeListView .padding(.bottom, 30) - .padding(.horizontal, 32) + .padding(.horizontal, screenEdgePadding) // Activities in Your Area row HStack { @@ -90,7 +90,7 @@ struct ActivityFeedView: View { seeAllActivitiesButton } .padding(.bottom, 14) - .padding(.horizontal, 32) + .padding(.horizontal, screenEdgePadding) // Activities - no container padding, cards will handle their own ActivityListView( diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift index dc858040..e6aa971c 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift @@ -34,7 +34,7 @@ struct FullscreenActivityListView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, 25) + .padding(.horizontal, screenEdgePadding) .padding(.vertical, 12) ActivityListView(viewModel: viewModel, user: user, callback: callback) From c9ba11d0c3ace70676bce8102ae6f307ab7a09c1 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:06:46 -0800 Subject: [PATCH 25/53] friend activities loading fixed --- .../Profile/MyProfile/Calendar/ProfileCalendarView.swift | 8 +++++++- .../Profile/Shared/Calendar/ActivityCalendarView.swift | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift index 63993e50..6c816907 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift @@ -21,6 +21,8 @@ struct ProfileCalendarView: View { // Whether to show the month/year header (default true for backwards compatibility) var showMonthHeader: Bool = true + // When set, fetches calendar data for the friend instead of the current user + var friendUserId: UUID? = nil @Environment(\.colorScheme) private var colorScheme @State private var currentDate = Date() @@ -268,7 +270,11 @@ struct ProfileCalendarView: View { private func fetchCalendarData() { Task { - await profileViewModel.fetchAllCalendarActivities() + if let friendUserId = friendUserId { + await profileViewModel.fetchAllCalendarActivities(friendUserId: friendUserId) + } else { + await profileViewModel.fetchAllCalendarActivities() + } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift index 81989c76..92ea7068 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/Calendar/ActivityCalendarView.swift @@ -15,6 +15,7 @@ struct ActivityCalendarView: View { let userCreationDate: Date? let calendarOwnerName: String? + var friendUserId: UUID? = nil @State private var currentMonth = Date() @State private var scrollOffset: CGFloat = 0 @@ -200,7 +201,11 @@ struct ActivityCalendarView: View { private func fetchCalendarData() { Task { - await profileViewModel.fetchAllCalendarActivities() + if let friendUserId = friendUserId { + await profileViewModel.fetchAllCalendarActivities(friendUserId: friendUserId) + } else { + await profileViewModel.fetchAllCalendarActivities() + } } } From 74284cb739c09ae07bdcf9cf6cf6c40473f2d068 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:07:04 -0800 Subject: [PATCH 26/53] friend calendar pop out working --- .../Shared/FriendActivitiesCalendarView.swift | 151 +++++++----------- 1 file changed, 55 insertions(+), 96 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift index ae0389ad..8b2178d2 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift @@ -6,14 +6,13 @@ struct FriendActivitiesCalendarView: View { @Binding var showActivityDetails: Bool @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme @ObservedObject private var locationManager = LocationManager.shared @State private var showFullActivityList: Bool = false - - private var emptyDayCellColor: Color { - colorScheme == .dark ? Color(hex: colorsGray700) : Color(hex: colorsGray200) - } + @State private var showCalendarPopup: Bool = false + @State private var navigateToCalendar: Bool = false + @State private var navigateToDayActivities: Bool = false + @State private var selectedDayActivities: [CalendarActivityDTO] = [] private var sortedActivities: [ProfileActivityDTO] { let upcomingActivities = profileViewModel.profileActivities @@ -45,7 +44,17 @@ struct FriendActivitiesCalendarView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { activitiesSection - calendarSection + + ProfileCalendarView( + profileViewModel: profileViewModel, + showCalendarPopup: $showCalendarPopup, + showActivityDetails: $showActivityDetails, + navigateToCalendar: $navigateToCalendar, + navigateToDayActivities: $navigateToDayActivities, + selectedDayActivities: $selectedDayActivities, + showMonthHeader: true, + friendUserId: user.id + ) } .padding(.horizontal, 16) .padding(.top, 16) @@ -72,6 +81,24 @@ struct FriendActivitiesCalendarView: View { showActivityDetails: $showActivityDetails ) } + .navigationDestination(isPresented: $navigateToCalendar) { + ActivityCalendarView( + profileViewModel: profileViewModel, + userCreationDate: profileViewModel.userProfileInfo?.dateCreated, + calendarOwnerName: FormatterService.shared.formatFirstName(user: user), + friendUserId: user.id, + onActivitySelected: { activity in + handleCalendarActivitySelection(activity) + }, + onDayActivitiesSelected: { activities in + selectedDayActivities = activities + navigateToDayActivities = true + } + ) + } + .navigationDestination(isPresented: $navigateToDayActivities) { + calendarDayActivitiesPageView + } } // MARK: - Activities Section @@ -139,102 +166,34 @@ struct FriendActivitiesCalendarView: View { ) } - // MARK: - Calendar Section - private var calendarSection: some View { - VStack(spacing: 16) { - HStack(spacing: 6.618) { - ForEach(Array(["S", "M", "T", "W", "T", "F", "S"].enumerated()), id: \.offset) { _, day in - Text(day) - .font(.onestMedium(size: 13)) - .foregroundColor(universalAccentColor) - .frame(width: 46.33) - } - } - - if profileViewModel.isLoadingCalendar { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 150) - } else { - VStack(spacing: 6.618) { - ForEach(0..<5, id: \.self) { row in - HStack(spacing: 6.618) { - ForEach(0..<7, id: \.self) { col in - calendarDayCell(row: row, col: col) - } - } - } - } - } - } - } - - @ViewBuilder - private func calendarDayCell(row: Int, col: Int) -> some View { - if row < profileViewModel.calendarActivities.count, - col < profileViewModel.calendarActivities[row].count, - let activity = profileViewModel.calendarActivities[row][col] - { - FriendCalendarDayCell(activity: activity) - } else { - emptyDayCell(row: row, col: col) - } - } + // MARK: - Calendar Navigation Helpers - @ViewBuilder - private func emptyDayCell(row: Int, col: Int) -> some View { - let isOutsideMonth = isDayOutsideCurrentMonth(row: row, col: col) + private var calendarDayActivitiesPageView: some View { + let date = selectedDayActivities.first?.dateAsDate ?? Date() - if isOutsideMonth { - RoundedRectangle(cornerRadius: 6.618) - .stroke(style: StrokeStyle(lineWidth: 1.655, dash: [6, 6])) - .foregroundColor(universalAccentColor.opacity(0.1)) - .frame(width: 46.33, height: 46.33) - } else { - ZStack { - RoundedRectangle(cornerRadius: 6.618) - .fill(emptyDayCellColor) - .frame(width: 46.33, height: 46.33) - .shadow(color: Color.black.opacity(0.1), radius: 6.618, x: 0, y: 1.655) - - RoundedRectangle(cornerRadius: 6.618) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.5), Color.clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: 46.33, height: 46.33) - .allowsHitTesting(false) + return DayActivitiesPageView( + date: date, + initialActivities: selectedDayActivities, + onDismiss: { + navigateToDayActivities = false + }, + onActivitySelected: { activity in + navigateToDayActivities = false + handleCalendarActivitySelection(activity) } - } + ) } - private func isDayOutsideCurrentMonth(row: Int, col: Int) -> Bool { - let calendar = Calendar.current - let now = Date() - let currentMonth = calendar.component(.month, from: now) - let currentYear = calendar.component(.year, from: now) - - var components = DateComponents() - components.year = currentYear - components.month = currentMonth - components.day = 1 - - guard let firstOfMonth = calendar.date(from: components) else { - return false - } - - let weekday = calendar.component(.weekday, from: firstOfMonth) - let firstDayOffset = weekday - 1 - - guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { - return false + private func handleCalendarActivitySelection(_ activity: CalendarActivityDTO) { + Task { + if let activityId = activity.activityId, + await profileViewModel.fetchActivityDetails(activityId: activityId) != nil + { + await MainActor.run { + showActivityDetails = true + } + } } - let daysInMonth = range.count - let dayIndex = row * 7 + col - - return dayIndex < firstDayOffset || dayIndex >= firstDayOffset + daysInMonth } } From e9c33f098e0fd6368c1be1ae48d2fba5b19ddd1d Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:07:23 -0800 Subject: [PATCH 27/53] friend activities standardized components --- .../Shared/FriendActivitiesShowAllView.swift | 30 +++++------- .../Profile/UserProfile/UserProfileView.swift | 49 ++++++------------- 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift index 3b588b8a..07164dee 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift @@ -36,8 +36,18 @@ struct FriendActivitiesShowAllView: View { } } .navigationBarHidden(true) - .sheet(isPresented: $showActivityDetails) { - activityDetailsView + .onChange(of: showActivityDetails) { _, isShowing in + if isShowing, let activity = profileViewModel.selectedActivity { + let activityColor = getActivityColor(for: activity.id) + + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": activity, "color": activityColor] + ) + showActivityDetails = false + profileViewModel.selectedActivity = nil + } } } .onAppear { @@ -185,22 +195,6 @@ struct FriendActivitiesShowAllView: View { } } - private var activityDetailsView: some View { - Group { - if let activity = profileViewModel.selectedActivity { - let activityColor = getActivityColor(for: activity.id) - - ActivityDescriptionView( - activity: activity, - users: activity.participantUsers, - color: activityColor, - userId: UserAuthViewModel.shared.spawnUser?.id ?? UUID() - ) - .presentationDetents([.medium, .large]) - } - } - } - // MARK: - Helper Methods private func fetchFriendData() async { // Data is already loaded from the parent view diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 15a8dae4..a426ae5d 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -179,8 +179,6 @@ struct UserProfileView: View { profileWithOverlay .modifier( UserProfileSheetsModifier( - showActivityDetails: $showActivityDetails, - activityDetailsView: AnyView(activityDetailsView), showRemoveFriendConfirmation: $showRemoveFriendConfirmation, removeFriendConfirmationAlert: AnyView(removeFriendConfirmationAlert), showReportDialog: $showReportDialog, @@ -191,6 +189,19 @@ struct UserProfileView: View { profileMenuSheet: AnyView(profileMenuSheet) ) ) + .onChange(of: showActivityDetails) { _, isShowing in + if isShowing, let activity = profileViewModel.selectedActivity { + let activityColor = getActivityColor(for: activity.id) + + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": activity, "color": activityColor] + ) + showActivityDetails = false + profileViewModel.selectedActivity = nil + } + } .onTapGesture { // Dismiss profile menu if it's showing if showProfileMenu { @@ -368,10 +379,10 @@ struct UserProfileView: View { .foregroundColor(.white) } else { Image(systemName: "person.badge.clock") - .foregroundColor(Color(hex: colorsWhite)) + .foregroundColor(universalAccentColor) Text("Request Sent") .bold() - .foregroundColor(Color(hex: colorsWhite)) + .foregroundColor(universalAccentColor) } } .font(.onestMedium(size: 16)) @@ -391,7 +402,7 @@ struct UserProfileView: View { RoundedRectangle(cornerRadius: 12) .stroke( profileViewModel.friendshipStatus == .requestSent - ? Color(hex: colorsWhite) + ? universalAccentColor : Color.clear, lineWidth: 2 ) @@ -474,29 +485,6 @@ struct UserProfileView: View { } } - private var activityDetailsView: some View { - Group { - if profileViewModel.selectedActivity != nil { - EmptyView() // Replaced with global popup system - } - } - .onChange(of: showActivityDetails) { _, isShowing in - if isShowing, let activity = profileViewModel.selectedActivity { - let activityColor = getActivityColor(for: activity.id) - - // Post notification to show global popup - NotificationCenter.default.post( - name: .showGlobalActivityPopup, - object: nil, - userInfo: ["activity": activity, "color": activityColor] - ) - // Reset local state since global popup will handle it - showActivityDetails = false - profileViewModel.selectedActivity = nil - } - } - } - // MARK: - Sub-expressions for better type checking private var reportUserDrawer: some View { @@ -790,8 +778,6 @@ struct UserProfileView: View { // MARK: - UserProfile Sheets Modifier struct UserProfileSheetsModifier: ViewModifier { - @Binding var showActivityDetails: Bool - var activityDetailsView: AnyView @Binding var showRemoveFriendConfirmation: Bool var removeFriendConfirmationAlert: AnyView @Binding var showReportDialog: Bool @@ -803,9 +789,6 @@ struct UserProfileSheetsModifier: ViewModifier { func body(content: Content) -> some View { content - .sheet(isPresented: $showActivityDetails) { - activityDetailsView - } .confirmationDialog( "Remove this friend?", isPresented: $showRemoveFriendConfirmation, From 1ad188f27ed002df34da805d825c207f2e41c939 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:08:10 -0800 Subject: [PATCH 28/53] alerts -> in-app notifications, for consistency --- .../Activity/ActivityTypeViewModel.swift | 13 +++--- .../ViewModels/Profile/ProfileViewModel.swift | 3 +- .../ActivityTypeEditView.swift | 13 ------ .../ActivityTypeManagementView.swift | 13 ------ .../ActivityTypeView.swift | 13 ------ .../ActivityDetail/ActivityEditView.swift | 13 ------ .../Components/InterestsSection.swift | 22 +++++----- .../EditProfile/EditProfileView.swift | 23 ++--------- .../Settings/ChangePasswordView.swift | 40 ++++++++----------- 9 files changed, 38 insertions(+), 115 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift index 8c61afd0..a3ef3f3e 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Activity/ActivityTypeViewModel.swift @@ -160,7 +160,10 @@ final class ActivityTypeViewModel { if willBePinned { if currentPinnedCount >= 4 { print("❌ Cannot pin: Already at maximum of 4 pinned activity types") - errorMessage = "You can only pin up to 4 activity types" + notificationService.showErrorMessage( + "You can only pin up to 4 activity types", + title: "Pin Limit Reached" + ) return } } @@ -234,7 +237,10 @@ final class ActivityTypeViewModel { /// Removes a friend from an activity type func removeFriendFromActivityType(activityTypeId: UUID, friendId: UUID) async { guard let activityType = activityTypes.first(where: { $0.id == activityTypeId }) else { - errorMessage = "Activity type not found" + notificationService.showErrorMessage( + "Activity type not found", + title: "Error" + ) return } @@ -279,9 +285,7 @@ final class ActivityTypeViewModel { print("❌ Error updating activity type: \(error)") print("❌ Error details: \(ErrorFormattingService.shared.formatError(error))") - // Check if error is related to pinning limits let formattedError = ErrorFormattingService.shared.formatError(error) - print("πŸ” Formatted error from server: \(formattedError)") if formattedError.contains("pinned activity types") { print("⚠️ Server returned pinning limit error - this might be a server-side validation bug") errorMessage = "You can only pin up to 4 activity types" @@ -294,7 +298,6 @@ final class ActivityTypeViewModel { error, resource: .activityType, operation: .update) } - // Refresh from API to get correct state await fetchActivityTypes() } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift index 51dd032d..f664413a 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift @@ -180,9 +180,8 @@ final class ProfileViewModel { return true case .failure(let error): - // Revert local state if API call fails self.userInterests.removeAll { $0 == interest } - self.errorMessage = notificationService.handleError( + _ = notificationService.handleError( error, resource: .profile, operation: .update) return false } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift index 44b7b119..568a29cc 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeEditView.swift @@ -11,7 +11,6 @@ struct ActivityTypeEditView: View { @State private var hasChanges: Bool = false @State private var friendSelectionActivityType: ActivityTypeDTO? @State private var showEmojiPicker: Bool = false - @State private var showErrorAlert: Bool = false @FocusState private var isTitleFieldFocused: Bool @State private var viewModel: ActivityTypeViewModel @@ -61,18 +60,6 @@ struct ActivityTypeEditView: View { ) .environmentObject(AppCache.shared) } - .alert("Error", isPresented: $showErrorAlert) { - Button("OK") { - viewModel.clearError() - } - } message: { - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - } - } - .onChange(of: viewModel.errorMessage) { _, newValue in - showErrorAlert = newValue != nil - } .overlay(loadingOverlay) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift index 15e0a140..188e8c40 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeManagementView.swift @@ -9,7 +9,6 @@ struct ActivityTypeManagementView: View { @State private var showingEditView = false @State private var navigateToProfile = false @State private var selectedUserForProfile: MinimalFriendDTO? - @State private var showErrorAlert = false // Store background refresh task so we can cancel it on disappear @State private var backgroundRefreshTask: Task? @@ -175,18 +174,6 @@ struct ActivityTypeManagementView: View { } } } - .alert("Error", isPresented: $showErrorAlert) { - Button("OK") { - viewModel.clearError() - } - } message: { - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - } - } - .onChange(of: viewModel.errorMessage) { _, newValue in - showErrorAlert = newValue != nil - } // Custom popup overlay if showingOptions { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift index c7b83583..8fc9e7a8 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift @@ -14,7 +14,6 @@ struct ActivityTypeView: View { // Delete confirmation state @State private var showDeleteConfirmation = false @State private var activityTypeToDelete: ActivityTypeDTO? - @State private var showErrorAlert = false // Store background refresh task so we can cancel it on disappear @State private var backgroundRefreshTask: Task? @@ -100,18 +99,6 @@ struct ActivityTypeView: View { backgroundRefreshTask?.cancel() backgroundRefreshTask = nil } - .alert("Error", isPresented: $showErrorAlert) { - Button("OK") { - viewModel.clearError() - } - } message: { - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - } - } - .onChange(of: viewModel.errorMessage) { _, newValue in - showErrorAlert = newValue != nil - } .alert("Delete Activity Type", isPresented: $showDeleteConfirmation) { Button("Cancel", role: .cancel) { activityTypeToDelete = nil diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift index 0c1a4934..155bbabd 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityDetail/ActivityEditView.swift @@ -13,7 +13,6 @@ struct ActivityEditView: View { @State private var hasChanges: Bool = false @State private var showSuccessMessage: Bool = false @State private var showSaveConfirmation: Bool = false - @State private var showErrorAlert: Bool = false @FocusState private var isTitleFieldFocused: Bool private var adaptiveBackgroundColor: Color { @@ -173,18 +172,6 @@ struct ActivityEditView: View { .sheet(isPresented: $showEmojiPicker) { ElegantEmojiPickerView(selectedEmoji: $editedIcon, isPresented: $showEmojiPicker) } - .alert("Error", isPresented: $showErrorAlert) { - Button("OK") { - viewModel.clearError() - } - } message: { - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - } - } - .onChange(of: viewModel.errorMessage) { _, newValue in - showErrorAlert = newValue != nil - } .alert("Save All Changes?", isPresented: $showSaveConfirmation) { Button("Don't Save", role: .destructive) { // Reset to original values and dismiss diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift index 15eab52d..7f12e950 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift @@ -6,8 +6,6 @@ struct InterestsSection: View { let userId: UUID @Binding var newInterest: String let maxInterests: Int - @Binding var showAlert: Bool - @Binding var alertMessage: String @FocusState private var isTextFieldFocused: Bool var body: some View { @@ -67,23 +65,23 @@ struct InterestsSection: View { private func addInterest() { guard !newInterest.isEmpty else { return } guard profileViewModel.userInterests.count < maxInterests else { - alertMessage = "You can have a maximum of \(maxInterests) interests" - showAlert = true + InAppNotificationService.shared.showErrorMessage( + "You can have a maximum of \(maxInterests) interests", + title: "Limit Reached" + ) return } let interest = newInterest.trimmingCharacters(in: .whitespacesAndNewlines) - // Don't add duplicates - if !profileViewModel.userInterests.contains(interest) { - // Only update local state - don't call API until save + let isDuplicate = profileViewModel.userInterests.contains { + $0.caseInsensitiveCompare(interest) == .orderedSame + } + if !isDuplicate { profileViewModel.userInterests.append(interest) - newInterest = "" - isTextFieldFocused = false // Dismiss keyboard - } else { - newInterest = "" - isTextFieldFocused = false // Dismiss keyboard } + newInterest = "" + isTextFieldFocused = false } private func removeInterest(_ interest: String) { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift index 428199b3..666b53ca 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift @@ -15,8 +15,6 @@ struct EditProfileView: View { @State private var whatsappLink: String @State private var instagramLink: String @State private var isSaving: Bool = false - @State private var showAlert: Bool = false - @State private var alertMessage: String = "" // User ID to edit let userId: UUID @@ -114,9 +112,7 @@ struct EditProfileView: View { profileViewModel: profileViewModel, userId: userId, newInterest: $newInterest, - maxInterests: maxInterests, - showAlert: $showAlert, - alertMessage: $alertMessage + maxInterests: maxInterests ) // Third party apps section @@ -137,13 +133,6 @@ struct EditProfileView: View { SwiftUIImagePicker(selectedImage: $selectedImage) .ignoresSafeArea() } - .alert(isPresented: $showAlert) { - Alert( - title: Text("Profile Update"), - message: Text(alertMessage), - dismissButton: .default(Text("OK")) - ) - } .onAppear { // Save original interests for cancel functionality profileViewModel.saveOriginalInterests() @@ -231,14 +220,8 @@ struct EditProfileView: View { await MainActor.run { isSaving = false - alertMessage = "Profile updated successfully" - showAlert = true - - // Dismiss after a short delay - Task { @MainActor in - try? await Task.sleep(for: .seconds(1.5)) - presentationMode.wrappedValue.dismiss() - } + InAppNotificationService.shared.showSuccess(.profileUpdated) + presentationMode.wrappedValue.dismiss() } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/ChangePasswordView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/ChangePasswordView.swift index b1cc7961..81e1b3ae 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/ChangePasswordView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/ChangePasswordView.swift @@ -12,9 +12,6 @@ struct ChangePasswordView: View { @State private var currentPassword = "" @State private var newPassword = "" @State private var confirmPassword = "" - @State private var showAlert = false - @State private var alertMessage = "" - @State private var isSuccess = false @ObservedObject var userAuth = UserAuthViewModel.shared // Show/hide password toggles @@ -136,14 +133,18 @@ struct ChangePasswordView: View { Button(action: { if newPassword.isEmpty || confirmPassword.isEmpty || currentPassword.isEmpty { - alertMessage = "Please fill in all fields" - showAlert = true + InAppNotificationService.shared.showErrorMessage( + "Please fill in all fields", + title: "Missing Fields" + ) return } if newPassword != confirmPassword { - alertMessage = "New passwords don't match" - showAlert = true + InAppNotificationService.shared.showErrorMessage( + "New passwords don't match", + title: "Password Mismatch" + ) return } @@ -151,13 +152,15 @@ struct ChangePasswordView: View { do { try await userAuth.changePassword( currentPassword: currentPassword, newPassword: newPassword) - alertMessage = "Password successfully changed" - isSuccess = true - showAlert = true + await MainActor.run { + InAppNotificationService.shared.showSuccess(.passwordChanged) + presentationMode.wrappedValue.dismiss() + } } catch { - alertMessage = "Failed to change password: \(error.localizedDescription)" - isSuccess = false - showAlert = true + InAppNotificationService.shared.showErrorMessage( + "Failed to change password: \(error.localizedDescription)", + title: "Error" + ) } } }) { @@ -175,17 +178,6 @@ struct ChangePasswordView: View { } .background(universalBackgroundColor) .navigationBarHidden(true) - .alert(isPresented: $showAlert) { - Alert( - title: Text(isSuccess ? "Success" : "Error"), - message: Text(alertMessage), - dismissButton: .default(Text("OK")) { - if isSuccess { - presentationMode.wrappedValue.dismiss() - } - } - ) - } } } From 82635a644afcb96bf835d74d03748788fee84045 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:23:05 -0800 Subject: [PATCH 29/53] Fix activities popping up for friends & back page navigation states --- .../Views/Pages/Friends/FriendsView.swift | 4 +- .../Calendar/ProfileCalendarView.swift | 36 +++-------- .../Shared/FriendActivitiesCalendarView.swift | 14 ++++- .../Shared/FriendActivitiesShowAllView.swift | 61 ++++++++----------- .../Profile/UserProfile/UserProfileView.swift | 61 +++++++++---------- 5 files changed, 77 insertions(+), 99 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsView.swift index 257ee08c..87382554 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsView.swift @@ -106,14 +106,12 @@ struct FriendsView: View { case .success(let fetchedUser, source: _): // Navigate to the profile await MainActor.run { - let profileView = UserProfileView(user: fetchedUser) + let profileView = NavigationStack { UserProfileView(user: fetchedUser) } - // Get the current window and present the profile if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let rootViewController = window.rootViewController { - let hostingController = UIHostingController(rootView: profileView) rootViewController.present(hostingController, animated: true) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift index 6c816907..80f35fed 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Calendar/ProfileCalendarView.swift @@ -14,7 +14,6 @@ struct ProfileCalendarView: View { @ObservedObject var userAuth = UserAuthViewModel.shared @Binding var showCalendarPopup: Bool - @Binding var showActivityDetails: Bool @Binding var navigateToCalendar: Bool @Binding var navigateToDayActivities: Bool @Binding var selectedDayActivities: [CalendarActivityDTO] @@ -74,29 +73,6 @@ struct ProfileCalendarView: View { .onAppear { fetchCalendarData() } - .overlay( - // Use the same ActivityPopupDrawer as the feed view for consistency - Group { - if showActivityDetails, profileViewModel.selectedActivity != nil { - EmptyView() // Replaced with global popup system - } - } - ) - .onChange(of: showActivityDetails) { _, isShowing in - if isShowing, let activity = profileViewModel.selectedActivity { - let activityColor = getActivityColor(for: activity) - - // Post notification to show global popup - NotificationCenter.default.post( - name: .showGlobalActivityPopup, - object: nil, - userInfo: ["activity": activity, "color": activityColor] - ) - // Reset local state since global popup will handle it - showActivityDetails = false - profileViewModel.selectedActivity = nil - } - } } // MARK: - Helper Functions @@ -235,16 +211,19 @@ struct ProfileCalendarView: View { } private func handleActivitySelection(_ activity: CalendarActivityDTO) { - // First close the calendar popup showCalendarPopup = false - // Then fetch and show the activity details Task { if let activityId = activity.activityId, - await profileViewModel.fetchActivityDetails(activityId: activityId) != nil + let fullActivity = await profileViewModel.fetchActivityDetails(activityId: activityId) { await MainActor.run { - showActivityDetails = true + let activityColor = getActivityColor(for: fullActivity) + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": fullActivity, "color": activityColor] + ) } } } @@ -321,7 +300,6 @@ struct ProfileCalendarView: View { ProfileCalendarView( profileViewModel: ProfileViewModel(userId: UUID()), showCalendarPopup: .constant(false), - showActivityDetails: .constant(false), navigateToCalendar: .constant(false), navigateToDayActivities: .constant(false), selectedDayActivities: .constant([]) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift index 8b2178d2..7f46a90b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesCalendarView.swift @@ -48,7 +48,6 @@ struct FriendActivitiesCalendarView: View { ProfileCalendarView( profileViewModel: profileViewModel, showCalendarPopup: $showCalendarPopup, - showActivityDetails: $showActivityDetails, navigateToCalendar: $navigateToCalendar, navigateToDayActivities: $navigateToDayActivities, selectedDayActivities: $selectedDayActivities, @@ -74,6 +73,19 @@ struct FriendActivitiesCalendarView: View { } } } + .onChange(of: showActivityDetails) { _, isShowing in + if isShowing, let activity = profileViewModel.selectedActivity { + let activityColor = getActivityColor(for: activity.id) + + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": activity, "color": activityColor] + ) + showActivityDetails = false + profileViewModel.selectedActivity = nil + } + } .navigationDestination(isPresented: $showFullActivityList) { FriendActivitiesShowAllView( user: user, diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift index 07164dee..457dc459 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/FriendActivitiesShowAllView.swift @@ -11,43 +11,36 @@ struct FriendActivitiesShowAllView: View { @ObservedObject private var locationManager = LocationManager.shared var body: some View { - NavigationStack { - ZStack { - // Background - universalBackgroundColor - .ignoresSafeArea() - - VStack(spacing: 0) { - // Header - headerView - - ScrollView { - VStack(spacing: 12) { - // Upcoming Activities Section - upcomingActivitiesSection - - // Past Activities Section - pastActivitiesSection - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 100) // Account for tab bar + ZStack { + universalBackgroundColor + .ignoresSafeArea() + + VStack(spacing: 0) { + headerView + + ScrollView { + VStack(spacing: 12) { + upcomingActivitiesSection + pastActivitiesSection } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 100) } } - .navigationBarHidden(true) - .onChange(of: showActivityDetails) { _, isShowing in - if isShowing, let activity = profileViewModel.selectedActivity { - let activityColor = getActivityColor(for: activity.id) - - NotificationCenter.default.post( - name: .showGlobalActivityPopup, - object: nil, - userInfo: ["activity": activity, "color": activityColor] - ) - showActivityDetails = false - profileViewModel.selectedActivity = nil - } + } + .navigationBarHidden(true) + .onChange(of: showActivityDetails) { _, isShowing in + if isShowing, let activity = profileViewModel.selectedActivity { + let activityColor = getActivityColor(for: activity.id) + + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": activity, "color": activityColor] + ) + showActivityDetails = false + profileViewModel.selectedActivity = nil } } .onAppear { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index a426ae5d..32e9becd 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -175,41 +175,38 @@ struct UserProfileView: View { // Main content broken into a separate computed property to reduce complexity private var profileContent: some View { - NavigationStack { - profileWithOverlay - .modifier( - UserProfileSheetsModifier( - showRemoveFriendConfirmation: $showRemoveFriendConfirmation, - removeFriendConfirmationAlert: AnyView(removeFriendConfirmationAlert), - showReportDialog: $showReportDialog, - reportUserDrawer: AnyView(reportUserDrawer), - showBlockDialog: $showBlockDialog, - blockUserAlert: AnyView(blockUserAlert), - showProfileMenu: $showProfileMenu, - profileMenuSheet: AnyView(profileMenuSheet) - ) + profileWithOverlay + .modifier( + UserProfileSheetsModifier( + showRemoveFriendConfirmation: $showRemoveFriendConfirmation, + removeFriendConfirmationAlert: AnyView(removeFriendConfirmationAlert), + showReportDialog: $showReportDialog, + reportUserDrawer: AnyView(reportUserDrawer), + showBlockDialog: $showBlockDialog, + blockUserAlert: AnyView(blockUserAlert), + showProfileMenu: $showProfileMenu, + profileMenuSheet: AnyView(profileMenuSheet) ) - .onChange(of: showActivityDetails) { _, isShowing in - if isShowing, let activity = profileViewModel.selectedActivity { - let activityColor = getActivityColor(for: activity.id) - - NotificationCenter.default.post( - name: .showGlobalActivityPopup, - object: nil, - userInfo: ["activity": activity, "color": activityColor] - ) - showActivityDetails = false - profileViewModel.selectedActivity = nil - } + ) + .onChange(of: showActivityDetails) { _, isShowing in + if isShowing, let activity = profileViewModel.selectedActivity { + let activityColor = getActivityColor(for: activity.id) + + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": activity, "color": activityColor] + ) + showActivityDetails = false + profileViewModel.selectedActivity = nil } - .onTapGesture { - // Dismiss profile menu if it's showing - if showProfileMenu { - showProfileMenu = false - } + } + .onTapGesture { + if showProfileMenu { + showProfileMenu = false } - .background(universalBackgroundColor) - } + } + .background(universalBackgroundColor) } private var profileWithOverlay: some View { From b2cc9c83e8517efac1a0bfe6d5834aca51bd74e0 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:37:05 -0800 Subject: [PATCH 30/53] fix profile editing states --- .../DataService/Services/DataWriter.swift | 12 +-- .../ViewModels/Profile/ProfileViewModel.swift | 99 +++++++++++++++---- .../Components/InterestsSection.swift | 7 +- .../EditProfile/EditProfileView.swift | 5 - .../Profile/MyProfile/MyProfileView.swift | 1 - 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/DataService/Services/DataWriter.swift b/Spawn-App-iOS-SwiftUI/Services/DataService/Services/DataWriter.swift index 69d60108..0700f6a8 100644 --- a/Spawn-App-iOS-SwiftUI/Services/DataService/Services/DataWriter.swift +++ b/Spawn-App-iOS-SwiftUI/Services/DataService/Services/DataWriter.swift @@ -217,14 +217,8 @@ final class DataWriter: IDataWriter { print("πŸ—‘οΈ [DataWriter] Invalidating cache keys: \(keys.joined(separator: ", "))") - // For now, we'll just log. In a more sophisticated implementation, - // we would have a cache invalidation mechanism in AppCache - // that could selectively clear or refresh specific cache keys. - - // Future enhancement: Add a method to AppCache like: - // appCache.invalidateCacheKeys(keys) - - // For now, we can trigger a background refresh of affected data - // by posting notifications or calling refresh methods + // Note: Cache invalidation is handled at the ViewModel level through + // force-refresh (apiOnly) reads after write operations. The ViewModel's + // loadAllProfileData() always uses apiOnly to ensure fresh data. } } diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift index f664413a..e0c81a82 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift @@ -112,7 +112,7 @@ final class ProfileViewModel { .store(in: &cancellables) } - func fetchUserStats(userId: UUID) async { + func fetchUserStats(userId: UUID, forceRefresh: Bool = false) async { // Check if user is still authenticated before making API call guard UserAuthViewModel.shared.spawnUser != nil, UserAuthViewModel.shared.isLoggedIn else { print("Cannot fetch user stats: User is not logged in") @@ -120,33 +120,59 @@ final class ProfileViewModel { return } + let cachePolicy: CachePolicy = forceRefresh ? .apiOnly : .cacheFirst(backgroundRefresh: false) let result: DataResult = await dataService.read( .profileStats(userId: userId), - cachePolicy: .cacheFirst(backgroundRefresh: true) + cachePolicy: cachePolicy ) switch result { - case .success(let stats, _): + case .success(let stats, let source): self.userStats = stats self.isLoadingStats = false + if source == .cache { + Task { @MainActor in + let freshResult: DataResult = await self.dataService.read( + .profileStats(userId: userId), + cachePolicy: .apiOnly + ) + if case .success(let freshStats, _) = freshResult { + self.userStats = freshStats + } + } + } + case .failure(let error): self.errorMessage = ErrorFormattingService.shared.formatError(error) self.isLoadingStats = false } } - func fetchUserInterests(userId: UUID) async { + func fetchUserInterests(userId: UUID, forceRefresh: Bool = false) async { + let cachePolicy: CachePolicy = forceRefresh ? .apiOnly : .cacheFirst(backgroundRefresh: false) let result: DataResult<[String]> = await dataService.read( .profileInterests(userId: userId), - cachePolicy: .cacheFirst(backgroundRefresh: true) + cachePolicy: cachePolicy ) switch result { - case .success(let interests, _): + case .success(let interests, let source): self.userInterests = interests self.isLoadingInterests = false + if source == .cache { + Task { @MainActor in + let freshResult: DataResult<[String]> = await self.dataService.read( + .profileInterests(userId: userId), + cachePolicy: .apiOnly + ) + if case .success(let freshInterests, _) = freshResult { + self.userInterests = freshInterests + } + } + } + case .failure(let error): self.errorMessage = ErrorFormattingService.shared.formatError(error) self.isLoadingInterests = false @@ -154,6 +180,12 @@ final class ProfileViewModel { } func addUserInterest(userId: UUID, interest: String) async -> Bool { + // Don't add if already present (case-insensitive) + let isDuplicate = self.userInterests.contains { + $0.caseInsensitiveCompare(interest) == .orderedSame + } + guard !isDuplicate else { return true } + // Update local state immediately for better UX self.userInterests.append(interest) @@ -187,17 +219,30 @@ final class ProfileViewModel { } } - func fetchUserSocialMedia(userId: UUID) async { + func fetchUserSocialMedia(userId: UUID, forceRefresh: Bool = false) async { + let cachePolicy: CachePolicy = forceRefresh ? .apiOnly : .cacheFirst(backgroundRefresh: false) let result: DataResult = await dataService.read( .profileSocialMedia(userId: userId), - cachePolicy: .cacheFirst(backgroundRefresh: true) + cachePolicy: cachePolicy ) switch result { - case .success(let socialMedia, _): + case .success(let socialMedia, let source): self.userSocialMedia = socialMedia self.isLoadingSocialMedia = false + if source == .cache { + Task { @MainActor in + let freshResult: DataResult = await self.dataService.read( + .profileSocialMedia(userId: userId), + cachePolicy: .apiOnly + ) + if case .success(let freshSocialMedia, _) = freshResult { + self.userSocialMedia = freshSocialMedia + } + } + } + case .failure(let error): self.errorMessage = ErrorFormattingService.shared.formatError(error) self.isLoadingSocialMedia = false @@ -228,7 +273,7 @@ final class ProfileViewModel { } } - func fetchUserProfileInfo(userId: UUID, requestingUserId: UUID? = nil) async { + func fetchUserProfileInfo(userId: UUID, requestingUserId: UUID? = nil, forceRefresh: Bool = false) async { // Check if user is still authenticated before making API call guard UserAuthViewModel.shared.spawnUser != nil, UserAuthViewModel.shared.isLoggedIn else { print("Cannot fetch profile info: User is not logged in") @@ -238,13 +283,13 @@ final class ProfileViewModel { self.isLoadingProfileInfo = true - // Use centralized DataType configuration - // When requestingUserId is provided, the backend returns relationshipStatus and pendingFriendRequestId + let cachePolicy: CachePolicy = forceRefresh ? .apiOnly : .cacheFirst(backgroundRefresh: false) let result: DataResult = await dataService.read( - .profileInfo(userId: userId, requestingUserId: requestingUserId)) + .profileInfo(userId: userId, requestingUserId: requestingUserId), + cachePolicy: cachePolicy) switch result { - case .success(let profileInfo, _): + case .success(let profileInfo, let source): self.userProfileInfo = profileInfo self.isLoadingProfileInfo = false @@ -254,6 +299,21 @@ final class ProfileViewModel { relationshipStatus, pendingRequestId: profileInfo.pendingFriendRequestId) } + if source == .cache { + Task { @MainActor in + let freshResult: DataResult = await self.dataService.read( + .profileInfo(userId: userId, requestingUserId: requestingUserId), + cachePolicy: .apiOnly) + if case .success(let freshInfo, _) = freshResult { + self.userProfileInfo = freshInfo + if let relationshipStatus = freshInfo.relationshipStatus { + self.setFriendshipStatusFromRelationshipType( + relationshipStatus, pendingRequestId: freshInfo.pendingFriendRequestId) + } + } + } + } + case .failure(let error): self.errorMessage = ErrorFormattingService.shared.formatError(error) self.isLoadingProfileInfo = false @@ -305,11 +365,12 @@ final class ProfileViewModel { } func loadAllProfileData(userId: UUID, requestingUserId: UUID? = nil) async { - // Use async let to fetch all profile data in parallel for faster loading - async let stats: () = fetchUserStats(userId: userId) - async let interests: () = fetchUserInterests(userId: userId) - async let socialMedia: () = fetchUserSocialMedia(userId: userId) - async let profileInfo: () = fetchUserProfileInfo(userId: userId, requestingUserId: requestingUserId) + // Always force-refresh from API since this is called after save operations + async let stats: () = fetchUserStats(userId: userId, forceRefresh: true) + async let interests: () = fetchUserInterests(userId: userId, forceRefresh: true) + async let socialMedia: () = fetchUserSocialMedia(userId: userId, forceRefresh: true) + async let profileInfo: () = fetchUserProfileInfo( + userId: userId, requestingUserId: requestingUserId, forceRefresh: true) // Wait for all fetches to complete let _ = await (stats, interests, socialMedia, profileInfo) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift index 7f12e950..10721370 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/Components/InterestsSection.swift @@ -77,7 +77,12 @@ struct InterestsSection: View { let isDuplicate = profileViewModel.userInterests.contains { $0.caseInsensitiveCompare(interest) == .orderedSame } - if !isDuplicate { + if isDuplicate { + InAppNotificationService.shared.showErrorMessage( + "\"\(interest)\" is already in your interests", + title: "Duplicate Interest" + ) + } else { profileViewModel.userInterests.append(interest) } newInterest = "" diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift index 666b53ca..b06d1cfa 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift @@ -195,11 +195,6 @@ struct EditProfileView: View { try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds delay - // Only refetch social media if we updated it - if socialMediaChanged { - await profileViewModel.fetchUserSocialMedia(userId: userId) - } - // Update profile picture if selected if let newImage = selectedImage { await userAuth.updateProfilePicture(newImage) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift index 8e97af96..dc97a7d3 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift @@ -262,7 +262,6 @@ struct MyProfileView: View { ProfileCalendarView( profileViewModel: profileViewModel, showCalendarPopup: $showCalendarPopup, - showActivityDetails: $showActivityDetails, navigateToCalendar: $navigateToCalendar, navigateToDayActivities: $navigateToDayActivities, selectedDayActivities: $selectedDayActivities, From c42aaee69dac3e83b0db993e5049fcc01e4831ef Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:47:52 -0800 Subject: [PATCH 31/53] redesign profile update & delete apis --- .../Config/WriteOperationConfig.swift | 18 ++++- .../ViewModels/Profile/ProfileViewModel.swift | 79 +++++-------------- .../Pages/FeedAndMap/ActivityFeedView.swift | 16 +++- .../EditProfile/EditProfileView.swift | 29 ++----- 4 files changed, 55 insertions(+), 87 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/DataService/Config/WriteOperationConfig.swift b/Spawn-App-iOS-SwiftUI/Services/DataService/Config/WriteOperationConfig.swift index 7735dbad..16115700 100644 --- a/Spawn-App-iOS-SwiftUI/Services/DataService/Config/WriteOperationConfig.swift +++ b/Spawn-App-iOS-SwiftUI/Services/DataService/Config/WriteOperationConfig.swift @@ -19,6 +19,9 @@ enum WriteOperationType { // MARK: - Profile Operations + /// Replace all interests for a user's profile + case replaceProfileInterests(userId: UUID, interests: [String]) + /// Add an interest to a user's profile case addProfileInterest(userId: UUID, interest: String) @@ -147,7 +150,8 @@ enum WriteOperationType { .updateNotificationPreferences: return .post - case .updateSocialMedia, + case .replaceProfileInterests, + .updateSocialMedia, .acceptFriendRequest, .declineFriendRequest, .batchUpdateActivityTypes, @@ -180,10 +184,11 @@ enum WriteOperationType { var endpoint: String { switch self { // Profile + case .replaceProfileInterests(let userId, _): + return "users/\(userId)/interests" case .addProfileInterest(let userId, _): return "users/\(userId)/interests" case .removeProfileInterest(let userId, let interest): - // URL encode the interest name let encoded = interest.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? interest return "users/\(userId)/interests/\(encoded)" case .updateSocialMedia(let userId, _): @@ -280,7 +285,8 @@ enum WriteOperationType { var cacheInvalidationKeys: [String] { switch self { // Profile - case .addProfileInterest(let userId, _), + case .replaceProfileInterests(let userId, _), + .addProfileInterest(let userId, _), .removeProfileInterest(let userId, _): return ["profileInterests-\(userId)"] case .updateSocialMedia(let userId, _): @@ -371,6 +377,8 @@ enum WriteOperationType { /// Human-readable name for logging var displayName: String { switch self { + case .replaceProfileInterests: + return "Replace Profile Interests" case .addProfileInterest: return "Add Profile Interest" case .removeProfileInterest: @@ -438,6 +446,8 @@ enum WriteOperationType { /// Returns nil if the operation doesn't have a body func getBody() -> T? where T: Encodable { switch self { + case .replaceProfileInterests(_, let interests): + return interests as? T case .addProfileInterest(_, let interest): return interest as? T case .updateSocialMedia(_, let socialMedia): @@ -500,6 +510,8 @@ enum WriteOperationType { /// This preserves the actual body type without requiring generic type inference func getAnyBody() -> AnyEncodable? { switch self { + case .replaceProfileInterests(_, let interests): + return AnyEncodable(interests) case .addProfileInterest(_, let interest): return AnyEncodable(interest) case .updateSocialMedia(_, let socialMedia): diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift index e0c81a82..7b004e7e 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/Profile/ProfileViewModel.swift @@ -179,33 +179,37 @@ final class ProfileViewModel { } } + func replaceAllInterests(userId: UUID, interests: [String]) async -> Bool { + let operationType = WriteOperationType.replaceProfileInterests(userId: userId, interests: interests) + let result: DataResult<[String]> = await dataService.write(operationType, body: interests) + + switch result { + case .success(let savedInterests, _): + self.userInterests = savedInterests + return true + case .failure(let error): + _ = notificationService.handleError(error, resource: .profile, operation: .update) + return false + } + } + func addUserInterest(userId: UUID, interest: String) async -> Bool { - // Don't add if already present (case-insensitive) let isDuplicate = self.userInterests.contains { $0.caseInsensitiveCompare(interest) == .orderedSame } guard !isDuplicate else { return true } - // Update local state immediately for better UX self.userInterests.append(interest) - // Use DataService for the POST operation - let operation = WriteOperation.post( - endpoint: "users/\(userId)/interests", - body: interest, - cacheInvalidationKeys: ["profileInterests_\(userId)"] - ) - - let result: DataResult = await dataService.writeWithoutResponse(operation) + let operationType = WriteOperationType.addProfileInterest(userId: userId, interest: interest) + let result: DataResult = await dataService.writeWithoutResponse(operationType) switch result { case .success: - // Refresh interests from cache after successful update let refreshResult: DataResult<[String]> = await dataService.read( .profileInterests(userId: userId), cachePolicy: .apiOnly ) - if case .success(let interests, _) = refreshResult { self.userInterests = interests } @@ -699,77 +703,30 @@ final class ProfileViewModel { userInterests = originalUserInterests } - // Interest management methods func removeUserInterest(userId: UUID, interest: String) async { - // Store original state for potential rollback let originalInterests = userInterests - - // Update local state immediately for better UX self.userInterests.removeAll { $0 == interest } - // URL encode the interest name to handle spaces and special characters - guard let encodedInterest = interest.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - self.userInterests = originalInterests - self.errorMessage = "Failed to encode interest name" - return - } - - let operation = WriteOperation.delete( - endpoint: "users/\(userId)/interests/\(encodedInterest)", - cacheInvalidationKeys: ["profileInterests_\(userId)"] - ) - - let result: DataResult = await dataService.writeWithoutResponse(operation) + let operationType = WriteOperationType.removeProfileInterest(userId: userId, interest: interest) + let result: DataResult = await dataService.writeWithoutResponse(operationType) switch result { case .success: - // Refresh interests from server after successful delete let refreshResult: DataResult<[String]> = await dataService.read( .profileInterests(userId: userId), cachePolicy: .apiOnly ) - if case .success(let interests, _) = refreshResult { self.userInterests = interests } case .failure(let error): print("❌ Failed to remove interest '\(interest)': \(ErrorFormattingService.shared.formatError(error))") - - // Revert the optimistic update since the API call failed self.userInterests = originalInterests self.errorMessage = ErrorFormattingService.shared.formatError(error) } } - // Method for edit profile flow - doesn't revert local state on error - func removeUserInterestForEdit(userId: UUID, interest: String) async { - // URL encode the interest name to handle spaces and special characters - guard let encodedInterest = interest.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - print("❌ Failed to encode interest name: \(interest)") - return - } - - let operation = WriteOperation.delete( - endpoint: "users/\(userId)/interests/\(encodedInterest)", - cacheInvalidationKeys: ["profileInterests_\(userId)"] - ) - - let result: DataResult = await dataService.writeWithoutResponse(operation) - - switch result { - case .success: - // Refresh interests from cache - let _: DataResult<[String]> = await dataService.read( - .profileInterests(userId: userId), cachePolicy: .apiOnly) - - case .failure(let error): - print("❌ Failed to remove interest '\(interest)': \(ErrorFormattingService.shared.formatError(error))") - // For other errors, we could show a warning but still keep the local state - // since the user explicitly wanted to remove it - } - } - // MARK: - Activity Management func fetchActivityDetails(activityId: UUID) async -> FullFeedActivityDTO? { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift index d7f88709..1ce5d903 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift @@ -40,6 +40,7 @@ struct ActivityFeedView: View { // Tutorial state @State private var showTutorialPreConfirmation = false @State private var tutorialSelectedActivityType: ActivityTypeDTO? + @State private var activityTypesFrame: CGRect = .zero init( user: BaseUserDTO, viewModel: FeedViewModel, selectedTab: Binding, @@ -78,6 +79,15 @@ struct ActivityFeedView: View { // Activity Types row activityTypeListView + .background( + GeometryReader { geo in + Color.clear + .preference( + key: ActivityTypesFrameKey.self, + value: geo.frame(in: .global) + ) + } + ) .padding(.bottom, 30) .padding(.horizontal, screenEdgePadding) @@ -128,9 +138,11 @@ struct ActivityFeedView: View { colorInPopup = nil } } + .onPreferenceChange(ActivityTypesFrameKey.self) { frame in + activityTypesFrame = frame + } .overlay( - // Tutorial overlay - TutorialOverlayView() + TutorialOverlayView(activityTypesFrame: activityTypesFrame) ) .overlay( // Tutorial pre-confirmation popup diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift index b06d1cfa..dc11d849 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift @@ -222,28 +222,15 @@ struct EditProfileView: View { } private func saveInterestChanges() async { - let currentInterests = Set(profileViewModel.userInterests) - let originalInterests = Set(profileViewModel.originalUserInterests) + let currentInterests = profileViewModel.userInterests + let changed = Set(currentInterests) != Set(profileViewModel.originalUserInterests) + guard changed else { return } - // Find interests to add (in current but not in original) - let interestsToAdd = currentInterests.subtracting(originalInterests) - - // Find interests to remove (in original but not in current) - let interestsToRemove = originalInterests.subtracting(currentInterests) - - // Add new interests - for interest in interestsToAdd { - _ = await profileViewModel.addUserInterest(userId: userId, interest: interest) - } - - // Remove old interests using the edit-specific method that handles 404 as success - for interest in interestsToRemove { - await profileViewModel.removeUserInterestForEdit(userId: userId, interest: interest) - } - - // Update the original interests to match current state after saving - await MainActor.run { - profileViewModel.originalUserInterests = profileViewModel.userInterests + let success = await profileViewModel.replaceAllInterests(userId: userId, interests: currentInterests) + if success { + await MainActor.run { + profileViewModel.originalUserInterests = profileViewModel.userInterests + } } } } From 10776b4f229537e9465067b0c03351f3fcc0678b Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 05:48:05 -0800 Subject: [PATCH 32/53] fix (tutorial): overlay styling --- .../Shared/Tutorial/TutorialOverlayView.swift | 142 ++++++++++-------- 1 file changed, 83 insertions(+), 59 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift index 0c347953..937efdd1 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift @@ -7,6 +7,13 @@ import SwiftUI +struct ActivityTypesFrameKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + struct TutorialOverlayView: View { var tutorialViewModel = TutorialViewModel.shared @Environment(\.colorScheme) var colorScheme @@ -16,6 +23,10 @@ struct TutorialOverlayView: View { @State private var showCallout = false + private let cutoutPadding: CGFloat = 12 + private let cutoutCornerRadius: CGFloat = 16 + private let calloutGap: CGFloat = 14 + init(activityTypesFrame: CGRect? = nil, headerFrame: CGRect? = nil) { self.activityTypesFrame = activityTypesFrame self.headerFrame = headerFrame @@ -24,54 +35,31 @@ struct TutorialOverlayView: View { var body: some View { ZStack { if tutorialViewModel.tutorialState.shouldShowTutorialOverlay { - GeometryReader { geometry in - let safeAreaTop = geometry.safeAreaInsets.top - let _ = geometry.size.height - - // Calculate dynamic positions based on screen size - let headerHeight = safeAreaTop + 44 // Safe area + navigation bar - let spawnInHeight: CGFloat = 100 // Approximate height of "Spawn in!" section - let activityTypesAreaHeight: CGFloat = 62 // Activity types height (115) + minimal padding (16) - let welcomeMessageHeight: CGFloat = 80 // Welcome message area - - // Dynamic overlay positioning - VStack(spacing: 0) { - // Top overlay (covers header and "Spawn in!" section) - Color.black.opacity(0.6) - .frame(height: headerHeight + spawnInHeight) + GeometryReader { _ in + ZStack(alignment: .top) { + overlayWithCutout - // Clear space for activity types with minimal padding - Color.clear - .frame(height: activityTypesAreaHeight) - - // Clear space for welcome message - Color.clear - .frame(height: welcomeMessageHeight) - - // Bottom overlay (covers remaining space) - Color.black.opacity(0.6) - .frame(maxHeight: .infinity) + if showCallout && tutorialViewModel.shouldShowCallout, + let frame = activityTypesFrame, frame != .zero + { + calloutView + .padding(.top, frame.maxY + cutoutPadding + calloutGap) + .allowsHitTesting(false) + .transition( + .asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + ) + ) + } } } .ignoresSafeArea() - .onTapGesture { - // Prevent background taps during tutorial - } - - // Tutorial callout - if showCallout && tutorialViewModel.shouldShowCallout { - tutorialCallout - .transition( - .asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .opacity - )) - } + .onTapGesture {} } } .onAppear { if tutorialViewModel.tutorialState.shouldShowTutorialOverlay { - // Animate in the callout with delay Task { @MainActor in try? await Task.sleep(for: .seconds(0.5)) withAnimation(.easeOut(duration: 0.4)) { @@ -89,21 +77,39 @@ struct TutorialOverlayView: View { } } - private var tutorialCallout: some View { - VStack(spacing: 12) { - // Callout text with theme-appropriate styling - VStack(spacing: 8) { - Text("Welcome to Spawn! πŸ‘‹") - .font(.onestSemiBold(size: 18)) - .foregroundColor(Color(red: 0.23, green: 0.22, blue: 0.22)) + @ViewBuilder + private var overlayWithCutout: some View { + if let frame = activityTypesFrame, frame != .zero { + TutorialCutoutShape( + cutoutRect: CGRect( + x: frame.origin.x - cutoutPadding, + y: frame.origin.y - cutoutPadding, + width: frame.width + cutoutPadding * 2, + height: frame.height + cutoutPadding * 2 + ), + cornerRadius: cutoutCornerRadius + ) + .fill(Color.black.opacity(0.6), style: FillStyle(eoFill: true)) + } else { + Color.black.opacity(0.6) + } + } - Text("Tap on an Activity Type to create your first activity") - .font(.onestMedium(size: 16)) - .foregroundColor(Color(red: 0.23, green: 0.22, blue: 0.22)) - .multilineTextAlignment(.center) - } + private var calloutView: some View { + VStack(spacing: 0) { + CalloutTriangle() + .fill(Color.white) + .frame(width: 24, height: 14) + + Text( + "Welcome to Spawn! Tap on an Activity Type to create your first activity." + ) + .font(.onestMedium(size: 16)) + .foregroundColor(Color(red: 0.23, green: 0.22, blue: 0.22)) + .multilineTextAlignment(.center) .padding(.horizontal, 24) .padding(.vertical, 20) + .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 16) .fill(Color.white) @@ -114,15 +120,33 @@ struct TutorialOverlayView: View { y: 4 ) ) - .padding(.horizontal, 32) } - .position(x: UIScreen.main.bounds.width / 2, y: calculateCalloutPosition()) + .padding(.horizontal, 24) } +} + +private struct TutorialCutoutShape: Shape { + let cutoutRect: CGRect + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + path.addRect(rect) + path.addRoundedRect( + in: cutoutRect, + cornerSize: CGSize(width: cornerRadius, height: cornerRadius) + ) + return path + } +} - private func calculateCalloutPosition() -> CGFloat { - // Position the callout in the space between activity types and "See what's happening" - // Activity types end around 425px, "See what's happening" starts around 500px - // So position callout around 450px from top - return 350 +private struct CalloutTriangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + return path } } From d9be8a561ea776a1f1e2c76b2db9a73fc83647aa Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 06:17:24 -0800 Subject: [PATCH 33/53] fix (profile): proper phone number digit error handling --- .../Registration/UserDetailsInputView.swift | 23 ++++++++++--------- .../Shared/Tutorial/TutorialOverlayView.swift | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserDetailsInputView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserDetailsInputView.swift index ce263ff2..15fd5017 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserDetailsInputView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserDetailsInputView.swift @@ -37,25 +37,24 @@ struct UserDetailsInputView: View { } } - // Phone number formatter (US style: (XXX) XXX-XXXX) + @State private var isFormattingPhone = false + private func formatPhoneNumber(_ number: String) -> String { - let digits = number.filter { $0.isNumber } - var result = "" + let digits = String(number.filter { $0.isNumber }.prefix(10)) let count = digits.count if count == 0 { return "" } if count < 4 { - result = digits + return digits } else if count < 7 { let area = digits.prefix(3) - let prefix = digits.suffix(count - 3) - result = "(\(area)) \(prefix)" + let rest = digits.dropFirst(3) + return "(\(area)) \(rest)" } else { let area = digits.prefix(3) - let prefix = digits.dropFirst(3).prefix(3) - let line = digits.dropFirst(6).prefix(4) - result = "(\(area)) \(prefix)-\(line)" + let mid = digits.dropFirst(3).prefix(3) + let line = digits.dropFirst(6) + return "(\(area)) \(mid)-\(line)" } - return result } var body: some View { @@ -122,11 +121,13 @@ struct UserDetailsInputView: View { errorMessage: phoneError ) .onChange(of: phoneNumber) { _, newValue in + guard !isFormattingPhone else { return } let formatted = formatPhoneNumber(newValue) if formatted != newValue { + isFormattingPhone = true phoneNumber = formatted + isFormattingPhone = false } - // Check for taken phone number (demo scenario) if formatted == "(778) 100-1000" { isPhoneNumberTaken = true phoneError = "This phone number has already been used. Try signing in instead." diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift index 937efdd1..259b736a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/Tutorial/TutorialOverlayView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ActivityTypesFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero + nonisolated(unsafe) static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } From 680fd9a6b4ae06f1794bc3c69d760ccc9d7e57a1 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 06:20:32 -0800 Subject: [PATCH 34/53] fix (errors): error handling for validation (profile fields) --- .../Services/API/APIError.swift | 5 +- .../Services/API/APIService.swift | 12 ++-- .../AuthFlow/UserAuthViewModel.swift | 65 ++++++++++--------- .../EditProfile/EditProfileView.swift | 22 ++++--- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIError.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIError.swift index 5c0656ee..5ae550a3 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIError.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIError.swift @@ -10,12 +10,13 @@ import Foundation enum APIError: LocalizedError { case failedHTTPRequest(description: String) case invalidStatusCode(statusCode: Int) + case validationError(message: String) case failedJSONParsing(url: URL) case invalidData case URLError case unknownError(error: Error) case failedTokenSaving(tokenType: String) - case cancelled // New case for cancelled requests + case cancelled var errorDescription: String? { switch self { @@ -23,6 +24,8 @@ enum APIError: LocalizedError { return description case .invalidStatusCode(let statusCode): return "Invalid Status Code: \(statusCode)" + case .validationError(let message): + return message case .failedJSONParsing(let url): return "Failed to properly parse JSON received from request to this url: \(url)" diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift index d92b9eb0..62005878 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift @@ -859,9 +859,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 { - // Handle token refresh logic here let newAccessToken: String = try await handleRefreshToken() - // Retry the request with the new access token let newData = try await retryRequest(request: &request, bearerAccessToken: newAccessToken) return try APIService.makeDecoder().decode(U.self, from: newData) } @@ -870,9 +868,14 @@ final class APIService: IAPIService, @unchecked Sendable { "invalid status code \(httpResponse.statusCode) for \(url)" print("❌ ERROR: Invalid status code \(httpResponse.statusCode) for \(url)") - // Try to parse error message from response if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { print("❌ ERROR DETAILS: \(errorJson)") + + if httpResponse.statusCode == 400, + let message = errorJson["message"] as? String + { + throw APIError.validationError(message: message) + } } throw APIError.invalidStatusCode( @@ -883,12 +886,13 @@ final class APIService: IAPIService, @unchecked Sendable { let decoder = APIService.makeDecoder() let decodedData = try decoder.decode(U.self, from: data) return decodedData + } catch let apiError as APIError { + throw apiError } catch { errorMessage = APIError.failedJSONParsing(url: url).localizedDescription print("❌ ERROR: JSON parsing failed for \(url): \(error)") - // Log the data that couldn't be parsed print( "❌ DATA THAT FAILED TO PARSE: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift index fa61a360..19b1f407 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift @@ -1214,52 +1214,59 @@ final class UserAuthViewModel: NSObject, ObservableObject { } } - func spawnEditProfile(username: String, name: String) async { + /// Returns nil on success, or a user-facing error message on failure. + func spawnEditProfile(username: String, name: String) async -> String? { guard let userId = spawnUser?.id else { print("Cannot edit profile: No user ID found") - return + return "No user ID found." } - // Log user details if let user = spawnUser { print( "Editing profile for user \(userId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))" ) } - if let url = URL(string: APIService.baseURL + "users/\(userId)") { - do { - let updateDTO = UserUpdateDTO( - username: username, - name: name - ) + guard let url = URL(string: APIService.baseURL + "users/\(userId)") else { + return "Invalid URL." + } - print("Updating profile with: username=\(username), name=\(name)") + do { + let updateDTO = UserUpdateDTO( + username: username, + name: name + ) - let updatedUser: BaseUserDTO = try await self.apiService.patchData( - from: url, - with: updateDTO - ) + print("Updating profile with: username=\(username), name=\(name)") - await MainActor.run { - // Update the current user object - self.spawnUser = updatedUser + let updatedUser: BaseUserDTO = try await self.apiService.patchData( + from: url, + with: updateDTO + ) - // Ensure UI updates with the latest values - self.objectWillChange.send() + await MainActor.run { + self.spawnUser = updatedUser + self.objectWillChange.send() - print("Profile updated successfully: \(updatedUser.username ?? "Unknown")") + print("Profile updated successfully: \(updatedUser.username ?? "Unknown")") - // Post notification for profile update to trigger hot-reload across the app - NotificationCenter.default.post( - name: .profileUpdated, - object: nil, - userInfo: ["updatedUser": updatedUser, "updateType": "nameAndUsername"] - ) - } - } catch { - print("Error updating profile: \(error.localizedDescription)") + NotificationCenter.default.post( + name: .profileUpdated, + object: nil, + userInfo: ["updatedUser": updatedUser, "updateType": "nameAndUsername"] + ) + } + return nil + } catch let apiError as APIError { + if case .validationError(let message) = apiError { + print("Validation error updating profile: \(message)") + return message } + APIError.logIfNotCancellation(apiError, message: "Error updating profile") + return "Failed to update profile. Please try again." + } catch { + print("Error updating profile: \(error.localizedDescription)") + return "Failed to update profile. Please try again." } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift index dc11d849..15ad2eac 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/EditProfile/EditProfileView.swift @@ -157,21 +157,29 @@ struct EditProfileView: View { isSaving = true Task { - // Only update profile info (name/username) if it actually changed let currentName = await MainActor.run { userAuth.spawnUser.flatMap { FormatterService.shared.formatName(user: $0) } ?? "" } let currentUsername = await MainActor.run { userAuth.spawnUser?.username ?? "" } if username != currentUsername || name != currentName { - await userAuth.spawnEditProfile( + let errorMessage = await userAuth.spawnEditProfile( username: username, name: name ) + if let errorMessage { + await MainActor.run { + isSaving = false + InAppNotificationService.shared.showErrorMessage( + errorMessage, + title: "Profile Update Failed" + ) + } + return + } await MainActor.run { userAuth.objectWillChange.send() } await userAuth.fetchUserData() } - // Format social media links for comparison and API let formattedWhatsapp = FormatterService.shared.formatWhatsAppLink(whatsappLink) let formattedInstagram = FormatterService.shared.formatInstagramLink(instagramLink) let newWhatsapp = formattedWhatsapp.isEmpty ? nil : formattedWhatsapp @@ -181,7 +189,6 @@ struct EditProfileView: View { let socialMediaChanged = (newWhatsapp ?? "") != (oldWhatsapp ?? "") || (newInstagram ?? "") != (oldInstagram ?? "") - // Only PUT social media when whatsapp or instagram actually changed if socialMediaChanged { await profileViewModel.updateSocialMedia( userId: userId, @@ -190,22 +197,17 @@ struct EditProfileView: View { ) } - // Only run interest add/remove for interests that changed (saveInterestChanges already does this) await saveInterestChanges() - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds delay + try? await Task.sleep(nanoseconds: 500_000_000) - // Update profile picture if selected if let newImage = selectedImage { await userAuth.updateProfilePicture(newImage) - // Invalidate the cached profile picture since we have a new one await ProfilePictureCache.shared.removeCachedImage(for: userId) } - // Refresh all profile data await profileViewModel.loadAllProfileData(userId: userId) - // Ensure the user object is fully refreshed if let spawnUser = userAuth.spawnUser { print("Updated profile: \(spawnUser.name ?? "Unknown"), @\(spawnUser.username ?? "unknown")") await MainActor.run { From fcd91155ccec11644495ddaa1c11c460fadb76a0 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 06:40:56 -0800 Subject: [PATCH 35/53] fix (profile): editing causing user sign out --- .../Services/API/APIService.swift | 20 +++++++++---------- .../Services/UI/ErrorFormattingService.swift | 4 ++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift index 62005878..04ceeef4 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift @@ -665,16 +665,14 @@ final class APIService: IAPIService, @unchecked Sendable { private func handleAuthTokens(from response: HTTPURLResponse, for url: URL) throws { - // Check if this is an auth endpoint - let authEndpoints = [ - APIService.baseURL + "auth/sign-in", - APIService.baseURL + "auth/login", - APIService.baseURL + "auth/register/oauth", - APIService.baseURL + "auth/register/verification/check", - APIService.baseURL + "auth/user/details", - APIService.baseURL + "auth/quick-sign-in", - ] - guard authEndpoints.contains(where: { url.absoluteString.contains($0) }) else { + // Only process responses that actually contain auth tokens in headers. + // The backend returns new tokens (e.g. after username change) via + // Authorization and X-Refresh-Token headers on any endpoint that + // requires token rotation, not just auth endpoints. + guard + response.allHeaderFields["Authorization"] as? String != nil + || response.allHeaderFields["authorization"] as? String != nil + else { return } @@ -882,6 +880,8 @@ final class APIService: IAPIService, @unchecked Sendable { statusCode: httpResponse.statusCode) } + try handleAuthTokens(from: httpResponse, for: url) + do { let decoder = APIService.makeDecoder() let decodedData = try decoder.decode(U.self, from: data) diff --git a/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift b/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift index 938a9dea..7dc4dfeb 100644 --- a/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/UI/ErrorFormattingService.swift @@ -34,6 +34,8 @@ final class ErrorFormattingService: Sendable { return "We're having trouble connecting to our servers. Please try again." case .invalidStatusCode(let statusCode): return formatStatusCodeError(statusCode) + case .validationError(let message): + return formatGenericError(message) case .failedJSONParsing: return "We're having trouble processing the server response. Please try again." case .invalidData: @@ -190,6 +192,8 @@ final class ErrorFormattingService: Sendable { return formatContextualStatusCode(statusCode, resource: resource, operation: operation) case .failedHTTPRequest: return "We're having trouble connecting to our servers. Please check your connection and try again." + case .validationError(let message): + return formatGenericContextualError(message, resource: resource, operation: operation) case .failedJSONParsing: return "We received unexpected data. Please try again." case .invalidData: From a023db6b6595541563c3ca44d91e89b0b6432135 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 28 Feb 2026 06:42:28 -0800 Subject: [PATCH 36/53] v2.0 --- Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj b/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj index 68111632..ec91675a 100644 --- a/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj +++ b/Spawn-App-iOS-SwiftUI.xcodeproj/project.pbxproj @@ -363,7 +363,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -426,7 +426,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -464,7 +464,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -500,7 +500,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -518,7 +518,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -536,7 +536,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -552,7 +552,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUIUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -568,7 +568,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "danielagapov.Spawn-App-iOS-SwiftUIUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; From c79446073a6fb4457752f6e4f5a9ef2ac542ef18 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 6 Mar 2026 00:38:09 -0800 Subject: [PATCH 37/53] fix: misc. warnings --- .../Services/UI/InAppNotificationService.swift | 1 - .../Views/Pages/Profile/MyProfile/MyProfileView.swift | 2 +- .../Views/Pages/Profile/UserProfile/UserProfileView.swift | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/UI/InAppNotificationService.swift b/Spawn-App-iOS-SwiftUI/Services/UI/InAppNotificationService.swift index 4a87be98..3cb2112a 100644 --- a/Spawn-App-iOS-SwiftUI/Services/UI/InAppNotificationService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/UI/InAppNotificationService.swift @@ -813,7 +813,6 @@ final class InAppNotificationService { /// Generate a default success message for combinations not explicitly handled private func generateDefaultSuccessMessage(resource: ResourceContext, operation: OperationContext) -> String { let resourceName = resource.displayName - let article = resource.article switch operation { case .create: diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift index dc97a7d3..77ebc618 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/MyProfileView.swift @@ -382,7 +382,7 @@ struct MyProfileView: View { let currentName = userAuth.spawnUser?.name ?? "" let currentUsername = userAuth.spawnUser?.username ?? "" if username != currentUsername || name != currentName { - await userAuth.spawnEditProfile( + let _ = await userAuth.spawnEditProfile( username: username, name: name ) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 32e9becd..660d271e 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -117,7 +117,7 @@ struct UserProfileView: View { ) // Fetch activities if they're friends OR if viewing own profile - if let currentUserId = currentUserId { + if currentUserId != nil { if profileViewModel.friendshipStatus == .friends || profileViewModel.friendshipStatus == .themself { await profileViewModel.fetchProfileActivities( profileUserId: user.id From fd1332b3423f4804e419423c936ea22de4d47891 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Tue, 10 Mar 2026 22:45:03 -0700 Subject: [PATCH 38/53] Terms and Conditions --- Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md | 88 ++++++++++++ .../PrivacyPolicyPlaceholderView.swift | 47 +++++++ .../Registration/TermsAndConditionsView.swift | 128 ++++++++++++++++++ .../Pages/AuthFlow/Registration/UserToS.swift | 44 ++++-- .../MyProfile/Settings/SettingsView.swift | 45 ++++++ 5 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md create mode 100644 Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift create mode 100644 Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift diff --git a/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md new file mode 100644 index 00000000..b0977cda --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md @@ -0,0 +1,88 @@ +# Terms and Conditions β€” Suggestions & Notes + +This document contains suggestions, fixes, and app-related thoughts about the Spawn Terms and Conditions. **Do not copy these into the in-app terms** unless you intentionally adopt them after legal review. + +--- + +## 1. Placeholders to fix in the actual terms + +- **Section 7 β€” [Company Name]:** Replace with your legal entity name (e.g. β€œSpawn, Inc.” or β€œSpawn LLC”). Using a placeholder in live terms can create enforceability and clarity issues. +- **Section 5 β€” β€œInclude link to Privacy Policy”:** This is instruction text, not user-facing wording. Replace with either: + - A direct link (e.g. β€œView our Privacy Policy at [URL]”), or + - β€œOur Privacy Policy is available in the app under Settings β†’ Legal β†’ Privacy Policy and at [URL].” + +--- + +## 2. Date + +- **β€œMarch 2nd 2025”:** Confirm whether this is the intended effective date. If the terms go live in 2026, update the date to avoid confusion and to match your records. + +--- + +## 3. Age & parental consent (Section 2) + +- **13+ and under-18 consent:** Align with **COPPA** (US) and any similar rules in your target countries. If you actually allow under-13 users, you’ll need stricter parental consent and data handling; many apps set a minimum of 13 and treat 13–17 as β€œminor with consent.” +- Consider explicitly stating that **parents/guardians** of users under 18 agree to these Terms on the minor’s behalf and are responsible for the minor’s use. + +--- + +## 4. Location (Section 6) + +- The app relies on **real-time location**. Consider adding: + - That location is used to show activities and presence to friends (and any other uses). + - That users can control sharing via in-app privacy/location settings. + - That turning off location may limit certain features (e.g. seeing or joining nearby activities). +- Ensure the **Privacy Policy** describes exactly how location is collected, stored, and shared, and for how long. + +--- + +## 5. Activities and user content + +- **Section 4** covers β€œactivities” and β€œcontent” at a high level. You may want to clarify: + - Who owns **user-created content** (e.g. activity descriptions, photos): e.g. user retains ownership but grants Spawn a license to operate the service. + - That **activity locations and times** may be visible to invited friends (and possibly others, depending on product). +- If users can report or block others, a short reference to **community standards / reporting** can reinforce Section 4 and Section 10. + +--- + +## 6. Missing clauses often found in app terms + +- **Governing law and venue:** e.g. β€œThese Terms are governed by the laws of [State/Country]. Any disputes will be resolved in the courts of [jurisdiction].” +- **Arbitration / class action waiver:** If you want to require arbitration (common in US consumer apps), add a clear arbitration clause and, where applicable, class action waiver, and ensure it’s consistent with the rest of the Terms. +- **Severability:** If one provision is invalid, the rest remain in effect. +- **Entire agreement:** These Terms (together with the Privacy Policy) constitute the entire agreement between the user and Spawn regarding the App. + +--- + +## 7. Limitation of liability (Section 8) + +- Section 8 is brief. Many apps add: + - A **cap on liability** (e.g. the amount paid to Spawn in the past 12 months, or a fixed sum). + - Clarification that Spawn is not liable for **user conduct**, **third-party services**, or **location inaccuracy**. +- **Jurisdiction-dependent:** Some countries do not allow certain liability exclusions; consider a carve-out (e.g. β€œSome jurisdictions do not allow …; in those jurisdictions our liability is limited to the maximum permitted by law”). + +--- + +## 8. Changes to terms (Section 11) + +- Consider specifying **how** you’ll notify users of material changes (e.g. in-app notice, email, or push) and **when** the new terms take effect (e.g. 30 days after notice). +- For material changes, some apps require **re-acceptance** (e.g. checkbox or β€œI agree” after the next app update). Your onboarding already has acceptance; you could mirror that for major updates. + +--- + +## 9. Contact (Section 12) + +- **spawnappmarketing@gmail.com** is listed for β€œquestions about these Terms.” Consider: + - A dedicated **legal/support** address for terms and privacy (e.g. legal@ or support@) so these requests aren’t mixed only with marketing. + - Stating a **reasonable response time** (e.g. β€œWe aim to respond within X business days”) to set expectations. + +--- + +## 10. App-specific implementation notes + +- **In-app:** Terms are shown in **TermsAndConditionsView** and linked from the onboarding β€œTerms” link and from **Settings β†’ Legal β†’ Terms and Conditions**. Privacy Policy is currently a placeholder (Settings β†’ Legal β†’ Privacy Policy); once you have a URL or in-app version, replace the placeholder and, in the terms text, replace β€œInclude link to Privacy Policy” with the actual link or reference. +- **Acceptance:** Users accept by checking the box and continuing on the **UserToS** screen; consider logging acceptance (user id + timestamp + terms version) for compliance and dispute purposes. + +--- + +*This file is for internal use only. Have a lawyer review the final Terms and Privacy Policy before release.* diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift new file mode 100644 index 00000000..096eeee4 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift @@ -0,0 +1,47 @@ +// +// PrivacyPolicyPlaceholderView.swift +// Spawn-App-iOS-SwiftUI +// + +import SwiftUI + +struct PrivacyPolicyPlaceholderView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var themeService = ThemeService.shared + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: 0) { + HStack { + UnifiedBackButton { dismiss() } + Spacer() + Text("Privacy Policy") + .font(.onestSemiBold(size: 18)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + Spacer() + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 25) + .padding(.vertical, 12) + + Spacer() + Text("Our full Privacy Policy will be available here or via a link in the app.") + .font(.onestRegular(size: 16)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Text("For questions, contact spawnappmarketing@gmail.com") + .font(.onestRegular(size: 14)) + .foregroundColor(universalPlaceHolderTextColor(from: themeService, environment: colorScheme)) + .padding(.top, 12) + .padding(.horizontal, 32) + Spacer() + } + .background(universalBackgroundColor(from: themeService, environment: colorScheme).ignoresSafeArea()) + .navigationBarHidden(true) + } +} + +#Preview { + PrivacyPolicyPlaceholderView() +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift new file mode 100644 index 00000000..224824a5 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift @@ -0,0 +1,128 @@ +// +// TermsAndConditionsView.swift +// Spawn-App-iOS-SwiftUI +// + +import SwiftUI + +struct TermsAndConditionsView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var themeService = ThemeService.shared + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: 0) { + HStack { + UnifiedBackButton { dismiss() } + Spacer() + Text("Terms and Conditions") + .font(.onestSemiBold(size: 18)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + Spacer() + // Balance back button + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 25) + .padding(.vertical, 12) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("March 2nd 2025") + .font(.onestMedium(size: 14)) + .foregroundColor(universalPlaceHolderTextColor(from: themeService, environment: colorScheme)) + + sectionTitle("1. Introduction") + bodyText( + "Welcome to Spawn! These Terms and Conditions (\"Terms\") govern your use of the Spawn mobile application (\"App\"), which allows users to discover and join friends' activities in real time. By accessing or using Spawn, you agree to comply with these Terms. If you do not agree, please do not use the App." + ) + + sectionTitle("2. Eligibility") + bodyText( + "You must be at least 13 years old to use Spawn. If you are under 18, you must have parental or legal guardian consent. By using the App, you confirm that you meet these requirements." + ) + + sectionTitle("3. User Accounts") + bodyText("You are responsible for maintaining the confidentiality of your account credentials.") + bodyText( + "You agree not to share your account with others or use another person's account without permission." + ) + bodyText("Spawn reserves the right to suspend or terminate accounts that violate these Terms.") + + sectionTitle("4. Acceptable Use") + bodyText("When using Spawn, you agree to:") + bodyText("Share and engage with activities responsibly and respectfully.") + bodyText("Not post false, misleading, or inappropriate content.") + bodyText("Not use the App for illegal, harmful, or fraudulent purposes.") + bodyText("Not attempt to hack, disrupt, or exploit the App.") + + sectionTitle("5. Privacy Policy") + bodyText( + "Your use of Spawn is subject to our Privacy Policy, which explains how we collect, use, and protect your data. By using Spawn, you agree to our data practices. Include link to Privacy Policy" + ) + + sectionTitle("6. Location Services") + bodyText( + "Spawn uses real-time location data to enhance user experience. You acknowledge and agree that your location may be shared with friends based on your selected privacy settings." + ) + + sectionTitle("7. Intellectual Property") + bodyText( + "Spawn and its associated trademarks, logos, and content are the exclusive property of [Company Name]." + ) + bodyText("Users may not copy, modify, or distribute any content from Spawn without permission.") + + sectionTitle("8. Limitation of Liability") + bodyText( + "Spawn is provided \"as is\" without warranties of any kind. We do not guarantee uninterrupted or error-free service. Spawn is not responsible for any loss, damages, or disputes arising from use of the App." + ) + + sectionTitle("9. Third-Party Links & Services") + bodyText( + "Spawn may contain links to third-party websites or services. We do not control or endorse these services and are not responsible for their content or policies." + ) + + sectionTitle("10. Termination") + bodyText( + "We reserve the right to suspend or terminate your access to Spawn at our discretion if you violate these Terms or engage in harmful activities on the App." + ) + + sectionTitle("11. Changes to Terms") + bodyText( + "Spawn may update these Terms periodically. Continued use of the App after changes constitutes acceptance of the updated Terms." + ) + + sectionTitle("12. Contact Us") + bodyText( + "If you have any questions about these Terms, please contact us at spawnappmarketing@gmail.com." + ) + + bodyText( + "By using Spawn, you acknowledge that you have read, understood, and agreed to these Terms and Conditions." + ) + .padding(.top, 8) + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + } + .background(universalBackgroundColor(from: themeService, environment: colorScheme).ignoresSafeArea()) + .navigationBarHidden(true) + } + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.onestSemiBold(size: 16)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + } + + private func bodyText(_ text: String) -> some View { + Text(text) + .font(.onestRegular(size: 15)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + } +} + +#Preview { + TermsAndConditionsView() +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserToS.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserToS.swift index a19c95c0..9fea9e7a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserToS.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/UserToS.swift @@ -12,6 +12,8 @@ struct UserToS: View { @ObservedObject private var userAuth = UserAuthViewModel.shared @State private var agreed: Bool = false @State private var isSubmitting: Bool = false + @State private var showTermsSheet: Bool = false + @State private var showPrivacySheet: Bool = false @ObservedObject var themeService = ThemeService.shared @Environment(\.colorScheme) var colorScheme @@ -77,20 +79,28 @@ struct UserToS: View { } .buttonStyle(PlainButtonStyle()) - Text("I agree to the ") - .font(.onestMedium(size: 14)) - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - + Text("Terms") - .font(.onestMedium(size: 14)) - .underline() - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - + Text(" & ") - .font(.onestMedium(size: 14)) - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - + Text("Privacy Policy") - .font(.onestMedium(size: 14)) - .underline() - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + HStack(spacing: 0) { + Text("I agree to the ") + .font(.onestMedium(size: 14)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + Button(action: { showTermsSheet = true }) { + Text("Terms") + .font(.onestMedium(size: 14)) + .underline() + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + } + .buttonStyle(PlainButtonStyle()) + Text(" & ") + .font(.onestMedium(size: 14)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + Button(action: { showPrivacySheet = true }) { + Text("Privacy Policy") + .font(.onestMedium(size: 14)) + .underline() + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + } + .buttonStyle(PlainButtonStyle()) + } } .padding(.horizontal, 40) @@ -141,6 +151,12 @@ struct UserToS: View { // Clear any previous error state when this view appears userAuth.clearAllErrors() } + .sheet(isPresented: $showTermsSheet) { + TermsAndConditionsView() + } + .sheet(isPresented: $showPrivacySheet) { + PrivacyPolicyPlaceholderView() + } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/SettingsView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/SettingsView.swift index 99333348..c3d67cd7 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/SettingsView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/Settings/SettingsView.swift @@ -152,6 +152,51 @@ struct SettingsView: View { } } + // Legal + SettingsSection(title: "Legal") { + NavigationLink(destination: TermsAndConditionsView()) { + HStack { + Image(systemName: "doc.text") + .font(.system(size: 18)) + .foregroundColor(universalAccentColor) + .frame(width: 24, height: 24) + + Text("Terms and Conditions") + .font(.body) + .foregroundColor(universalAccentColor) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(.horizontal) + .frame(height: 44) + } + + NavigationLink(destination: PrivacyPolicyPlaceholderView()) { + HStack { + Image(systemName: "hand.raised") + .font(.system(size: 18)) + .foregroundColor(universalAccentColor) + .frame(width: 24, height: 24) + + Text("Privacy Policy") + .font(.body) + .foregroundColor(universalAccentColor) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(.horizontal) + .frame(height: 44) + } + } + // Contact Us SettingsSection(title: "Contact Us") { if let userId = userAuth.spawnUser?.id, let email = userAuth.spawnUser?.email { From 1c3044d9807decc1034fc56d30bffe8e0f259bb1 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Tue, 10 Mar 2026 22:47:30 -0700 Subject: [PATCH 39/53] dismiss verification code keypad --- .../AuthFlow/Registration/VerificationCodeView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift index 0cb42dba..3dffe175 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift @@ -47,6 +47,15 @@ struct VerificationCodeView: View { .onDisappear { viewModel.stopTimer() } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + focusedIndex = nil + viewModel.focusedIndex = nil + } + } + } .navigationBarHidden(true) } From 003c1ec9b348e311a71895ee316c0875a959366f Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Tue, 10 Mar 2026 22:55:55 -0700 Subject: [PATCH 40/53] Privacy policy + terms updates --- .../Services/Core/ServiceConstants.swift | 4 ++ Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md | 69 +++++-------------- .../PrivacyPolicyPlaceholderView.swift | 38 ++++++++-- .../Registration/TermsAndConditionsView.swift | 11 +-- 4 files changed, 61 insertions(+), 61 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Services/Core/ServiceConstants.swift b/Spawn-App-iOS-SwiftUI/Services/Core/ServiceConstants.swift index f3b1c8c3..9c514ebc 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Core/ServiceConstants.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Core/ServiceConstants.swift @@ -8,6 +8,10 @@ struct ServiceConstants { // Base URL for sharing activities - updated to match deployed web app static let shareBase = "https://getspawn.com" + + /// Privacy Policy β€” open in browser from Settings β†’ Legal β†’ Privacy Policy and from terms (Section 5). + static let privacyPolicy = + "https://doc-hosting.flycricket.io/spawn-privacy-policy/8f254bc3-3403-4928-8353-f1f787ed6eec/privacy" } // MARK: - Share URL Generation diff --git a/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md index b0977cda..d87be02e 100644 --- a/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md +++ b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md @@ -4,83 +4,50 @@ This document contains suggestions, fixes, and app-related thoughts about the Sp --- -## 1. Placeholders to fix in the actual terms +## 1. Placeholders / unincorporated (Section 7) -- **Section 7 β€” [Company Name]:** Replace with your legal entity name (e.g. β€œSpawn, Inc.” or β€œSpawn LLC”). Using a placeholder in live terms can create enforceability and clarity issues. -- **Section 5 β€” β€œInclude link to Privacy Policy”:** This is instruction text, not user-facing wording. Replace with either: - - A direct link (e.g. β€œView our Privacy Policy at [URL]”), or - - β€œOur Privacy Policy is available in the app under Settings β†’ Legal β†’ Privacy Policy and at [URL].” +- **Section 7 β€” Unincorporated:** You are **not** incorporated, so do not use "Spawn, Inc." or "Spawn LLC." The in-app terms now use "the operators of Spawn." If you prefer to name individuals, you can use: + - The **operators' names** (e.g. "Spawn is operated by [Founder A] and [Founder B]"). + - A **DBA / trade name** if you have one (e.g. "Spawn" as a trade name used by [Your Name(s)]). + Using a company name when you are not incorporated can be misleading; the current wording keeps the terms accurate. +- **Section 5 β€” Privacy Policy:** The app now links to your Privacy Policy (see **Section 6** below). The URL is set in `ServiceConstants.URLs.privacyPolicy`. --- -## 2. Date +## 2. Location (Section 6) -- **β€œMarch 2nd 2025”:** Confirm whether this is the intended effective date. If the terms go live in 2026, update the date to avoid confusion and to match your records. +- The in-app terms have been aligned with your **Info.plist** usage description: location is used to show nearby activities on the map and as the initial location for new activities you create and share with friends. Ensure the **Privacy Policy** describes how location is collected, stored, and shared, and for how long. --- -## 3. Age & parental consent (Section 2) +## 3. Missing clauses often found in app terms -- **13+ and under-18 consent:** Align with **COPPA** (US) and any similar rules in your target countries. If you actually allow under-13 users, you’ll need stricter parental consent and data handling; many apps set a minimum of 13 and treat 13–17 as β€œminor with consent.” -- Consider explicitly stating that **parents/guardians** of users under 18 agree to these Terms on the minor’s behalf and are responsible for the minor’s use. - ---- - -## 4. Location (Section 6) - -- The app relies on **real-time location**. Consider adding: - - That location is used to show activities and presence to friends (and any other uses). - - That users can control sharing via in-app privacy/location settings. - - That turning off location may limit certain features (e.g. seeing or joining nearby activities). -- Ensure the **Privacy Policy** describes exactly how location is collected, stored, and shared, and for how long. - ---- - -## 5. Activities and user content - -- **Section 4** covers β€œactivities” and β€œcontent” at a high level. You may want to clarify: - - Who owns **user-created content** (e.g. activity descriptions, photos): e.g. user retains ownership but grants Spawn a license to operate the service. - - That **activity locations and times** may be visible to invited friends (and possibly others, depending on product). -- If users can report or block others, a short reference to **community standards / reporting** can reinforce Section 4 and Section 10. - ---- - -## 6. Missing clauses often found in app terms - -- **Governing law and venue:** e.g. β€œThese Terms are governed by the laws of [State/Country]. Any disputes will be resolved in the courts of [jurisdiction].” -- **Arbitration / class action waiver:** If you want to require arbitration (common in US consumer apps), add a clear arbitration clause and, where applicable, class action waiver, and ensure it’s consistent with the rest of the Terms. +- **Governing law and venue:** e.g. "These Terms are governed by the laws of [State/Country]. Any disputes will be resolved in the courts of [jurisdiction]." +- **Arbitration / class action waiver:** If you want to require arbitration (common in US consumer apps), add a clear arbitration clause and, where applicable, class action waiver, and ensure it's consistent with the rest of the Terms. - **Severability:** If one provision is invalid, the rest remain in effect. - **Entire agreement:** These Terms (together with the Privacy Policy) constitute the entire agreement between the user and Spawn regarding the App. --- -## 7. Limitation of liability (Section 8) +## 4. Limitation of liability (Section 8) - Section 8 is brief. Many apps add: - A **cap on liability** (e.g. the amount paid to Spawn in the past 12 months, or a fixed sum). - Clarification that Spawn is not liable for **user conduct**, **third-party services**, or **location inaccuracy**. -- **Jurisdiction-dependent:** Some countries do not allow certain liability exclusions; consider a carve-out (e.g. β€œSome jurisdictions do not allow …; in those jurisdictions our liability is limited to the maximum permitted by law”). - ---- - -## 8. Changes to terms (Section 11) - -- Consider specifying **how** you’ll notify users of material changes (e.g. in-app notice, email, or push) and **when** the new terms take effect (e.g. 30 days after notice). -- For material changes, some apps require **re-acceptance** (e.g. checkbox or β€œI agree” after the next app update). Your onboarding already has acceptance; you could mirror that for major updates. +- **Jurisdiction-dependent:** Some countries do not allow certain liability exclusions; consider a carve-out (e.g. "Some jurisdictions do not allow …; in those jurisdictions our liability is limited to the maximum permitted by law"). --- -## 9. Contact (Section 12) +## 5. Changes to terms (Section 11) -- **spawnappmarketing@gmail.com** is listed for β€œquestions about these Terms.” Consider: - - A dedicated **legal/support** address for terms and privacy (e.g. legal@ or support@) so these requests aren’t mixed only with marketing. - - Stating a **reasonable response time** (e.g. β€œWe aim to respond within X business days”) to set expectations. +- Consider specifying **how** you'll notify users of material changes (e.g. in-app notice, email, or push) and **when** the new terms take effect (e.g. 30 days after notice). +- For material changes, some apps require **re-acceptance** (e.g. checkbox or "I agree" after the next app update). Your onboarding already has acceptance; you could mirror that for major updates. --- -## 10. App-specific implementation notes +## 6. App-specific implementation notes -- **In-app:** Terms are shown in **TermsAndConditionsView** and linked from the onboarding β€œTerms” link and from **Settings β†’ Legal β†’ Terms and Conditions**. Privacy Policy is currently a placeholder (Settings β†’ Legal β†’ Privacy Policy); once you have a URL or in-app version, replace the placeholder and, in the terms text, replace β€œInclude link to Privacy Policy” with the actual link or reference. +- **In-app:** Terms are shown in **TermsAndConditionsView** and linked from the onboarding "Terms" link and from **Settings β†’ Legal β†’ Terms and Conditions**. The Privacy Policy is linked from **Settings β†’ Legal β†’ Privacy Policy** and from the terms (Section 5); the app opens the URL defined in `ServiceConstants.URLs.privacyPolicy` (your Flycricket-hosted policy). - **Acceptance:** Users accept by checking the box and continuing on the **UserToS** screen; consider logging acceptance (user id + timestamp + terms version) for compliance and dispute purposes. --- diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift index 096eeee4..afa8f608 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift @@ -10,6 +10,8 @@ struct PrivacyPolicyPlaceholderView: View { @ObservedObject var themeService = ThemeService.shared @Environment(\.colorScheme) var colorScheme + private var privacyPolicyURL: URL? { URL(string: ServiceConstants.URLs.privacyPolicy) } + var body: some View { VStack(spacing: 0) { HStack { @@ -25,15 +27,39 @@ struct PrivacyPolicyPlaceholderView: View { .padding(.vertical, 12) Spacer() - Text("Our full Privacy Policy will be available here or via a link in the app.") - .font(.onestRegular(size: 16)) - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) + VStack(spacing: 20) { + Text("Our Privacy Policy explains how we collect, use, and protect your data.") + .font(.onestRegular(size: 16)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + if let url = privacyPolicyURL { + Button(action: { + UIApplication.shared.open(url) + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 18)) + Text("View Privacy Policy") + .font(.onestSemiBold(size: 16)) + } + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke( + universalAccentColor(from: themeService, environment: colorScheme), lineWidth: 1.5) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } Text("For questions, contact spawnappmarketing@gmail.com") .font(.onestRegular(size: 14)) .foregroundColor(universalPlaceHolderTextColor(from: themeService, environment: colorScheme)) - .padding(.top, 12) + .padding(.top, 24) .padding(.horizontal, 32) Spacer() } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift index 224824a5..8419236a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift @@ -27,7 +27,7 @@ struct TermsAndConditionsView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { - Text("March 2nd 2025") + Text("March 10th 2026") .font(.onestMedium(size: 14)) .foregroundColor(universalPlaceHolderTextColor(from: themeService, environment: colorScheme)) @@ -54,20 +54,23 @@ struct TermsAndConditionsView: View { bodyText("Not post false, misleading, or inappropriate content.") bodyText("Not use the App for illegal, harmful, or fraudulent purposes.") bodyText("Not attempt to hack, disrupt, or exploit the App.") + bodyText( + "You retain ownership of content you create (e.g. activity descriptions, photos). By posting content, you grant Spawn a license to use, display, and share it as needed to operate the service. Activity locations and times may be visible to friends you invite or others based on your and the App's settings." + ) sectionTitle("5. Privacy Policy") bodyText( - "Your use of Spawn is subject to our Privacy Policy, which explains how we collect, use, and protect your data. By using Spawn, you agree to our data practices. Include link to Privacy Policy" + "Your use of Spawn is subject to our Privacy Policy, which explains how we collect, use, and protect your data. By using Spawn, you agree to our data practices. Our Privacy Policy is available in the app under Settings β†’ Legal β†’ Privacy Policy and at the link provided there." ) sectionTitle("6. Location Services") bodyText( - "Spawn uses real-time location data to enhance user experience. You acknowledge and agree that your location may be shared with friends based on your selected privacy settings." + "Spawn uses your location to show nearby activities on the map and as the initial location for new activities you create and share with friends. You can control location access in your device and in-app settings. Disabling location may limit features such as seeing or joining nearby activities." ) sectionTitle("7. Intellectual Property") bodyText( - "Spawn and its associated trademarks, logos, and content are the exclusive property of [Company Name]." + "Spawn and its associated trademarks, logos, and content are the exclusive property of the operators of Spawn." ) bodyText("Users may not copy, modify, or distribute any content from Spawn without permission.") From 073c9e7839ae393703fb14df8ed8b9d532971be2 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Tue, 10 Mar 2026 23:14:49 -0700 Subject: [PATCH 41/53] fix: activity click from calendar --- .../DayActivities/DayActivitiesPageView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift index e328dcc1..80672c10 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/MyProfile/DayActivities/DayActivitiesPageView.swift @@ -139,8 +139,15 @@ struct DayActivitiesPageView: View { activity: fullActivity, color: getColorForActivity(activity), locationManager: locationManager, - callback: { _, _ in - onActivitySelected(activity) + callback: { tappedActivity, activityColor in + // Post notification directly with full activity - we already have it, + // avoiding the parent's fetch flow which could show a blank drawer + NotificationCenter.default.post( + name: .showGlobalActivityPopup, + object: nil, + userInfo: ["activity": tappedActivity, "color": activityColor] + ) + onDismiss() }, horizontalPadding: 16 ) From 41ffdf6bbccbd7215224d9bcaae8afeaa3e239d3 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Wed, 11 Mar 2026 00:09:57 -0700 Subject: [PATCH 42/53] fix: nav title for add friends to activity type truncation --- .../ActivityTypeFriendSelectionView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeFriendSelectionView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeFriendSelectionView.swift index 992cace9..646f0c82 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeFriendSelectionView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeFriendSelectionView.swift @@ -61,10 +61,17 @@ struct ActivityTypeFriendSelectionView: View { } } } - .navigationTitle("Select friends to add to this type") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(false) .toolbar { + ToolbarItem(placement: .principal) { + Text("Select friends to add to this type") + .font(.onestSemiBold(size: 17)) + .foregroundColor(universalAccentColor) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } ToolbarItem(placement: .navigationBarTrailing) { Button(action: { saveActivityType() }) { Text("Save") From e4398b84be0a7f4d9f8bee755ed2ecc6f5afc744 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Wed, 11 Mar 2026 00:10:11 -0700 Subject: [PATCH 43/53] fix: auth provider buttons no longer have drop shadow --- .../Pages/AuthFlow/Components/AuthProviderButtonView.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Components/AuthProviderButtonView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Components/AuthProviderButtonView.swift index 8ac6e332..c8e1e154 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Components/AuthProviderButtonView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Components/AuthProviderButtonView.swift @@ -57,12 +57,6 @@ struct AuthProviderButtonView: View { RoundedRectangle(cornerRadius: 16) .fill(getButtonBackgroundColor()) ) - .shadow( - color: Color.black.opacity(0.15), - radius: 8, - x: 0, - y: 4 - ) } private func getButtonBackgroundColor() -> Color { From 8fb054485121a7e7191ec33f64b339c8bfa00665 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sat, 21 Mar 2026 14:22:14 -0700 Subject: [PATCH 44/53] fix: verification code entry --- .../Registration/VerificationCodeView.swift | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift index 3dffe175..0d53914c 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift @@ -22,10 +22,8 @@ struct VerificationCodeView: View { var body: some View { VStack(spacing: 0) { - // Navigation Bar - matches activity creation flow positioning HStack { UnifiedBackButton { - // Clear any error states when going back userAuthViewModel.clearAllErrors() dismiss() } @@ -33,6 +31,7 @@ struct VerificationCodeView: View { } .padding(.horizontal, 25) .padding(.top, 16) + Spacer() mainContent Spacer() @@ -41,7 +40,6 @@ struct VerificationCodeView: View { .onAppear { viewModel.initialize() focusedIndex = viewModel.focusedIndex - // Clear any previous error state when this view appears userAuthViewModel.clearAllErrors() } .onDisappear { @@ -59,20 +57,6 @@ struct VerificationCodeView: View { .navigationBarHidden(true) } - private var navigationBar: some View { - HStack { - UnifiedBackButton { - // Go back one step in the onboarding flow - dismiss() - } - Spacer() - } - .padding(.horizontal, 25) - .padding(.top, 16) - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) - .zIndex(1) - } - private var mainContent: some View { VStack(spacing: 32) { titleSection @@ -161,7 +145,6 @@ struct VerificationCodeView: View { Binding( get: { viewModel.code[index] }, set: { newValue in - // The custom text field handles validation, so just update the value viewModel.code[index] = newValue } ) @@ -173,13 +156,25 @@ struct VerificationCodeView: View { await viewModel.verifyCode() } }) { - OnboardingButtonCoreView("Verify") { - viewModel.isFormValid ? figmaIndigo : Color.gray.opacity(0.6) + HStack { + Spacer() + Text("Verify") + .font(.onestSemiBold(size: 20)) + .foregroundColor(.white) + Spacer() } + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(viewModel.isFormValid ? figmaIndigo : Color.gray.opacity(0.6)) + ) + .shadow( + color: Color.black.opacity(0.15), + radius: 8, + x: 0, + y: 4 + ) } - .padding(.top, -16) - .padding(.bottom, -30) - .padding(.horizontal, -22) .disabled(!viewModel.isFormValid) } From 856954bd95a428c214ba62470058456634fc62bb Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 17:00:06 -0700 Subject: [PATCH 45/53] fix: tutorial flow ending properly --- .../ViewModels/AuthFlow/TutorialViewModel.swift | 12 ++++++++---- .../ActivityCreation/ActivityCreationView.swift | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift index 8790598e..d8a0ab77 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift @@ -158,10 +158,14 @@ final class TutorialViewModel { /// Handle activity creation completion during tutorial func handleActivityCreationComplete() { - guard case .activityCreation = tutorialState else { return } - - // Complete the tutorial - completeTutorial() + // Complete tutorial when user finishes creating an activity - handle both activityCreation + // (normal flow) and activityTypeSelection (user selected type directly in activities tab) + switch tutorialState { + case .activityCreation, .activityTypeSelection: + completeTutorial() + case .notStarted, .completed: + break + } } // MARK: - Server Sync Methods diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift index d30acd1d..ad43d876 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift @@ -218,6 +218,8 @@ struct ActivityCreationView: View { // If we're at activityType step and an activity type gets selected, skip to dateTime if currentStep == .activityType && newActivityType != nil { currentStep = .dateTime + // Progress tutorial from activityTypeSelection β†’ activityCreation so other tabs become enabled + tutorialViewModel.handleActivityTypeSelection(newActivityType!) } } } From a131a844fcd5befe0b776149707bcab322a15c28 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 17:53:45 -0700 Subject: [PATCH 46/53] fix: social media icon image resolution improved --- .../Image 1.imageset/Contents.json | 23 ++++++++++++++++++ .../Image 1.imageset/instagram-1x.png | Bin 0 -> 3973 bytes .../Image 1.imageset/instagram-2x.png | Bin 0 -> 10356 bytes .../Image 1.imageset/instagram-3x.png | Bin 0 -> 19141 bytes .../whatsapp.imageset/Contents.json | 15 ++++++++++-- .../whatsapp.imageset/whatsapp-1x.png | Bin 0 -> 5632 bytes .../whatsapp.imageset/whatsapp-2x.png | Bin 0 -> 16888 bytes .../whatsapp.imageset/whatsapp-3x.png | Bin 0 -> 32502 bytes .../whatsapp.imageset/whatsapp_logo.png | Bin 4026 -> 0 bytes 9 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-2x.png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-3x.png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-2x.png create mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png delete mode 100644 Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp_logo.png diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json new file mode 100644 index 00000000..773ad2d5 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "instagram-1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "instagram-2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "instagram-3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c7e64f9a2bc8e564ae030d581d7e764b97d65626 GIT binary patch literal 3973 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 S5z-+50000)~W+AQ)bFcnJF`6 zrp%O?GE-*COqnS&Wv0xOnKDym%1oIlGi9cv#lTGYMK8uVH+iEmpxg1YgP(J;FjIb! z3W3A}MHc^=Kw{_*mg}y&jy{@euf5iP7Xayb2BrYo@sNC&Pvap_vTcyUj<;5NTL?X=UnX<4>xS(hAJ!=*~sKqks?ozZnwV&le*B+A@5=bYnFL*fjMk$Gl-a|#s# zf{G>*OQPT!Y*;%oGGZqtCTzW4w^v+z@!Vg2#Vg)dtyEr{;X>znjIjn&YE#;p7sd7> z&%bkMaPSKsxaOJ%5OfYjS&q94SsdQ0ARS2WKb<8u1DsQ<1Z#*y2(p4XeE6_MU8YhL zg}Hp)x_KAA{`DV1R{0HV*f}jPK=4VGY57h-@FJ-wwjCKB{^0c={pdf{YPB4fqNk^) zKrj_33O#sA*P8-B8O)SZq6ph~;9w)t#|%M}A=6ZlX)4$L>7V}InP;B)Icx2OxR8Dm zBpEfXn&b)^zYVeqdE=vx9zoFuv{1SI_S+3A+khbQ z-&G-RE;EdCl8fpyRNnE9cUZEPaPgvvK!Xi9@8SzD{HtoM_M7Q>MsyL^q775l35*k& zCs=97Vn2Oy^X4l*|CO(7CHCp=?#}1TnNzG>xw0U=M?_ak#+d=mNiGT;>e*ywV{gxJ zogwRFuY29==3jo%MgNr9>|AvBrM?`Oo~vR=*Uk4x_Y-_lAmMU&>$Yug{firJ*o22R z&|1r%dFC0?e;nV!6{&wHToX=WS^r0m_Ki%``^~uRx31`;ZR**w)(jL4H+cKauh<0t zhKne|-rQCt7{vye+|`>t`RV(uh4V@$2mu)WU|eDZse%mRE#~7UGn0s{lppM-%Q^%KOw`xg0^Wd6z0Nx5CrPY=*%Y$I z?^e2l>>DtJ8L-X_ppCH2xNMWj++bt4vgy`aUpffCphX*wL=-X2`1rWN-d;s!_q701p63s~|HB`?5|#2geI@XmefHV8u#QW?^MV3r-S2;IUGB1L&{MUp z#43|OF_~?knRU`*{b$!2bP&&%>kZ@rYM2sorDH#-MkkXRLoBv-G03?J_5EHDK`#%RSX08=tgV43tB+ER!+Re{oc z9sb=o!0ld}#_GT1^ZksL4CVZel=&WqI@daWlZ8Qo{VhqV3 zU@R>tqOxoM@I&v)OyvxGKmrIDi%(rp1@MzI;H*<+`OKG`1--Lo>6Lk<%%Gr>?-S$W z@br&=40T-l%}qWY`hMEdrJrmx8ZR3g8w;^51WrSPqpIut+!=?&QsIiPTW+w)fPu(5 zX(N|d23i~sAmYk21Ecl#(s@nap~X3+;~cXacwlZq`7oFn*aSts4GfCGycnoKb>P|! z#}6#^4oe}0*!ydUZSZ@?XaD-M8*OHmm2?;qMZ_{DvJd_1oihur`t9F5uKPdy;DfOF z@yEmWQr=F&I@kQgUp#>Et_ftohFDo%T*>nDWt?^I`~EeM7uS+kksG*$UeXFMxWxu> z@{tpsshY!8s;XyoS z6W(hOZ}}J=KWMUSQ+2`(e(1Zc#h~!<%h^Y~vLdVGjaL zPW))KR|N_mIXq+@y!Qdu-P>bSOre4e_4-6Ais05SW5R zlIwX8>b{^CnHJ~xT4d1q^W9`-n*3bkLRcrvw4S7gX%vv-mp^~)!vtOwfiQrF4%pmn zb5*;k>WaY|-?(W2*1=CvDYh82H1p=o%P_kS`_`b};J5eV?tUvQXd9%ipqDKlgPw1_ z32u1BMUNG(ad=|k@aBV~dmgIUuGc`_cr(e!MF|viA7^shVOi%v@F0qSzB|kJcff#F zQHY*kbNO4}ieQ=VLGuubs5iguZ44Jk40*M>__38yX20y*bI*O?fd}qK(2$rSk9Hx- zkY}i$890~ydjw7HuC+-+!K6krWpPk=(~^NvvW}WqrU?kD3`5$$vR}bB8Az$PRA9OQ zQvF^6C+IUX^Z5Jea~Zh?B+skYNm&Y-fF~kYK}BEinro=DRbA)n7j-ZF50$*`vEn?d z@WF;qN6N7U7GLPdW%L_CD?$bxV}b&IvyP5VciG#nFbtXlaQkhy!Seq8mhC4TrOv|u zpgD8ro_plTkq78b{c^=}3R%^kPi2K9+?NYs~5 z6fj;bPJKa=(<51dK3QzpvSom-0v}6Lo#=T3$JVoJ@V@YKUGktdDE|54)YeygM<4H zz@6XtXICGez#qi5j=mn#+=i19;UPKDkbvxGG4}UYJsU07+9XhUV`k9%CR8i4ib*Jpf3=URmr!Ko|P7a*UhI zn2@k#%We7#n=CO|(;uHI!`nQOF!&0}nDMOR=@*}+(x@Pzh`+{KdGNyPFfJqQx*&uh z*72-NAdH9BI;(K;RhJ5A4#Hhuzuk>bjCu3ga~w>LmC*^?Dj(qEuS>}bPzQId)#`LRi_ECWWiW!+V%&hCV)rvm7Hhe}UXL<`nm#R{X~ zX=6DHU0fHqKpiXjXagLY#laohZbOI1~|YZ@fO)L?t`QbjGWGlqka{& zg6Jm{K7f#sYskl>EfNX=1}h7(K`cX7R6(ReQUgU8-4^v5&pD(CRXaQ2;&)yIa~IBK z(0t`~j3ZC@>l7w+t%s&BwWWBBJs&1xuK>NrL&!KJq5#7o{s_tyqC_(aKO8r3-u?E6 z*W)klk4j!r>8WlYN|rveX`+3u{Mb0OzdENAY74ueIJYZyr1De~6llbQ)4_L`U@ftOBN^+6n9a-RoiQqCPmZ_aNN! zrMsX}Z;%c|5HPX3SZAQMNRmemUv%Ht#KexCo*vIY=*w|vmODzbkeJo+Gft&&e;7ca zjc;Nl22kWhtYCd8S8g2Dc?c|>FPZ}uDL0%`0*aXom^MHO+x3{aoh^zBfy60;KuXPZ?H|^@Y{?NL#eF)NJBfr)NBIVh**TEqq%wmwVF|I z)lT{$nOA;&=O@;1e3OE%lX1;uofY%?_q+!Bmdt@eyAQ#=U$_?)VFX|1g`ffTG>c>$ zIx^&+b$DnfsBW#I>G~agKTpy3!NEc3?(U{|6@yH4oHB1sQ+{{-_xkboZ7tDv0*XN4 za~Fs3 zfw!i}G$)(AFbxEa^Wv-0Yv97)dj-rnZ8i+IdMTWr7NC)C$!3q@c z!Fp!(nxUa15F_sTOyyKlwBLsQ;7|VKoTEpNPGE$5oFe5*moBYu+_L#yau_meA=`*llHSX8$sZDr^2q4u-3S^lC}}^2ARK~5$6fhye86$$ZDfh0mQ6`# zaRL@FfvTB`BZzF(Lf2V~ptf{&%f^RhFf=g({nf^951I&o>3{+-1>;Qjy}JUn*-5NL4iA2=O4b6miw=J~qdAd_Ab0F#=$rVJ((ZCNdY-d|n@*{se-#LB8g5Sm)WuW8A8JD|0` z!WWG!)rvuy*{SKAmcW_>^mR`O5Pq=uy|H%)#s>~Uec!ML4c{kWIe_y03SzX1%VyP#t!7Z2-wn0{ z*Yjvyfyx40lL%-6)|5a=S;qxj1T^-Kc?neU zceB2Uu`9r?t-GQB#b?M+7S5BQ2ImL_hA5C-ys_?;Es{-SausZJlPekHSJwI{MbfFo zr1xmm;7*BYX!eWG{{81aznK7oOHOPxU(ubmh=3j3eiYZjiqAaoJ7_)*;veWmbmPYP}_%OK5|f;ReW3p(Da z@!}{+gN6`?ic}X9L{XI&#f~q0^{XGp?{x%C13{B_baYU@ae<)*nwa4EF-`@Fek3nY zC3h0@ghox-2Y+aH2h2KqDP$FW*4ARF7#W9=hn|JTo*@swY5*9Nz{<`xo>_{$vWE4B zcsVim^=5nw)ghV6ZvTiwC#}hM43;bm-{(<0)E4z3fKcgO(t}{?fGWBQXvyX#qEJxO z3P4f+NG(vk{vzCv8kCKxo&-Eq%7?|$TwM-HJrlcUGDfzG~s z$BrFt@#4i!M|GxFm4Ra{*RQ+h>p;og2A~vXNG_zU$37gsNAJs)L+6VZ%RZCn9g%4W zG)L~-3~qRwnZr983YE>kf1TbsS=-*kqVhQxkpW{8jOs&rQYdC(zKAR##`>9$tMFssrv{Z4)&_6_L^r1NP+LDNK zKJG;X#mYJTyduwc{PUf6{?@JE{N`4)q$ZGQCXi_+_V3^CgCMd@8qs;oj8iR&e!iQF zY*8}H%c6_6Wj!6xd-f8c;fT_d-revR4Bz<#SX_HO+shyNZyjg3dEsm#s|kpV0>-3z z4@ZyzlojQhWN`%$>hFkCAX<wN}(?1JV{mDyV>qj?2W5gjNk}{L|WVL-;cS1fsZWf%r zkgCC3S()?kEAkL}y~S{qg`XMXafMF<750gKYpGt>sMmjV_kH)h?_1yg_BPy6N6^%( z)oNXI8$WvmE}m&p&>RDK(WIXTZ{LKVnV>2V zrcuTyOb|Kbx|bm#CN#PVzF2gNUWc_x0fTQM;{s)|xx)7hKrxZ&3NaV?F!l_aW}k-_ za8Q442bf4ab!AetNnpDRb{lOLtqKsnIFmnVkrXofg%-^>Wh%V4eF!#NDG7v?Y zG2-A7NSJWE301&?$_f)18`zUkW|Tm=r3=g>ZNyur;%<+D2nM4il18le5lRoDltt8ngT6{Is&JX=t;o( z4Fnc28r0>%Vhjr?B}9eFGDg`-m0nRNOIJ6+&%~K0Ji3;aus92_yfy2gde} z_(#jJhA4tVsW{kJZess1Y~Md<2tZx)dZ0G9gYXqZlZ@hEr=A!wW8-6w92q(M{V&}9 z)mt9_;p2njH0X$X$=Y*i$<|fXKD%g5M!1!=7z)p9|g` zaFFUfpre9{SO183+0JLBL1y_hNmXqrk6RjCZxvDMf zG%G*ya_CU-SfxV#%zm2!hBqI8r$6#N{8adWC@IN=1XP*PHCFhbXyG_7nf6zDYko>d z+m-o{L!RYB+fP66ms|Ed{iBUf{_Bp%xBc)4UOgwL9x@Cmy4+ttEJliE*;LzSa*$6NRQ^S$S#VV$YGT+I`mID5gTMiWTLy?{ig(?&7A4yVk)HRlgATG)Q=2^#-g!fh$1j>h}_RGXoN3h4T*aTMCIiM z200ZJE+#OHVX)|Vxse7ppgo0$BqR)@MC;XI2`mBYe(|Bf@IW4AJ#c7p4_x!Gd-2CF zG+A#oh%C=O2_-Li`wBDsN<{Q;iCDEX|!f5E9!Xxr)KlDpo2txbX=>H*0CNY^dhJ$Oq64ZUKD$EWJhY4sOTzy zy)w*zv3jmBi3y@j7{*haQJ-Yb`3p@A!yEZ%1ID%yD%7sy%x96T=C){6I^>%lKgu5Qp+&M0YhTGW3^C-^N#j-BD;GkwQWi~V zOPhTMu@5!kK8OGj)gXceQqQz9o;v#EyOj}!Alze;$I)0*M-%+j^_d|EQf#>4HM z3p?EE>)!}nwcVz8)pY)DeP(PvGDExgYvtGE{6cTvuxH zS=N0Q%r};Tn#d*@i7cPSWjs(SHuJ`M)l{%PGTdirI|j|^b2dPJ8MKlbsJqk=G(j0A zc9BdzFS61w!ts=88Rw2yeYU^AAFW+cdy|i{84(jfGBCS$E}Q4SnP$0y>-r948@!$s zKMUvMDlw?i99vrKitY?saNHTnY@ z`-f297=wwQ9yE>Z2ayrRK}~7Wu6Z6`vFOC%!2dbIEC+fvSoL2oHnqN5khxLWzV z3t`da%i#d}XUHEc<9uQK*+JvbGOo_523^`DU761FPazHjwbrcACx9epdB)KbiGv9uXr<4tSQ7Q$tkA6B3RE-SCB1X z{ialj06}Cl_UuE(%0m{uksr@WEQrQK3-L} zjwXMe_lBh~c-IzS&z#p&j2p>e^oe~i_w}c>ton|nJwOU{K2&GNkn}CajmAE?O0**~ zw2MKVYF}qdN5vOpni7NC=#=NhGyxXV9Z)M8{U8dCe@2SylO<#Y2|6qUSXUv%kmx#5 zBS2B*KX2XcDL;6p0D}KU&Hdy&)iI1-Um*j*w_`nMDhuX7?^|DOx?g)vx$&UfkW+!D za{lSiyXG|C5Nf}gq~md?yvk34Bzt<9QxwJXc-NeXd`m| zFI(l<&yS8e)JaU~zAZGz`krCnq~H|M){O5Sfp(?q%sDXnlfy0~T_vstz8HkWc;QEq z1c%@SF4F=BmBO`Lu})n2L5x6f1_HAIwQ1+_g+>`Cp(0+x9q|2Pg|T`t-rJ=T&Fbm{x*!+ePLv5F}Vy zuy;KMFL0R_IM~R2+{YtOIL|n;j!%$coyF7^%?^wcV=(et4-P_hbl8d70KvE7TpAz^ zlZtuYhZov9Sjz?S>AvjM(EHn$H-QBv>ld=U^j%F~I|W0fBJ2!|T7Td&IB?o5IP~}5 z6@N{^4hX}#`M%FS3`_pt^;6d2#O`4jesm{DPPWi!!TxU3UezESM;X67!)Oy+;$xvI z;9l@4dZ%X`Q(voKi9VGgT0sCM-K4f;4%iaon9vxF!-t@f)!FGnTWoOK%FK8o-OAn; z@JYEY55cP5U%wc7uDT>#8k)hP003wUtY(%<*{Qh~FSzBG|lxlv3m%%{J}qa@Sw3wiYxnae3dyDUD2&e304MLtF+NS4S5j_K}%T3@H%U1|*f0j=qs&v{=A>oZ&nyb>l+FIG~O z27;*afa-b2o1o`6E}5E1OtV;O!8}j~*RhuQSHISuci@%>wJRAbe#m_{f6olw@k2NF z*VmfPGZ(r-+hg)PDoPg36IJL@HA4oWdD~FRkL|ImIJXjLs$g0$o zYMR6d7JG0ni7N088e>FFa+J#*!PX#(A!yAUIRa$(bGO6j z-H*hEiy>~Wm(-tq4&0WVF!tbPe@xG%7r^Y_x&$gqrU%f1-@Dix-Sks8wq-8_)7o%W zB**FfZ;$|Frk0M+;qhU0Uz40=PUd#IGPY}!Ke!TfU=nCT<(UCE0i``~Onr@0QcmV< zGXfZE)I8=e>X-;tmb$W>ZUobJ!yiF5rwgVl zqqp7*qo2FO6(ghIgIEw%08W1}oOSI4(UONbdiM`t zpK&bISFmS4PLgLJ31|lIFK4ZHoVnCwHAtzWBu29RyHPKiNIh6C+FabkUSQd1cGF!t zE_oefix;$eRB#BCBY*yBn0WjdzM8O0l4!*u&oOvpNG%IZVF+sK!+(FbIf^W_@UMRd zP3+Do?^i)1U_OeigSUK#8_yL&1yi<$b(928sUsic=9#8I-JycfRYbFkLzPryqs-6F011bQsbB0vBQtQbNXw+%DlfX?Js;Mp!-trvA*Rw*!TYbfx66C+k5uC{pAQQlASq( zv|4}fJUqKGJhLd@kJrpMe0*(M;7p_puG(V|Tarf+kc0Z?33*#id*IlKeg@3As|r9! zxnN$x(_nR(z%UC5k|=_|gJbQ;!`!*|!&SvmQPV@+c@vjg=| z&k$1S4QGuuvQI1G?LFO7Uu<|(7(gmg&LMEHN7Z@THn0UT2VT2&@$1?>B20YyJ1}wE zT^@|AxXX2w1$Z(>pPJxBu3wWS%YGq#4yVs1E?R2;+>L+tNjLoU@3g&t_l0Lkov_Gu5) z9%{L_8VKSb_uI1-FM@WZ*t!+8QH5EYFoCaBOS3UK1raaV;`vZH@0Z$MckGK_0}v79 z^IZHjb7yS;1MmcV45A>etc;WL4!PWmDDRZfb7Z){k$<=wijk>BR>$fk(7k3k1H*<4 zO~b$d9t9!#jy$J0e;8DiHjFdAYlN9l8CZ(=UME?Txb1|!e`{*SX;6$RP`=1(p8#)^ z^1aCMy%>Se+uq`F`}RTrig%nACiY4Z9lqgNl+jW-?iMF3l|9NHDPcaK~r z4~(M>pkhWST};%%WUqL6+iN!Nyu+-bu@DmoREE*F6NM%CXEz!Vf`$9hmu(B(Ud zthUe5wYDD=6y8PQonM7yZqm&8AdJmp(@Y}bvo^uear8b1McxD}HubJY-wt>;_6r`| z)H09nIzTz7GVB5s#_(n?nLQU&@KiDV{;9`!?4k zCMceKGUWd9!&nvyE=dmbEoTiRu2uFG6{^LHuLq)<={PZ6d@ru?>~?f29x~Y!-sR3U z{lLIvbDJMpBd9t8L3|H>)C03n=juh!u29$lB-F#5$%xpp5cC~|j`QVIqU-gEMGK)_G5dB0W>NivuCI%*f-;3?2D@U#l-DcBN)vVf5vimj z(M*gs5nEt_ZedisLLz#-k_@U$JMoa`5cD6*x{msXT2>-jSGKopyqiL z(QaCcs=k}2eBpZGf$pFknd6xeHVCJNJ2B2bDs33Yt>F|L8PVtea6l7+H<^lb_(fCq zo?`2>qV%d(r%pj9>1aB)W^`tm-uBjp6Z0Nqj0?M=jXW>A7iB~`2i#@&N}-u@-T`8D zAOM7*q4I<{^z_%~dk(aH9uhg;@8iepWin*NGYrS3)N?HRXisg)thU$Wh&ubCbv7*y z4D#dxlFG^t2=xAi6BBOm_nK-4K2@KZrV`97fkmp6MU`m}95Xr$>}-SJDAw~VgqD*F z%l@8els7U1N5 zu!`krWhM1OM*n-TctCmZ7%vWMOWOk{M<7xJ-A|Zgq!T-p?>c8iw6ChQ>HwUe(grxi zT8`F5ka%YBG~t3x=xMj7$1hVAw{;6hT85;I8leOyF9G!5w5tmQc#_MhpN}oOILw@5 z8=5JjSzzA8XKgY`?k@O-pgEu%dN8buxHv0dAu)R+9cb!8;!0;RkO@b`$pHQ#cvPdctw+GL0lsVR)0(cIeC?5`$mcTi>etkbzuEeDRXA5sfF!>6# zix#%>D#S7}ES8vWa8;>;9t^1;l$m(ih9YO9QQrpVvVx|Sg=eF`R(d{9_YRz~YQ!{Q z6}k{U<)Ej3Uo$J({t*jr7iEj)wR;>Wb{~*vwI3{JIA&cKRhX?G#+yqTh7}!WYKw|I z#zszlp<4xQ434W!bOYPHZlxs@&1vJ^DaU} zsmDPGaDom2H+y%s1*2KKSWI_i4kbYo$lR+;P)O=Q5yY0!iBkM<9BZO3=f?wMvs&rk zJYI+Nv+Am4ZGU1hI?9$6so|IkO2i=9;;9*@ z7(PnTd83N3m6THFH0bGF#f(!B?WCvXP`pjqG3vX7aY)!ot}*vz?Fed(izDF#B&esQ zr8!=)uw3ItYIZyCw4;aAz+K`1oT70<8Tg4m-Ud%`E`iQG1U&uqylJf0#m(EMHi3ck zs&mgX@p|gX&b@b7&2(lZO+e#-r^MJmLn)BPsUY<@vqOtco| z;&nKHYSc?zT{^ohOV@WFiHzf&q%y<=#04QHy%DqDoItNJl_~rnVeaC7>@;h|+K}Su)#$Z}(Q5?J=11E<{3~9qAOrQo1n;o`iqktaI9tT~ z>hTKP0J0DQaSE)iw_eotImRE~0*Tk!U&D&;hAGyWkRB_#<8(e8%2)O$8lgu9oJn$_ z08;OtJFz-WYv7oo&xm7=D>QKgT4`993sd0I-NxrK@C@ID24m9<+q91U`|Tss_KlaalI))+jYH*{Ug6aGvQ9!A(o0Do*;0??2397L&&DF#)Lyf; z?Q_(hcp5@1%7BTDny?o{=O5j-V^w>vF;Cqa(lOZ64e?q5KysfGF(f7}ErEj-zg9e_ zA$1~5FelXM#WtxA1b{_LroO|`VGuwlKoms$nZV=$<(&U+LAL`O*bVShn~v1e3-&jD zi`l`(XEf2X&5XjLE2LFu+zMO>x|$GZ)g&@`r+3FC7q-m@qWtEG$A7}2)#>$Pa5Y+| zg-f?pm&}27W$c-KqC`hebJ^)C*Ahm0#R(V^la?uf<%r|O_jn1x?_WDQKwu)NG_ zpEJv%DzK9a`cCTw!D3AS7O@d3)^PV%+k+EDmbvb4P-K#lo+g~7Jzg!Fn9>lLCg?rh z^P8b)>c0N}nKoth=oh}}@;&>NZK9%Ygn`6gW5r{@{s(WNn#$t7DX;5afC7?9s39N6 zxjw372iEa4dI~TOWW^dkG`G2dgGfn46fM2&%}N*_j!eV)OcYfm1vv$$NtI~_x7}i3 z|L(T8A#rK``X+$4yxpjRYFUwY38R4OF09Pz_Nf0IXIPAX;??*eoo!OSm}-cHwU1jX1hdwHS(30qjTu7W4OFbYC3%?IEFmsUDXzu#&h zVF%-)MI<6b0X3Zu=g^)+F@{7@77($>GGUts5HN@otQ48N<%agGLxq^1#Gy}00voM^J7Mb z;rfh+o>?YaxD?>r^Bwwwj6L^UIMy=$#c#O!LmPc4z(~BCe-eAET}ZHEKQRYCjaXgQ z=kBz-{=}9&3U*FA5hd717-W(;4T;dFhHIQqQ=H0V;8ab&Uwfc9nK;VmcdnoCW>nm` zU`hKs4DMl(11;)GgGxFNZ0Mja`R*Y2(eC>y`1@V-b~vVj0D`MmLqO`tu+JRyzbG=L zf#WFkd;SG=o39ytaApMB!{G`aq4)3TaYn-o68>Or!Xs*{+SgfuI*wKj=Pr{!Co5Nq zbU6A@=`<(Q6sIy7IEF-(bWTlY(X0fWzRkv8HC+_ji@8kBQzId5D+PxT7}vG%fADk3 zK6s>y_;=i!B$!D=V#9O^>bH?Mi}*(81a{|RedNBt;U%KJ{IJu1Du3p!Nzj=;`Dgh3sK?Kk+ zaCjSmX1x7WWI@>tX&5T4T|`h2SFx_Zg|>*IOH%sa^Z(U(Gyl!+ffH5gx7`lopZNzb zahL^k_)8&1u>qcVz_POJA7AtlR^fcG(o6X^ig6wF8?W&43HhB=YH$&(MN=0@a^vd=z9JvGF>6jR{zqMVFH1}KbZ9-d5Iw#P}XwRA?MC8WfGhU z&GzHWK=LDzkzHMR+LSEKim`Y0nZl`uSn?%Pg*i-fQk-zMp;88p&HDVC0<4Gy`gvJU zio?VM0X$x$nI_rOROX{DvE7zMl%`im75Qz>xcFH6-Cp}Ih(Pgj`M&#GgVPc zwPT#|ExRH3`9jYUATo;?6>dQobLQ(fkxQ!lsRaS7 z{1nADI0>bRai9+*N1NHjKwux8&YN`~C+0e!7;1lqmt2VPrf&e~JV2SI#ECZSz^I2p z-4vjDp`Qsvo<&7hs$Um!4O%^m=K3f97+~%a0ni#`yCrbU1ud@PzPq4!-A7PWbZwg1?;*|j1$U3YGse-FD0tslGitcEF zn=rb>11@JL38;0eVzbca!GD9h9V*Y z6>mos{&i-YimKLJ-w7NNla_MGAw%HUro0&c9m?@3AG^{J1nwu`v@7RcngmKU#9&M* z?&V`x;6lj3U*EErD#ge|QiRwUNFY{NQY}EehY8PaqJK(6ZWb&^V?twV?o1K@m6vH_2(?ZxY5hj8xIV>mlVjzc)bV_< zlR%+>Hbwvzf#*SF6MzY@0_YWJ0c4OkGeL7`LPMG0PzuVg-zRVTW**Yyd!4wzg|$r; z*d(#5c7A&?HTKw6=1o=wK!?W!5CKTM4a7h_ITjU_GH|RJ43%670ODR80AtcXB{ymV zLG#?>uI-&(`EC?vOVX54W){9C-jA*yfTF=vC%}jdR}q1i32Rto9Vc!C-3}?sn8;Fk zP5_v$P0SfC1ua4K31br$fnW|N`V5$q9RPTiE)dXly!yPhRWy#kahf8k8#O0~Wu|Oq z@oA~OPEu)R92EhQ$_bI?CyA?M8&&9Ix0<$h>OckgZGT11Iujz}3JA60Vrqp8tdKUc zim4Lg1twxv3_wrL)u3%@A3$7;Iy@od@c0~N=i+zK*fqD=1h;qUlmnBn4cpPG9-~Ed z{+Vs7sJ>-qh(M*27J!b@NJewAYIt%?aVkx~af2*20D=cOSXDOBNdQV>ftEc_0PLJb z*O`Tu&6R%%FlRAevuc@Ls=_Q41%T)`x-Eb-=tz-)=($*=D%f2D77=$ALK2y9egYa7 zSjV@vunkWhYJ@!;_k6b8JZOSw^1sYT(iMaREKPWqb=M`Y^jU>dm9fX3WyM&p!J^1h zoE78S$*>qMG&&Tv!bvL4qUy1z^d>#Oq?m#UJv~kKq6Yt$8)5no1j_lhAoDCcFKJ|A zAj{XRLX!>!WpNV3i)>fDNx@J)9zs+6K&~eIDHlu;~la-?euUh6(=4z-#}=!0bgyW7{ZB6co)YM+{PNE)iWO0>yA+ zmUR~wmJ-u|x*J^bJ>(bh-=eAa`^9LMUz(g1Ei)$~DjWwLm%JM6qWNvBWa6oK~?ZHUzk&a$%BAps5v!e&DzL7`?;`Rv1`$2~1m7U&70l zZ=)~hAzIiy!b5~*aw~E6X*As=Y^?!Np$w2AzCIYLE=G-a$yeS6+yp+fBz_^r0v|%1 zArHYtww#;{6Xp~Uaaj`~sW*~W-k&285+|<`=>B(CwylQIyM6%lo(b0QqeIJ;*bID* zkJP%?xz5_~A5WT9RR&Hzu>m?dxW?4kXH`5wjFdB&ObJ*a_=#H{{S3gom1sq+gkvhJ zF5#sLE74`ia{>Cj_8(IN089)BEAypEl|J$mj0z=N3*p#@+#_^OuB& zl5`HEc{$tQB$g6z%(WW^-A6CGA8TWs-=m?2X#JGgCIeMAgCmU_cf`SWug9xgkKu+z z$Nn@`w7kxE6)#$b(fd1$B9K5dgzNnXsQp_IP{S?|P9d?IP!i8PcjA~$sDXMXu`_*T zl%|O!ot$2w$>A}5$+!b>kq!JhOrQ|sRK6m5x?IooA28X<7q$J#qj!Jb59%X0_~?ps zTcsZPCBkq*pX?wXfRk7zX8>mA8(7(INX%piqATX+h(}fwTjSLJR7KOk7&h|1d?0tJB2zsBa1yX`?=(0dNYB^|{Ft zdmY@53h;UVC%}s7-^XxVh5SMttJq3g`Rd904`Ymqg0HkbhydHUg$DK+cvyrqI+08S?A%bew2c-Q7^R@-3$Jn^zsnFM#3C-2ug}1CH}} zJZ6PxpsdQdAQqiW+)k?VsUxbWnE!a|p!?*TKaTZu4ZkVb#4vzO*2*|6rW}Js6lEjg zOqIzW{{H}b9|1W3|3(Mkafq&;rI4>^PRocrciWv?j2|Jo<7tePBTH?^??XfQoVy(C z-D5ygIMiF~)G28BWzCcdL+kD?u>GsSz7qX37hQBrLu;r^JpMF{eC~F{Vt0AH@!;Q+rWjLcJ+V@6?M9fG_(KFlUod+aAW=y!i>`#?0m@>_@NtKq zMA7v-0Ht@6KzBnrGMP26~u6Qs0zI#j2>1Kv$_QY~NGK364fCNNI zLOK1BzKPFkozi<#~K_r)tvWWhBOBs9DZ-eoQ^y+NE95EbO`9b9xr zh%?Tc2aq`43KwQ8^LbQGX3KYxjc82@b~h@%J&Slbg$up%ar9$1!Nfz4f>U?Ah?_X+ ziugMme-1Gcm##BO(x9dC(z7=F=aZ@&PYWC>?jNuGECS~0z$|G_i|RV5H(9T&dCIBS z5}&;evj&YLdeRW^}r!iCgb0Wmxg=1}IZ(JB*1tR^Ho4IIU*g`vTR zwles3JrRMG3(zWP9?wGys*3A{xUS)P(Q3|xUUG~fk>e;d7OU|v#-8qZz|pK)&hapj z=;T%b&eL#%I&6&oH1Ru%RbEJ_L0ppbd#{62pd1t9XmNME?XxIi-xDk>WuD*&3;>Ga z$~5044FLfEA>PRaP|7$iFpw}!!ZNgOIGF*~as0}^3l^Axa@n7|6h!>FA=hm9YdWZ+ zS-oTxu930k5RVDd*E;W>{JD=CT> zHb)B=(hz;r(s^c)5Ir3u`#J8;)>IT{c3{=LA2}ryzGGq>E#~rXyw}~mejEOdYZyFE z@_0*Txi|!6dk8{O@n6$%4l!0}1Q6#?Q zfXnC!9(F?$KRI~zF~dNEDE`v#@iWFf`Ns+<=uKTbJ(J^xH#+>ou*#<1V8hCA7#yUp zvG5F}{u_y3`X6a*99=KkAs%~5unm>t_63+rZ@m`J`c=-|F;LQNVv47vhH9=3XojCl zX5<3dmn4murUWka9ho$+>PqH6r@E%05ElSr6Xhz1QOSDY73cHm@hLm1xD29Gwu&cN zQb}dxL*j7=eMDb7Ai$7&85v&{Z==?y0~eCR0Ak=YJpR_z_kQGNI7Q0yaw3^G-g+zc zzBgiJ-%?7`)M?q&Os9k^9g(0(UmnY1h6F(n_s-Py7HO?MK%-ew>Pn=cGb8Zcb=Z;uryPawmN!n3~=uoET5f;UYMN z%JcRoVR3F)3m;(MImIAIo-WC21sE|#W%vYHT?EFICn z;z>ZEtO5X00p;c>fkW6V`kOS(FVW8w0j8w8s}tNf2pNJPGRQN9yz9axbGPU%Sn+od z0TU6T0|3=`;ZG`xYgcTzb^uPHV&Lag+@tIJp<=Jb27M1M{pIjOBB=er(WMjoH9RL z+=HTU0Wb|{QigG96fOeQ@vG6juO)HNMeB)YwD9Z9lpJ`86HhEAP=%T&%8d>{Q1F=S zvE_IFiK41E6fx$)cR50=I8DkDr#^%@W_y5b&0@mJK5wSZ<$T8@o~ zAOi+k4t+tys3>bBI+5x(B>;r)Q5R~`XG-gIJH3$4r#Uotujc|&WUl`!DEAHzk2BCp^6^gyOTkFMOZr32F=3s-b#clkqHdU+% zEOloRFtDPWgDB1cFsXhM6lfmaoiIzP07sq2Np4MG9&sM37ukD^ffV7bl_b&a` zjRWutQ2tE-XS(7(eVMS6EUsANLDoQ!5nDA%;*LKzQqk9>EEha-uW(JFg~YP*OhO?A z3ScIzlZq{2p8;H7xAa?|x*2|f%KsL?nXb4GUWzQ`SPT{<2A$|y2(r3}K^1ipBMTyj z*rK49Mp)z5kccn^Okr3b@~>Nyea3FU$m`7u{`u3lz%O8#0nRkV&;3l(_>KW9>a0(# zLD2Lev#c%U&lK#56ecpdUGi-Pk$yJikljB~>AZRAt^eNu`~sI5;QWlmy>nfEW300u zRib|MZuB$bRJ_WY7ug^(%;9PV8@g`&(g4hqnKDym%1oIlGi9dCl$kP9{_U0j1!KOi U0|(`rC;$Ke07*qoM6N<$f{b&ndjJ3c literal 0 HcmV?d00001 diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json index a519a2bd..7c8a27ad 100644 --- a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json +++ b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json @@ -1,8 +1,19 @@ { "images" : [ { - "filename" : "whatsapp_logo.png", - "idiom" : "universal" + "filename" : "whatsapp-1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "whatsapp-2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "whatsapp-3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c2878283e0b0dfcd79557f96b677079884902b70 GIT binary patch literal 5632 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 a*!~AkLGfgKowRHK0000zu<&Mijo+{`^gmes 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 D{$b!f literal 0 HcmV?d00001 diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png new file mode 100644 index 0000000000000000000000000000000000000000..3b2b89a039a178732ae8eec0a26036998958914d GIT binary patch literal 32502 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≥n~uD&v7$8XL) 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 From f4669774e9447a01976f1d8b81325468c0836012 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 17:55:05 -0700 Subject: [PATCH 47/53] fix: misc. spacing --- .../ActivityPopup/ActivityPopupDrawer.swift | 2 +- .../Registration/VerificationCodeView.swift | 14 ++++++++------ .../Views/Pages/Friends/FriendSearchView.swift | 2 +- .../Pages/Friends/FriendsTab/FriendsTabView.swift | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift index 337a6651..d1a7ee5a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift @@ -100,7 +100,7 @@ struct ActivityPopupDrawer: View { } ) .allowsHitTesting(true) // Ensure buttons inside can still be tapped - .ignoresSafeArea(.all, edges: .bottom) // Always extend to bottom edge + .ignoresSafeArea(.container, edges: .bottom) // Extend past home indicator; keyboard handled in ChatroomContentView .zIndex(isExpanded ? 1000 : 1) // Ensure expanded popup appears above tab bar } .transition(.move(edge: .bottom)) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift index 0d53914c..ff73cfc3 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift @@ -22,6 +22,12 @@ struct VerificationCodeView: View { var body: some View { VStack(spacing: 0) { + Spacer() + mainContent + Spacer() + } + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) + .safeAreaInset(edge: .top, spacing: 0) { HStack { UnifiedBackButton { userAuthViewModel.clearAllErrors() @@ -30,13 +36,9 @@ struct VerificationCodeView: View { Spacer() } .padding(.horizontal, 25) - .padding(.top, 16) - - Spacer() - mainContent - Spacer() + .padding(.vertical, 16) + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) } - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) .onAppear { viewModel.initialize() focusedIndex = viewModel.focusedIndex diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift index ec3f8ac5..d7e52d90 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift @@ -386,7 +386,7 @@ struct FriendRowView: View { shareProfile: { shareProfile(for: userForProfile) } ) .background(universalBackgroundColor) - .presentationDetents([.height(364)]) + .presentationDetents([.height(520)]) } .alert("Remove Friend", isPresented: $showRemoveFriendConfirmation) { Button("Cancel", role: .cancel) {} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift index 426c94ca..25fd108a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift @@ -138,7 +138,7 @@ struct FriendsTabView: View { navigateToProfile: { navigateToProfile = true } ) .background(universalBackgroundColor) - .presentationDetents([.height(420)]) + .presentationDetents([.height(580)]) } } From 11564f93acc0b9833bd13294dc465180bc4a7fd9 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 18:11:59 -0700 Subject: [PATCH 48/53] chore: simplify logs --- .../Services/API/APIService.swift | 240 ++++++------------ .../Cache/FriendshipCacheService.swift | 54 +--- .../Services/Core/AppLog.swift | 47 ++++ .../Integration/DeepLinkManager.swift | 92 ++++--- .../Notifications/NotificationService.swift | 165 ++++++------ 5 files changed, 275 insertions(+), 323 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift index 04ceeef4..656fdbee 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift @@ -114,7 +114,7 @@ final class APIService: IAPIService, @unchecked Sendable { UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn } guard isAuthenticated else { - print("❌ Cannot make API call to \(urlString): User is not logged in") + AppLog.warning("Cannot make API call to \(urlString): User is not logged in", category: "API") throw APIError.invalidStatusCode(statusCode: 401) } } @@ -133,7 +133,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "Invalid URL", category: "API") throw APIError.URLError } var request = URLRequest(url: finalURL) @@ -158,7 +158,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(finalURL)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -212,33 +212,23 @@ final class APIService: IAPIService, @unchecked Sendable { // Only attempt array handling if we're not already expecting an array type if !isArrayType { - // Try to decode as an array of that type - print( - "Attempting to decode as array and extract the first item for entity type '\(T.self)' from URL: \(finalURL)" - ) - do { - // Use JSONSerialization first to check if it's an array if let jsonObject = try JSONSerialization.jsonObject(with: data) as? [Any], !jsonObject.isEmpty { - - // It is an array, try to decode it as such and take the first element if let firstItemData = try? JSONSerialization.data(withJSONObject: jsonObject[0]) { let firstItem = try decoder.decode(T.self, from: firstItemData) - print("Successfully decoded first item from array response") return firstItem } } } catch { - print("Failed to extract first item from array: \(error)") + AppLog.debug("Failed to extract first item from array: \(error)", category: "API") } } - // If we got here, throw the original decoding error - print("JSON Decoding Error: \(decodingError)") + AppLog.error("JSON decoding failed for \(finalURL): \(decodingError)", category: "API") if let jsonString = String(data: data, encoding: .utf8) { - print("Received JSON: \(jsonString)") + AppLog.debug("Response body: \(jsonString)", category: "API") } throw APIError.failedJSONParsing(url: finalURL) } @@ -258,23 +248,11 @@ final class APIService: IAPIService, @unchecked Sendable { urlString.contains("/optional-details") || urlString.contains("/contacts/cross-reference") if !isAuthEndpoint && !isWhitelistedEndpoint { - let (isAuthenticated, spawnUserId, isLoggedIn) = await MainActor.run { - ( - UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn, - UserAuthViewModel.shared.spawnUser?.id.uuidString, - UserAuthViewModel.shared.isLoggedIn - ) + let isAuthenticated = await MainActor.run { + UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn } guard isAuthenticated else { - print("❌ Cannot make API call to \(urlString): User is not logged in") - - // DEBUG: Extra logging for blocking endpoints - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ CRITICAL - User not logged in when attempting to block user!") - print("🚫 DEBUG: spawnUser: \(spawnUserId ?? "nil")") - print("🚫 DEBUG: isLoggedIn: \(isLoggedIn)") - } - + AppLog.warning("Cannot make API call to \(urlString): User is not logged in", category: "API") throw APIError.invalidStatusCode(statusCode: 401) } } @@ -293,7 +271,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } @@ -321,7 +299,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(finalURL)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -332,37 +310,15 @@ final class APIService: IAPIService, @unchecked Sendable { if httpResponse.statusCode == 401 && !(urlString.contains("/auth/sign-in") || urlString.contains("/auth/login")) { - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: Received 401 for blocking request, attempting token refresh...") - } - - // Handle token refresh logic here do { let newAccessToken: String = try await handleRefreshToken() - // Retry the request with the new access token let newData = try await retryRequest(request: &request, bearerAccessToken: newAccessToken) - - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: βœ… Token refresh successful, blocking request retried") - } - return try APIService.makeDecoder().decode(U.self, from: newData) } catch { - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ Token refresh failed for blocking request: \(error)") - } throw error } } - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ Blocking request failed with status code: \(httpResponse.statusCode)") - } - throw createAPIError(statusCode: httpResponse.statusCode, data: data) } // Handle auth tokens if present @@ -391,7 +347,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorMessage = APIError.failedJSONParsing(url: finalURL) .localizedDescription - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedJSONParsing(url: finalURL) } } @@ -420,7 +376,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } @@ -448,7 +404,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { let message = "HTTP request failed for \(finalURL)" - print(message) + AppLog.error(message, category: "API") throw APIError.failedHTTPRequest(description: message) } @@ -463,7 +419,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorStatusCode = httpResponse.statusCode let message = "Invalid status code \(httpResponse.statusCode) for \(finalURL)" - print(message) + AppLog.error(message, category: "API") throw APIError.invalidStatusCode( statusCode: httpResponse.statusCode) } @@ -493,13 +449,10 @@ final class APIService: IAPIService, @unchecked Sendable { components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } guard let urlWithParams = components.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } finalUrl = urlWithParams - print("πŸ”“ [APIService] DELETE URL with parameters: \(finalUrl.absoluteString)") - } else { - print("πŸ”“ [APIService] DELETE URL (no parameters): \(finalUrl.absoluteString)") } var request = URLRequest(url: finalUrl) @@ -510,9 +463,6 @@ final class APIService: IAPIService, @unchecked Sendable { let encoder = APIService.makeEncoder() request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try encoder.encode(object) - print("πŸ”“ [APIService] DELETE with body") - } else { - print("πŸ”“ [APIService] DELETE without body") } let response: URLResponse @@ -524,21 +474,18 @@ final class APIService: IAPIService, @unchecked Sendable { // For cancelled DELETE requests, just return without logging return } - print("πŸ”“ [APIService] DELETE request failed with error: \(error)") - print("πŸ”“ [APIService] Error details: \(error.localizedDescription)") + AppLog.warning("DELETE request failed: \(error.localizedDescription)", category: "API") // Re-throw non-cancellation errors throw error } guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } - print("πŸ”“ [APIService] DELETE response status code: \(httpResponse.statusCode)") - // Check for a successful status code (204 is commonly used for successful deletions) guard httpResponse.statusCode == 204 || httpResponse.statusCode == 200 else { @@ -552,7 +499,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorStatusCode = httpResponse.statusCode errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode( statusCode: httpResponse.statusCode) } @@ -595,19 +542,16 @@ final class APIService: IAPIService, @unchecked Sendable { if let imageData = image.jpegData(compressionQuality: 0.9) { let base64String = imageData.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Including profile picture data of size: \(imageData.count) bytes") } else if let pngData = image.pngData() { // Try PNG as a fallback let base64String = pngData.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Including PNG profile picture data of size: \(pngData.count) bytes") } } else if let profilePicUrl = parameters?["profilePicUrl"], !profilePicUrl.isEmpty { // If we have a URL from Google/Apple but no selected image, include it // Try both field names that the backend might be expecting userCreationDTO["profilePictureUrl"] = profilePicUrl userCreationDTO["profilePicture"] = profilePicUrl // Try this field name as well - print("Including profile picture URL from provider: \(profilePicUrl)") // Try to download the image from the URL and convert to base64 if let url = URL(string: profilePicUrl) { @@ -615,9 +559,9 @@ final class APIService: IAPIService, @unchecked Sendable { let (data, _) = try await URLSession.shared.data(from: url) let base64String = data.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Successfully downloaded and included Google profile picture data: \(data.count) bytes") } catch { - print("Failed to download Google profile picture: \(error.localizedDescription)") + AppLog.warning( + "Failed to download provider profile picture: \(error.localizedDescription)", category: "API") } } } @@ -681,7 +625,7 @@ final class APIService: IAPIService, @unchecked Sendable { let accessToken = response.allHeaderFields["Authorization"] as? String ?? response.allHeaderFields[ "authorization"] as? String else { - print("⚠️ WARNING: No access token found in response headers") + AppLog.warning("No access token found in response headers", category: "API") return } @@ -691,7 +635,7 @@ final class APIService: IAPIService, @unchecked Sendable { as? String if refreshToken == nil { - print("⚠️ WARNING: No refresh token found in response headers") + AppLog.debug("No refresh token found in response headers", category: "API") // Don't throw error here - some endpoints might only return access tokens } @@ -708,7 +652,7 @@ final class APIService: IAPIService, @unchecked Sendable { saveSuccessful = KeychainService.shared.save(key: "accessToken", data: accessTokenData) if !saveSuccessful { saveAttempts += 1 - print("⚠️ Access token save attempt \(saveAttempts) failed") + AppLog.warning("Access token save attempt \(saveAttempts) failed", category: "API") if saveAttempts < maxSaveAttempts { // Brief delay before retry Thread.sleep(forTimeInterval: 0.1) @@ -717,7 +661,8 @@ final class APIService: IAPIService, @unchecked Sendable { } if !saveSuccessful { - print("❌ ERROR: Failed to save access token to keychain after \(maxSaveAttempts) attempts") + AppLog.error( + "Failed to save access token to keychain after \(maxSaveAttempts) attempts", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } } @@ -734,7 +679,7 @@ final class APIService: IAPIService, @unchecked Sendable { saveSuccessful = KeychainService.shared.save(key: "refreshToken", data: refreshTokenData) if !saveSuccessful { saveAttempts += 1 - print("⚠️ Refresh token save attempt \(saveAttempts) failed") + AppLog.warning("Refresh token save attempt \(saveAttempts) failed", category: "API") if saveAttempts < maxSaveAttempts { // Brief delay before retry Thread.sleep(forTimeInterval: 0.1) @@ -743,7 +688,8 @@ final class APIService: IAPIService, @unchecked Sendable { } if !saveSuccessful { - print("❌ ERROR: Failed to save refresh token to keychain after \(maxSaveAttempts) attempts") + AppLog.error( + "Failed to save refresh token to keychain after \(maxSaveAttempts) attempts", category: "API") throw APIError.failedTokenSaving(tokenType: "refreshToken") } } @@ -751,7 +697,7 @@ final class APIService: IAPIService, @unchecked Sendable { fileprivate func setAuthHeader(request: inout URLRequest) { guard let url = request.url else { - print("❌ ERROR: URL is nil") + AppLog.error("URLRequest URL is nil", category: "API") return } @@ -770,22 +716,12 @@ final class APIService: IAPIService, @unchecked Sendable { return } - // DEBUG: Log for blocking endpoints specifically - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: Setting auth header for blocking endpoint: \(url.absoluteString)") - } - // Get the access token from keychain guard let accessTokenData = KeychainService.shared.load(key: "accessToken"), let accessToken = String(data: accessTokenData, encoding: .utf8) else { - print("⚠️ Missing access token for authenticated endpoint: \(url.absoluteString)") - - // DEBUG: Extra logging for blocking endpoints - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: ❌ CRITICAL - No access token available for blocking request!") - } + AppLog.debug("Missing access token for authenticated endpoint: \(url.absoluteString)", category: "API") // Check if we have a refresh token available if let refreshTokenData = KeychainService.shared.load(key: "refreshToken"), @@ -793,21 +729,18 @@ final class APIService: IAPIService, @unchecked Sendable { { // We have a refresh token, but we'll let the API call handle the refresh // This will happen in the 401 handler in fetchData/sendData methods - print("πŸ”„ Missing access token but refresh token exists - will refresh during API call") + AppLog.debug( + "Missing access token but refresh token exists - will refresh during API call", category: "API") } else { - print("❌ ERROR: Missing both access token and refresh token in Keychain") - print("πŸ”„ Will let API call fail and higher-level error handling manage re-authentication") + AppLog.warning( + "Missing both access token and refresh token in Keychain for \(url.absoluteString)", category: "API" + ) // Don't immediately sign out - let the API call fail and higher-level error handling // manage re-authentication. This preserves OAuth credentials during onboarding. } return } - // DEBUG: Log for blocking endpoints specifically - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: βœ… Successfully set Authorization header for blocking request") - } - // Set the auth headers request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } @@ -821,9 +754,8 @@ final class APIService: IAPIService, @unchecked Sendable { let encoder = APIService.makeEncoder() let encodedData = try encoder.encode(object) - // Debug: Log the request details - print("πŸ” PATCH REQUEST: \(url.absoluteString)") - print("πŸ” REQUEST BODY: \(String(data: encodedData, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.debug( + "PATCH \(url.absoluteString) body: \(String(data: encodedData, encoding: .utf8) ?? "")", category: "API") var request = URLRequest(url: url) request.httpMethod = "PATCH" @@ -844,13 +776,11 @@ final class APIService: IAPIService, @unchecked Sendable { throw error } - // Debug: Log the response details - print("πŸ” RESPONSE: \(response)") - print("πŸ” RESPONSE DATA: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.debug("PATCH response status: \((response as? HTTPURLResponse)?.statusCode ?? -1)", category: "API") guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print("❌ ERROR: HTTP request failed for \(url)") + AppLog.error("HTTP request failed for \(url)", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -864,10 +794,10 @@ final class APIService: IAPIService, @unchecked Sendable { errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print("❌ ERROR: Invalid status code \(httpResponse.statusCode) for \(url)") + AppLog.error("Invalid status code \(httpResponse.statusCode) for \(url)", category: "API") if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - print("❌ ERROR DETAILS: \(errorJson)") + AppLog.debug("Error details: \(errorJson)", category: "API") if httpResponse.statusCode == 400, let message = errorJson["message"] as? String @@ -891,10 +821,8 @@ final class APIService: IAPIService, @unchecked Sendable { } catch { errorMessage = APIError.failedJSONParsing(url: url).localizedDescription - print("❌ ERROR: JSON parsing failed for \(url): \(error)") - - print( - "❌ DATA THAT FAILED TO PARSE: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.error("JSON parsing failed for \(url): \(error)", category: "API") + AppLog.debug("Response body: \(String(data: data, encoding: .utf8) ?? "")", category: "API") throw APIError.failedJSONParsing(url: url) } @@ -927,12 +855,11 @@ final class APIService: IAPIService, @unchecked Sendable { func updateProfilePicture(_ imageData: Data, userId: UUID) async throws -> BaseUserDTO { guard let url = URL(string: APIService.baseURL + "users/\(userId)/profile-picture") else { - print("❌ ERROR: Failed to create URL for profile picture update") + AppLog.error("Failed to create URL for profile picture update", category: "API") throw APIError.URLError } - print("πŸ” UPDATING PROFILE PICTURE: Starting request to \(url.absoluteString)") - print("πŸ” REQUEST DATA SIZE: \(imageData.count) bytes") + AppLog.debug("Profile picture PATCH \(url.absoluteString) size: \(imageData.count) bytes", category: "API") // Create the request var request = URLRequest(url: url) @@ -940,10 +867,8 @@ final class APIService: IAPIService, @unchecked Sendable { request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") request.httpBody = imageData setAuthHeader(request: &request) // Set auth headers if needed - // Log request headers - print("πŸ” REQUEST HEADERS: \(request.allHTTPHeaderFields ?? [:])") - // Perform the request with detailed logging + // Perform the request let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession.shared.data(for: request) @@ -956,18 +881,9 @@ final class APIService: IAPIService, @unchecked Sendable { throw error } - print("πŸ” RESPONSE RECEIVED: \(response)") - - // Check if we can read the response as JSON or text - if let responseString = String(data: data, encoding: .utf8) { - print("πŸ” RESPONSE DATA: \(responseString)") - } else { - print("πŸ” RESPONSE DATA: Unable to convert to string (binary data of \(data.count) bytes)") - } - // Check the HTTP response guard let httpResponse = response as? HTTPURLResponse else { - print("❌ ERROR: Failed HTTP request - unable to get HTTP response") + AppLog.error("Profile picture update: no HTTP response", category: "API") throw APIError.failedHTTPRequest(description: "HTTP request failed") } @@ -979,11 +895,10 @@ final class APIService: IAPIService, @unchecked Sendable { let newData = try await retryRequest(request: &request, bearerAccessToken: newAccessToken) return try JSONDecoder().decode(BaseUserDTO.self, from: newData) } - print("❌ ERROR: Invalid status code \(httpResponse.statusCode)") + AppLog.error("Profile picture update: invalid status \(httpResponse.statusCode)", category: "API") - // Try to parse error details if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - print("❌ ERROR DETAILS: \(errorJson)") + AppLog.debug("Error details: \(errorJson)", category: "API") } throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) @@ -995,9 +910,8 @@ final class APIService: IAPIService, @unchecked Sendable { let updatedUser = try decoder.decode(BaseUserDTO.self, from: data) return updatedUser } catch { - print("❌ ERROR: Failed to decode user data after profile picture update: \(error)") - print( - "❌ DATA THAT FAILED TO PARSE: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.error("Failed to decode user after profile picture update: \(error)", category: "API") + AppLog.debug("Response body: \(String(data: data, encoding: .utf8) ?? "")", category: "API") throw APIError.failedJSONParsing(url: url) } } @@ -1065,7 +979,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } @@ -1081,7 +995,7 @@ final class APIService: IAPIService, @unchecked Sendable { } errorMessage = "Invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) } @@ -1090,9 +1004,9 @@ final class APIService: IAPIService, @unchecked Sendable { /// Refresh Token fileprivate func handleRefreshToken() async throws -> String { - print("πŸ”„ Attempting to refresh access token...") + AppLog.debug("Attempting to refresh access token", category: "API") guard let url = URL(string: APIService.baseURL + "auth/refresh-token") else { - print("❌ ERROR: Failed to create refresh token URL") + AppLog.error("Failed to create refresh token URL", category: "API") throw APIError.URLError } @@ -1100,8 +1014,7 @@ final class APIService: IAPIService, @unchecked Sendable { let refreshTokenData = KeychainService.shared.load(key: "refreshToken"), let refreshToken = String(data: refreshTokenData, encoding: .utf8) else { - print("❌ ERROR: Missing refresh token in Keychain - cannot refresh") - print("πŸ”„ Logging out user due to missing refresh token") + AppLog.error("Missing refresh token in Keychain β€” signing out", category: "API") // If refresh token is missing, immediately log out the user to prevent endless retry loops await MainActor.run { UserAuthViewModel.shared.signOut() @@ -1109,7 +1022,7 @@ final class APIService: IAPIService, @unchecked Sendable { throw APIError.failedTokenSaving(tokenType: "refreshToken") } - print("πŸ”„ Making refresh token request...") + AppLog.debug("Making refresh token request", category: "API") var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") @@ -1128,16 +1041,14 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { let message = "HTTP request failed for refresh token endpoint" - print("❌ ERROR: \(message)") + AppLog.error(message, category: "API") throw APIError.failedHTTPRequest(description: message) } - print("πŸ”„ Refresh token response status: \(httpResponse.statusCode)") + AppLog.debug("Refresh token response status: \(httpResponse.statusCode)", category: "API") if httpResponse.statusCode == 401 { - // Refresh token is invalid/expired - print("❌ ERROR: Refresh token invalid (401) - token may be expired") - print("πŸ”„ Clearing invalid tokens but preserving OAuth credentials for potential re-authentication") + AppLog.warning("Refresh token invalid (401)", category: "API") // Clear the invalid tokens let _ = KeychainService.shared.delete(key: "accessToken") @@ -1154,48 +1065,46 @@ final class APIService: IAPIService, @unchecked Sendable { ?? httpResponse.allHeaderFields["authorization"] as? String { let cleanAccessToken = newAccessToken.replacingOccurrences(of: "Bearer ", with: "") - print("πŸ” Received new access token, saving to keychain...") + AppLog.debug("Received new access token, saving to keychain", category: "API") if let accessTokenData = cleanAccessToken.data(using: .utf8) { if !KeychainService.shared.save(key: "accessToken", data: accessTokenData) { - print("❌ ERROR: Failed to save refreshed access token to keychain") + AppLog.error("Failed to save refreshed access token to keychain", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } return "Bearer \(cleanAccessToken)" } else { - print("❌ ERROR: Failed to convert access token to data") + AppLog.error("Failed to convert access token to data", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } } else { - print("❌ ERROR: No access token found in refresh response headers") + AppLog.error("No access token in refresh response headers", category: "API") throw APIError.failedHTTPRequest(description: "No access token in refresh response") } } - print("❌ ERROR: Unexpected status code \(httpResponse.statusCode) from refresh endpoint") + AppLog.error("Unexpected status code \(httpResponse.statusCode) from refresh endpoint", category: "API") - // Log response body for debugging if let responseBody = String(data: data, encoding: .utf8) { - print("πŸ” Refresh response body: \(responseBody)") + AppLog.debug("Refresh response body: \(responseBody)", category: "API") } throw APIError.failedHTTPRequest(description: "Failed to refresh token - status \(httpResponse.statusCode)") } fileprivate func retryRequest(request: inout URLRequest, bearerAccessToken: String) async throws -> Data { - // Retry the request with the new access token - print("πŸ”„ Retrying request with new access token") + AppLog.debug("Retrying request with new access token", category: "API") request.setValue(bearerAccessToken, forHTTPHeaderField: "Authorization") let (newData, newResponse) = try await URLSession.shared.data(for: request) guard let newHttpResponse = newResponse as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(request.url?.absoluteString ?? "unknown URL")" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } guard (200...299).contains(newHttpResponse.statusCode) else { errorMessage = "Invalid status code \(newHttpResponse.statusCode) for \(request.url?.absoluteString ?? "unknown URL")" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: newHttpResponse.statusCode) } return newData @@ -1206,7 +1115,6 @@ final class APIService: IAPIService, @unchecked Sendable { // Don't send validation request if there are no cached items to validate if cachedItems.isEmpty { - print("No cached items to validate, returning empty response") return [:] } @@ -1256,7 +1164,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Validate the response guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } @@ -1266,7 +1174,7 @@ final class APIService: IAPIService, @unchecked Sendable { } else if httpResponse.statusCode != 200 { errorStatusCode = httpResponse.statusCode errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) } @@ -1306,9 +1214,9 @@ final class APIService: IAPIService, @unchecked Sendable { if let message = errorMessage { // Store the error message for the view model to use self.errorMessage = message - print("API Error (\(statusCode)): \(message)") + AppLog.warning("API error \(statusCode): \(message)", category: "API") } else { - print("API Error (\(statusCode)): No message available") + AppLog.warning("API error \(statusCode): (no message)", category: "API") } return APIError.invalidStatusCode(statusCode: statusCode) diff --git a/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift b/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift index 440529c3..18a88bd2 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift @@ -48,7 +48,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get friends for the current user func getCurrentUserFriends() -> [FullFriendUserDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserFriends: No user ID") return [] } let userFriends = friends[userId] ?? [] @@ -150,7 +149,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb let startTime = Date() guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh friends: No logged in user") return } @@ -161,7 +159,7 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb let duration = Date().timeIntervalSince(startTime) if duration > 0.5 { - print("⏱️ [FRIENDSHIP-CACHE] refreshFriends took \(String(format: "%.2f", duration))s") + AppLog.debug("refreshFriends took \(String(format: "%.2f", duration))s", category: "FriendshipCache") } } @@ -211,7 +209,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh recommended friends from the backend func refreshRecommendedFriends() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh recommended friends: No logged in user") return } @@ -233,12 +230,9 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get friend requests for the current user func getCurrentUserFriendRequests() -> [FetchFriendRequestDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserFriendRequests: No user ID") return [] } let requests = friendRequests[userId] ?? [] - print( - "πŸ“¦ [FRIENDSHIP-CACHE] getCurrentUserFriendRequests returned \(requests.count) requests for user \(userId)") return requests } @@ -297,21 +291,16 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh friend requests from the backend func refreshFriendRequests() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh friend requests: No logged in user") return } guard UserAuthViewModel.shared.isLoggedIn else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh friend requests: User is not logged in") return } - print("πŸ”„ [FRIENDSHIP-CACHE] Refreshing incoming friend requests for user: \(userId)") - await genericRefresh(endpoint: "friend-requests/incoming/\(userId)") { [weak self] (fetchedFriendRequests: [FetchFriendRequestDTO]) in guard let self = self else { return } - print("πŸ”„ [FRIENDSHIP-CACHE] Retrieved \(fetchedFriendRequests.count) incoming friend requests from API") self.updateFriendRequestsForUser(fetchedFriendRequests, userId: userId) } } @@ -327,22 +316,14 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get sent friend requests for the current user func getCurrentUserSentFriendRequests() -> [FetchSentFriendRequestDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserSentFriendRequests: No user ID") return [] } let requests = sentFriendRequests[userId] ?? [] - print( - "πŸ“¦ [FRIENDSHIP-CACHE] getCurrentUserSentFriendRequests returned \(requests.count) requests for user \(userId)" - ) return requests } /// Update sent friend requests for a specific user func updateSentFriendRequestsForUser(_ newSentFriendRequests: [FetchSentFriendRequestDTO], userId: UUID) { - print( - "πŸ’Ύ [FRIENDSHIP-CACHE] Updating sent friend requests cache for user \(userId): \(newSentFriendRequests.count) requests" - ) - // Normalize: remove zero UUIDs and unique by id let zeroUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! var seen = Set() @@ -395,21 +376,16 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh sent friend requests from the backend func refreshSentFriendRequests() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh sent friend requests: No logged in user") return } guard UserAuthViewModel.shared.isLoggedIn else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh sent friend requests: User is not logged in") return } - print("πŸ”„ [FRIENDSHIP-CACHE] Refreshing sent friend requests for user: \(userId)") - await genericRefresh(endpoint: "friend-requests/sent/\(userId)") { [weak self] (fetchedSentFriendRequests: [FetchSentFriendRequestDTO]) in guard let self = self else { return } - print("πŸ”„ [FRIENDSHIP-CACHE] Retrieved \(fetchedSentFriendRequests.count) sent friend requests from API") self.updateSentFriendRequestsForUser(fetchedSentFriendRequests, userId: userId) } } @@ -422,7 +398,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Force refresh both incoming and sent friend requests func forceRefreshAllFriendRequests() async { - print("πŸ”„ [FRIENDSHIP-CACHE] Force refreshing all friend request data") async let incomingTask: () = refreshFriendRequests() async let sentTask: () = refreshSentFriendRequests() @@ -452,7 +427,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh recently spawned with users from the backend func refreshRecentlySpawnedWith() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh recently spawned with: No logged in user") return } @@ -500,7 +474,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb // Clear cache timestamps for this user clearLastCheckedForUser(userId) - print("πŸ’Ύ [FRIENDSHIP-CACHE] Cleared all cached data for user \(userId)") saveToDisk() } @@ -509,7 +482,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb } func forceRefreshAll() async { - print("πŸ”„ [FRIENDSHIP-CACHE] Force refreshing all friendship data") async let friendsTask: () = refreshFriends() async let recommendedTask: () = refreshRecommendedFriends() async let requestsTask: () = refreshFriendRequests() @@ -526,26 +498,18 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Diagnostic method to force refresh all data with detailed logging func diagnosticForceRefresh() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("❌ [DIAGNOSTIC] Cannot run diagnostic: No user ID available") return } - print("πŸ” [DIAGNOSTIC] Starting diagnostic refresh for user: \(userId)") - print("πŸ” [DIAGNOSTIC] Current state before refresh:") - print(" - Friends: \(friends[userId]?.count ?? 0)") - print(" - Friend requests: \(friendRequests[userId]?.count ?? 0)") - print(" - Sent friend requests: \(sentFriendRequests[userId]?.count ?? 0)") - print(" - Recommended friends: \(recommendedFriends[userId]?.count ?? 0)") - print(" - Recently spawned with: \(recentlySpawnedWith[userId]?.count ?? 0)") - + AppLog.debug( + "diagnostic refresh: friends=\(friends[userId]?.count ?? 0) incoming=\(friendRequests[userId]?.count ?? 0) sent=\(sentFriendRequests[userId]?.count ?? 0)", + category: "FriendshipCache" + ) await forceRefreshAll() - - print("πŸ” [DIAGNOSTIC] State after refresh:") - print(" - Friends: \(friends[userId]?.count ?? 0)") - print(" - Friend requests: \(friendRequests[userId]?.count ?? 0)") - print(" - Sent friend requests: \(sentFriendRequests[userId]?.count ?? 0)") - print(" - Recommended friends: \(recommendedFriends[userId]?.count ?? 0)") - print(" - Recently spawned with: \(recentlySpawnedWith[userId]?.count ?? 0)") + AppLog.debug( + "diagnostic after: friends=\(friends[userId]?.count ?? 0) incoming=\(friendRequests[userId]?.count ?? 0) sent=\(sentFriendRequests[userId]?.count ?? 0)", + category: "FriendshipCache" + ) } // MARK: - Persistence diff --git a/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift b/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift new file mode 100644 index 00000000..5524a0c7 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift @@ -0,0 +1,47 @@ +// +// AppLog.swift +// Spawn-App-iOS-SwiftUI +// +// Centralized logging with severity levels. Adjust `minimumLevel` to control console noise +// (e.g. `.warning` for errors/warnings only, `.info` or `.debug` when investigating issues). +// + +import Foundation + +enum AppLog { + enum Level: Int, Comparable { + case debug = 0 + case info = 1 + case warning = 2 + case error = 3 + + static func < (lhs: Level, rhs: Level) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + /// Only messages at this level or higher are printed. Default is quiet (warnings and errors). + nonisolated(unsafe) static var minimumLevel: Level = .warning + + static func log(_ level: Level, category: String? = nil, _ message: @autoclosure () -> String) { + guard level >= minimumLevel else { return } + let prefix = category.map { "[\($0)] " } ?? "" + print("\(prefix)\(message())") + } + + static func debug(_ message: @autoclosure () -> String, category: String? = nil) { + log(.debug, category: category, message()) + } + + static func info(_ message: @autoclosure () -> String, category: String? = nil) { + log(.info, category: category, message()) + } + + static func warning(_ message: @autoclosure () -> String, category: String? = nil) { + log(.warning, category: category, message()) + } + + static func error(_ message: @autoclosure () -> String, category: String? = nil) { + log(.error, category: category, message()) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift b/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift index 4bbfc83d..a0fb2f2d 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift @@ -23,11 +23,13 @@ final class DeepLinkManager: ObservableObject { // MARK: - URL Handling func handleURL(_ url: URL) { - print("πŸ”— DeepLinkManager: Handling incoming URL: \(url.absoluteString)") + AppLog.debug("πŸ”— DeepLinkManager: Handling incoming URL: \(url.absoluteString)", category: "DeepLink") // Handle both custom URL schemes (spawn://) and Universal Links (https://) guard url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") else { - print("❌ DeepLinkManager: Invalid URL scheme or host: \(url.scheme ?? "nil")://\(url.host ?? "nil")") + AppLog.warning( + "❌ DeepLinkManager: Invalid URL scheme or host: \(url.scheme ?? "nil")://\(url.host ?? "nil")", + category: "DeepLink") return } @@ -40,8 +42,9 @@ final class DeepLinkManager: ObservableObject { let host = url.host ?? "" let pathComponents = url.pathComponents.filter { $0 != "/" } - print( - "πŸ”— DeepLinkManager: Parsing URL - Scheme: \(url.scheme ?? "nil"), Host: \(host), Path components: \(pathComponents)" + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing URL - Scheme: \(url.scheme ?? "nil"), Host: \(host), Path components: \(pathComponents)", + category: "DeepLink" ) // Handle Universal Links: https://getspawn.com/activity/{activityId} or https://getspawn.com/profile/{profileId} @@ -54,19 +57,21 @@ final class DeepLinkManager: ObservableObject { return parseCustomURLScheme(host: host, pathComponents: pathComponents) } - print("❌ DeepLinkManager: Unknown URL format") + AppLog.warning("❌ DeepLinkManager: Unknown URL format", category: "DeepLink") return .unknown } // MARK: - Universal Link Parsing private func parseUniversalLink(pathComponents: [String]) -> DeepLinkType { - print("πŸ”— DeepLinkManager: Parsing Universal Link with path components: \(pathComponents)") + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing Universal Link with path components: \(pathComponents)", category: "DeepLink") // Universal Link format: https://getspawn.com/activity/{activityId} or https://getspawn.com/profile/{profileId} // Share code format: https://getspawn.com/activity/{shareCode} or https://getspawn.com/profile/{shareCode} guard pathComponents.count >= 2 else { - print("❌ DeepLinkManager: Invalid Universal Link - not enough path components") + AppLog.warning( + "❌ DeepLinkManager: Invalid Universal Link - not enough path components", category: "DeepLink") return .unknown } @@ -79,7 +84,7 @@ final class DeepLinkManager: ObservableObject { return .activity(activityId) } else { // New share code format: https://getspawn.com/activity/{shareCode} - print("πŸ”— DeepLinkManager: Detected activity share code: \(identifier)") + AppLog.debug("πŸ”— DeepLinkManager: Detected activity share code: \(identifier)", category: "DeepLink") resolveActivityShareCode(shareCode: identifier) return .unknown // Return unknown for now, will be handled by resolution } @@ -89,19 +94,21 @@ final class DeepLinkManager: ObservableObject { return .profile(profileId) } else { // New share code format: https://getspawn.com/profile/{shareCode} - print("πŸ”— DeepLinkManager: Detected profile share code: \(identifier)") + AppLog.debug("πŸ”— DeepLinkManager: Detected profile share code: \(identifier)", category: "DeepLink") resolveProfileShareCode(shareCode: identifier) return .unknown // Return unknown for now, will be handled by resolution } } else { - print("❌ DeepLinkManager: Unknown Universal Link type: \(type)") + AppLog.warning("❌ DeepLinkManager: Unknown Universal Link type: \(type)", category: "DeepLink") } return .unknown } private func parseCustomURLScheme(host: String, pathComponents: [String]) -> DeepLinkType { - print("πŸ”— DeepLinkManager: Parsing custom URL scheme with host: \(host), path components: \(pathComponents)") + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing custom URL scheme with host: \(host), path components: \(pathComponents)", + category: "DeepLink") // Custom URL scheme format: spawn://activity/{activityId} or spawn://profile/{profileId} if host == "activity" { @@ -113,8 +120,9 @@ final class DeepLinkManager: ObservableObject { { return .activity(activityId) } else { - print( - "❌ DeepLinkManager: Failed to parse custom URL scheme activity ID from: \(activityIdString ?? "nil")" + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme activity ID from: \(activityIdString ?? "nil")", + category: "DeepLink" ) } } else if host == "profile" { @@ -126,8 +134,10 @@ final class DeepLinkManager: ObservableObject { { return .profile(profileId) } else { - print( - "❌ DeepLinkManager: Failed to parse custom URL scheme profile ID from: \(profileIdString ?? "nil")") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme profile ID from: \(profileIdString ?? "nil")", + category: "DeepLink" + ) } } else if pathComponents.count >= 2 { let type = pathComponents[0] @@ -137,19 +147,24 @@ final class DeepLinkManager: ObservableObject { if let activityId = UUID(uuidString: idString) { return .activity(activityId) } else { - print("❌ DeepLinkManager: Failed to parse custom URL scheme path activity ID from: \(idString)") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme path activity ID from: \(idString)", + category: "DeepLink") } } else if type == "profile" { if let profileId = UUID(uuidString: idString) { return .profile(profileId) } else { - print("❌ DeepLinkManager: Failed to parse custom URL scheme path profile ID from: \(idString)") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme path profile ID from: \(idString)", + category: "DeepLink") } } } - print( - "❌ DeepLinkManager: Invalid custom URL scheme format - expected spawn://activity/{activityId} or spawn://profile/{profileId}" + AppLog.warning( + "❌ DeepLinkManager: Invalid custom URL scheme format - expected spawn://activity/{activityId} or spawn://profile/{profileId}", + category: "DeepLink" ) return .unknown } @@ -158,7 +173,7 @@ final class DeepLinkManager: ObservableObject { private func processPendingDeepLink(_ deepLink: DeepLinkType) { switch deepLink { case .activity(let activityId): - print("🎯 DeepLinkManager: Processing activity deep link: \(activityId)") + AppLog.debug("🎯 DeepLinkManager: Processing activity deep link: \(activityId)", category: "DeepLink") pendingDeepLink = deepLink activityToShow = activityId shouldShowActivity = true @@ -174,7 +189,7 @@ final class DeepLinkManager: ObservableObject { ) case .profile(let profileId): - print("🎯 DeepLinkManager: Processing profile deep link: \(profileId)") + AppLog.debug("🎯 DeepLinkManager: Processing profile deep link: \(profileId)", category: "DeepLink") pendingDeepLink = deepLink profileToShow = profileId shouldShowProfile = true @@ -190,7 +205,7 @@ final class DeepLinkManager: ObservableObject { ) case .unknown: - print("❌ DeepLinkManager: Unknown deep link type, ignoring") + AppLog.warning("❌ DeepLinkManager: Unknown deep link type, ignoring", category: "DeepLink") pendingDeepLink = nil activityToShow = nil shouldShowActivity = false @@ -204,12 +219,13 @@ final class DeepLinkManager: ObservableObject { private func handleActivityDeepLink(_ activityId: UUID) { // If user has the app and is authenticated, open activity in-app if UserAuthViewModel.shared.isLoggedIn { - print("🎯 DeepLinkManager: User authenticated - opening activity in-app") + AppLog.debug("🎯 DeepLinkManager: User authenticated - opening activity in-app", category: "DeepLink") // The activity will be opened by the ContentView listening to notifications // The deep link will register the user as invited to this activity // and show the activity popup in the UI } else { - print("🎯 DeepLinkManager: User not authenticated - will show activity invite page") + AppLog.debug( + "🎯 DeepLinkManager: User not authenticated - will show activity invite page", category: "DeepLink") // If not authenticated, the app will show the activity invite page // where users can see activity details and join/install the app } @@ -218,21 +234,23 @@ final class DeepLinkManager: ObservableObject { private func handleProfileDeepLink(_ profileId: UUID) { // If user has the app and is authenticated, open profile in Friends page if UserAuthViewModel.shared.isLoggedIn { - print("🎯 DeepLinkManager: User authenticated - opening profile in Friends page") + AppLog.debug( + "🎯 DeepLinkManager: User authenticated - opening profile in Friends page", category: "DeepLink") // The profile will be opened in the Friends tab by ContentView } else { - print("🎯 DeepLinkManager: User not authenticated - will show profile invite page") + AppLog.debug( + "🎯 DeepLinkManager: User not authenticated - will show profile invite page", category: "DeepLink") // If not authenticated, show a profile preview/invite page } } // MARK: - Share Code Resolution private func resolveActivityShareCode(shareCode: String) { - print("πŸ”— DeepLinkManager: Resolving activity share code: \(shareCode)") + AppLog.debug("πŸ”— DeepLinkManager: Resolving activity share code: \(shareCode)", category: "DeepLink") let urlString = "\(ServiceConstants.URLs.apiBase)share/activity/\(shareCode)" guard let url = URL(string: urlString) else { - print("❌ DeepLinkManager: Invalid URL for activity share code: \(shareCode)") + AppLog.warning("❌ DeepLinkManager: Invalid URL for activity share code: \(shareCode)", category: "DeepLink") return } @@ -247,22 +265,24 @@ final class DeepLinkManager: ObservableObject { let activityIdString = json["id"] as? String, let activityId = UUID(uuidString: activityIdString) else { - print("❌ DeepLinkManager: Failed to resolve activity share code: \(shareCode)") + AppLog.warning( + "❌ DeepLinkManager: Failed to resolve activity share code: \(shareCode)", category: "DeepLink") return } self?.processPendingDeepLink(.activity(activityId)) } catch { - print("❌ DeepLinkManager: Network error resolving activity share code: \(error)") + AppLog.warning( + "❌ DeepLinkManager: Network error resolving activity share code: \(error)", category: "DeepLink") } } } private func resolveProfileShareCode(shareCode: String) { - print("πŸ”— DeepLinkManager: Resolving profile share code: \(shareCode)") + AppLog.debug("πŸ”— DeepLinkManager: Resolving profile share code: \(shareCode)", category: "DeepLink") let urlString = "\(ServiceConstants.URLs.apiBase)share/profile/\(shareCode)" guard let url = URL(string: urlString) else { - print("❌ DeepLinkManager: Invalid URL for profile share code: \(shareCode)") + AppLog.warning("❌ DeepLinkManager: Invalid URL for profile share code: \(shareCode)", category: "DeepLink") return } @@ -277,19 +297,21 @@ final class DeepLinkManager: ObservableObject { let profileIdString = json["id"] as? String, let profileId = UUID(uuidString: profileIdString) else { - print("❌ DeepLinkManager: Failed to resolve profile share code: \(shareCode)") + AppLog.warning( + "❌ DeepLinkManager: Failed to resolve profile share code: \(shareCode)", category: "DeepLink") return } self?.processPendingDeepLink(.profile(profileId)) } catch { - print("❌ DeepLinkManager: Network error resolving profile share code: \(error)") + AppLog.warning( + "❌ DeepLinkManager: Network error resolving profile share code: \(error)", category: "DeepLink") } } } // MARK: - State Management func clearPendingDeepLink() { - print("πŸ”— DeepLinkManager: Clearing pending deep link") + AppLog.debug("πŸ”— DeepLinkManager: Clearing pending deep link", category: "DeepLink") pendingDeepLink = nil activityToShow = nil shouldShowActivity = false diff --git a/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift b/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift index 1dca7fbc..0b47faeb 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift @@ -106,31 +106,31 @@ final class NotificationService: NSObject, ObservableObject { } return granted } catch { - print("Error requesting notification permission: \(error.localizedDescription)") + AppLog.warning("Error requesting notification permission: \(error.localizedDescription)", category: "Push") return false } } // Store device token when received from Apple func registerDeviceToken(_ deviceToken: String) { - print("[PUSH DEBUG] Device token received: \(deviceToken)") + AppLog.debug("Device token received: \(deviceToken)", category: "Push") // Store the token for later use storedDeviceToken = deviceToken // Only try to register with backend if user is already logged in if UserAuthViewModel.shared.isLoggedIn { - print("[PUSH DEBUG] User is logged in, registering token with backend immediately") + AppLog.debug("User is logged in, registering token with backend immediately", category: "Push") registerStoredTokenWithBackend() } else { - print("[PUSH DEBUG] User not logged in yet. Token stored for later registration") + AppLog.debug("User not logged in yet. Token stored for later registration", category: "Push") } } // Register the stored token with the backend func registerStoredTokenWithBackend() { guard let token = storedDeviceToken else { - print("No device token available to register") + AppLog.debug("No device token available to register", category: "Push") return } sendTokenToBackend(token) @@ -157,30 +157,31 @@ final class NotificationService: NSObject, ObservableObject { to: url, parameters: nil ) - print("[PUSH DEBUG] Successfully registered device token with backend") + AppLog.debug("Successfully registered device token with backend", category: "Push") } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print( - "[PUSH DEBUG] Device token registration endpoint not available (404): Backend may not support push notifications yet" + AppLog.debug( + "Device token registration endpoint not available (404): Backend may not support push notifications yet", + category: "Push" ) } else { - print("[PUSH DEBUG] Failed to register device token: \(error.localizedDescription)") - print("[PUSH DEBUG] API Error details: \(error)") + AppLog.debug("Failed to register device token: \(error.localizedDescription)", category: "Push") + AppLog.debug("API Error details: \(error)", category: "Push") } } catch { - print("[PUSH DEBUG] Failed to register device token: \(error.localizedDescription)") - print("[PUSH DEBUG] Error details: \(error)") + AppLog.debug("Failed to register device token: \(error.localizedDescription)", category: "Push") + AppLog.debug("Error details: \(error)", category: "Push") } } } else { - print("[PUSH DEBUG] Cannot register device token: user not logged in or missing ID") + AppLog.debug("Cannot register device token: user not logged in or missing ID", category: "Push") if UserAuthViewModel.shared.spawnUser == nil { - print("[PUSH DEBUG] User not logged in (spawnUser is nil)") + AppLog.debug("User not logged in (spawnUser is nil)", category: "Push") } else if let user = UserAuthViewModel.shared.spawnUser { - print("[PUSH DEBUG] User logged in but ID is nil. Username: \(user.username ?? "N/A")") + AppLog.debug("User logged in but ID is nil. Username: \(user.username ?? "N/A")", category: "Push") } - print("[PUSH DEBUG] APIService baseURL: \(APIService.baseURL)") + AppLog.debug("APIService baseURL: \(APIService.baseURL)", category: "Push") } } @@ -214,7 +215,7 @@ final class NotificationService: NSObject, ObservableObject { // Add to notification center UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error scheduling notification: \(error.localizedDescription)") + AppLog.warning("Error scheduling notification: \(error.localizedDescription)", category: "Push") } } } @@ -257,7 +258,8 @@ final class NotificationService: NSObject, ObservableObject { options: [UNNotificationAttachmentOptionsThumbnailHiddenKey: false] ) } catch { - print("Error creating notification attachment: \(error.localizedDescription)") + AppLog.warning( + "Error creating notification attachment: \(error.localizedDescription)", category: "Push") } } @@ -272,7 +274,10 @@ final class NotificationService: NSObject, ObservableObject { options: [UNNotificationAttachmentOptionsThumbnailHiddenKey: false] ) } catch { - print("Error creating notification attachment from bundle: \(error.localizedDescription)") + AppLog.warning( + "Error creating notification attachment from bundle: \(error.localizedDescription)", + category: "Push" + ) } } @@ -281,17 +286,17 @@ final class NotificationService: NSObject, ObservableObject { // Handle different notification types func handleNotification(userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Handling notification with payload: \(userInfo)") + AppLog.debug("Handling notification with payload: \(userInfo)", category: "Push") guard let typeString = userInfo["type"] as? String, let notificationType = NotificationType(rawValue: typeString) else { - print("[PUSH DEBUG] Error: Notification missing or invalid type info") - print("[PUSH DEBUG] Available keys in payload: \(userInfo.keys)") + AppLog.debug("Error: Notification missing or invalid type info", category: "Push") + AppLog.debug("Available keys in payload: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Processing notification of type: \(notificationType.rawValue)") + AppLog.debug("Processing notification of type: \(notificationType.rawValue)", category: "Push") // Handle different notification types switch notificationType { @@ -304,26 +309,26 @@ final class NotificationService: NSObject, ObservableObject { case .chat: handleChatNotification(userInfo) case .welcome: - print("[PUSH DEBUG] Received welcome notification") + AppLog.debug("Received welcome notification", category: "Push") // No special handling needed case .error: - print("[PUSH DEBUG] Received error notification") + AppLog.debug("Received error notification", category: "Push") // Error notifications are typically local app errors, not push notifications case .success: - print("[PUSH DEBUG] Received success notification") + AppLog.debug("Received success notification", category: "Push") // Success notifications are typically local app confirmations, not push notifications } } // Handle friend request notifications private func handleFriendRequestNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing friend request notification with data: \(userInfo)") + AppLog.debug("Processing friend request notification with data: \(userInfo)", category: "Push") guard let senderId = userInfo["senderId"] as? String, let requestId = userInfo["requestId"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in friend request notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in friend request notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } @@ -332,56 +337,57 @@ final class NotificationService: NSObject, ObservableObject { let user = UserAuthViewModel.shared.spawnUser, user.id == userId { - print( - "[PUSH DEBUG] Friend request from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown")), request ID: \(requestId)" + AppLog.debug( + "Friend request from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown")), request ID: \(requestId)", + category: "Push" ) } else { - print("[PUSH DEBUG] Friend request from user \(senderId), request ID: \(requestId)") + AppLog.debug("Friend request from user \(senderId), request ID: \(requestId)", category: "Push") } // Navigate to friend requests view (implementation will depend on your navigation setup) } // Handle activity invite notifications private func handleActivityInviteNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing activity invite notification with data: \(userInfo)") + AppLog.debug("Processing activity invite notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let activityName = userInfo["activityName"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in activity invite notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in activity invite notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Invited to activity \(activityName), ID: \(activityId)") + AppLog.debug("Invited to activity \(activityName), ID: \(activityId)", category: "Push") // Navigate to activity details (implementation will depend on your navigation setup) } // Handle activity update notifications private func handleActivityUpdateNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing activity update notification with data: \(userInfo)") + AppLog.debug("Processing activity update notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let updateType = userInfo["updateType"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in activity update notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in activity update notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Activity update (\(updateType)) for activity ID: \(activityId)") + AppLog.debug("Activity update (\(updateType)) for activity ID: \(activityId)", category: "Push") // Navigate to updated activity (implementation will depend on your navigation setup) } // Handle chat message notifications private func handleChatNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing chat notification with data: \(userInfo)") + AppLog.debug("Processing chat notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let senderId = userInfo["senderId"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in chat notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in chat notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } @@ -390,11 +396,12 @@ final class NotificationService: NSObject, ObservableObject { let user = UserAuthViewModel.shared.spawnUser, user.id == userId { - print( - "[PUSH DEBUG] New chat message in activity \(activityId) from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))" + AppLog.debug( + "New chat message in activity \(activityId) from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))", + category: "Push" ) } else { - print("[PUSH DEBUG] New chat message in activity \(activityId) from user \(senderId)") + AppLog.debug("New chat message in activity \(activityId) from user \(senderId)", category: "Push") } // Navigate to chat (implementation will depend on your navigation setup) } @@ -419,7 +426,7 @@ final class NotificationService: NSObject, ObservableObject { private func savePreferencesToUserDefaults() { guard let userId = UserAuthViewModel.shared.spawnUser?.id.uuidString else { - print("Cannot save notification preferences: no user logged in") + AppLog.debug("Cannot save notification preferences: no user logged in", category: "Push") return } @@ -444,13 +451,13 @@ final class NotificationService: NSObject, ObservableObject { @MainActor func fetchNotificationPreferences() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot fetch notification preferences: user not logged in") + AppLog.debug("Cannot fetch notification preferences: user not logged in", category: "Push") return } // Don't fetch from backend if in mock mode if MockAPIService.isMocking { - print("Using default notification preferences in mock mode") + AppLog.debug("Using default notification preferences in mock mode", category: "Push") return } @@ -476,13 +483,17 @@ final class NotificationService: NSObject, ObservableObject { } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print("Notification preferences endpoint not available (404): Using UserDefaults values") + AppLog.debug( + "Notification preferences endpoint not available (404): Using UserDefaults values", + category: "Push") // Continue using the UserDefaults values that were loaded in init() } else { - print("Failed to fetch notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to fetch notification preferences: \(error.localizedDescription)", category: "Push") } } catch { - print("Failed to fetch notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to fetch notification preferences: \(error.localizedDescription)", category: "Push") } } } @@ -490,14 +501,14 @@ final class NotificationService: NSObject, ObservableObject { // Update notification preferences on the backend func updateNotificationPreferences() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot update notification preferences: user not logged in") + AppLog.debug("Cannot update notification preferences: user not logged in", category: "Push") return } - // Log user details if let user = UserAuthViewModel.shared.spawnUser { - print( - "Updating notification preferences for user ID: \(userId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))" + AppLog.debug( + "Updating notification preferences for user ID: \(userId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))", + category: "Push" ) } @@ -506,7 +517,7 @@ final class NotificationService: NSObject, ObservableObject { // Don't update backend if in mock mode if MockAPIService.isMocking { - print("Skipping backend update for notification preferences in mock mode") + AppLog.debug("Skipping backend update for notification preferences in mock mode", category: "Push") return } @@ -529,13 +540,18 @@ final class NotificationService: NSObject, ObservableObject { } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print("Notification preferences endpoint not available (404): Values saved to UserDefaults only") + AppLog.debug( + "Notification preferences endpoint not available (404): Values saved to UserDefaults only", + category: "Push" + ) // We've already saved to UserDefaults above, so just continue } else { - print("Failed to update notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to update notification preferences: \(error.localizedDescription)", category: "Push") } } catch { - print("Failed to update notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to update notification preferences: \(error.localizedDescription)", category: "Push") } } } @@ -546,13 +562,13 @@ final class NotificationService: NSObject, ObservableObject { // Handle notification data and update cache accordingly func handleNotificationData(_ userInfo: [AnyHashable: Any]) { guard let type = userInfo["type"] as? String else { - print("Notification missing type") + AppLog.debug("Notification missing type", category: "Push") return } // Get the current user ID for DataService calls guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot handle notification - no logged in user") + AppLog.debug("Cannot handle notification - no logged in user", category: "Push") return } @@ -560,7 +576,6 @@ final class NotificationService: NSObject, ObservableObject { switch type { case "friend-accepted": // When a friend request is accepted, refresh friends and friend requests - print("πŸ”„ [CACHE] Friend request accepted - refreshing friends and friend requests") let _: DataResult<[FullFriendUserDTO]> = await DataService.shared.read( .friends(userId: userId), cachePolicy: .apiOnly) let _: DataResult<[FetchFriendRequestDTO]> = await DataService.shared.read( @@ -570,13 +585,11 @@ final class NotificationService: NSObject, ObservableObject { case "activity-updated": // When an activity is updated, refresh activities - print("πŸ”„ [CACHE] Activity updated - refreshing activities") let _: DataResult<[FullFeedActivityDTO]> = await DataService.shared.read( .activities(userId: userId), cachePolicy: .apiOnly) case "friend-request": // When a new friend request is received/sent, refresh both incoming and sent friend requests - print("πŸ”„ [CACHE] Friend request received/sent - refreshing friend requests") let _: DataResult<[FetchFriendRequestDTO]> = await DataService.shared.read( .friendRequests(userId: userId), cachePolicy: .apiOnly) let _: DataResult<[FetchSentFriendRequestDTO]> = await DataService.shared.read( @@ -589,13 +602,12 @@ final class NotificationService: NSObject, ObservableObject { { // Check if this is a profile we already have cached if appCache.otherProfiles[uuid] != nil { - print("πŸ”„ [CACHE] Profile updated - refreshing other profiles") await appCache.refreshOtherProfiles() } } default: - print("Unknown notification type: \(type) - triggering full cache validation") + AppLog.debug("Unknown notification type: \(type) - triggering full cache validation", category: "Push") // For unknown notification types, validate the entire cache await appCache.validateCache() } @@ -605,18 +617,18 @@ final class NotificationService: NSObject, ObservableObject { // Add a method to unregister the device token when signing out func unregisterDeviceToken() async { guard let token = storedDeviceToken ?? Messaging.messaging().fcmToken else { - print("[PUSH DEBUG] No device token to unregister") + AppLog.debug("No device token to unregister", category: "Push") return } guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("[PUSH DEBUG] Cannot unregister token: no user ID available - clearing token locally") + AppLog.debug("Cannot unregister token: no user ID available - clearing token locally", category: "Push") // Clear the stored token locally since we can't unregister from backend storedDeviceToken = nil return } - print("[PUSH DEBUG] Preparing to unregister device token: \(token)") + AppLog.debug("Preparing to unregister device token: \(token)", category: "Push") if let url = URL(string: "\(APIService.baseURL)notifications/device-tokens") { do { @@ -634,11 +646,11 @@ final class NotificationService: NSObject, ObservableObject { object: deviceTokenDTO ) - print("[PUSH DEBUG] Successfully unregistered device token") + AppLog.debug("Successfully unregistered device token", category: "Push") // Clear the stored token after successful unregistration storedDeviceToken = nil } catch { - print("[PUSH DEBUG] Failed to unregister device token: \(error.localizedDescription)") + AppLog.debug("Failed to unregister device token: \(error.localizedDescription)", category: "Push") // Clear the stored token locally even if backend unregistration failed storedDeviceToken = nil } @@ -651,14 +663,13 @@ extension NotificationService { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } let token = tokenParts.joined() - print("Device Token: \(token)") + AppLog.debug("Device token (APNs): \(token)", category: "Push") - // Here you would send the token to your server sendDeviceTokenToServer(token) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("Failed to register for notifications: \(error.localizedDescription)") + AppLog.warning("Failed to register for notifications: \(error.localizedDescription)", category: "Push") } private func sendDeviceTokenToServer(_ token: String) { @@ -674,9 +685,9 @@ extension NotificationService { let tokenData = ["deviceToken": token] _ = try await apiService.patchData(from: url, with: tokenData) as EmptyResponse - print("Device token successfully sent to server") + AppLog.debug("Device token successfully sent to server", category: "Push") } catch { - print("Failed to send device token: \(error)") + AppLog.warning("Failed to send device token: \(error)", category: "Push") } } } From 5c4bba1741f967a3515c289a721ae0f300081635 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 18:12:12 -0700 Subject: [PATCH 49/53] fix: google sign in url scheme --- .../ViewModels/AuthFlow/UserAuthViewModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift index 19b1f407..4d4508e9 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift @@ -13,6 +13,9 @@ import UIKit @MainActor final class UserAuthViewModel: NSObject, ObservableObject { + /// Google Sign-In URL scheme β€” must match `CFBundleURLSchemes` in Info.plist. + static let googleIOSURLScheme = "com.googleusercontent.apps.822760465266-hl53d2rku66uk4cljschig9ld0ur57na" + static let shared: UserAuthViewModel = UserAuthViewModel( apiService: MockAPIService.isMocking ? MockAPIService() : APIService()) // Singleton instance @Published var errorMessage: String? From 327fba96b6f7f1b2da7ccc5c74e34471d5f119b8 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 18:13:04 -0700 Subject: [PATCH 50/53] fix: padding --- .../Views/Helpers/Constants.swift | 17 +++++++-- .../ActivityCard/ActivityCardView.swift | 2 +- .../ActivityTypeView.swift | 10 +++--- .../ActivityConfirmationView.swift | 31 ++++------------ .../ActivityPreConfirmationView.swift | 35 +++++-------------- .../ActivityDateTimeView.swift | 12 +++---- .../ActivityCreationLocationView.swift | 8 ++--- .../ActivityPopup/ActivityCardPopupView.swift | 5 ++- .../Pages/FeedAndMap/ActivityFeedView.swift | 9 +++-- .../FullscreenActivityListView.swift | 2 +- .../Profile/UserProfile/UserProfileView.swift | 4 ++- 11 files changed, 55 insertions(+), 80 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift index 56720d6f..d9efb086 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit // MARK: - Dimensions and Spacing let dimensionScale: CGFloat = 2 @@ -16,8 +17,20 @@ let dimensionLG: CGFloat = 32 let dimensionXL: CGFloat = 64 /// Horizontal padding from screen edges for main content (home feed, activity creation). -/// Increased for physical devices (e.g. iPhone 11) where 32pt felt cramped. -let screenEdgePadding: CGFloat = 40 +/// Scales down on narrower widths so content is not over-inset toward the center. +func screenEdgePadding(for width: CGFloat) -> CGFloat { + switch width { + case ..<376: return 16 + case ..<400: return 20 + case ..<430: return 28 + default: return 36 + } +} + +@MainActor +func screenEdgePadding() -> CGFloat { + screenEdgePadding(for: UIScreen.main.bounds.width) +} let spacingXS: CGFloat = dimensionXS let spacingSM: CGFloat = dimensionSM diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift index e9b41af5..e015ad06 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift @@ -25,7 +25,7 @@ struct ActivityCardView: View { locationManager: LocationManager, callback: @escaping (FullFeedActivityDTO, Color) -> Void, selectedTab: Binding = .constant(nil), - horizontalPadding: CGFloat = screenEdgePadding + horizontalPadding: CGFloat = screenEdgePadding() ) { self.activity = activity self.color = color diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift index 8fc9e7a8..c593039b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift @@ -42,11 +42,11 @@ struct ActivityTypeView: View { .font(.caption) .foregroundColor(.red) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 8) .background(Color.red.opacity(0.1)) .cornerRadius(8) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) } if viewModel.isLoading { @@ -165,7 +165,7 @@ extension ActivityTypeView { .font(.title3) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } @@ -190,7 +190,7 @@ extension ActivityTypeView { .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(screenEdgePadding) + .padding(screenEdgePadding()) } private var activityTypeGrid: some View { @@ -202,7 +202,7 @@ extension ActivityTypeView { createNewActivityButton } - .padding(screenEdgePadding) + .padding(screenEdgePadding()) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift index c78738bc..e76b502e 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift @@ -95,7 +95,7 @@ struct ActivityConfirmationView: View { .font(Font.custom("Onest", size: 16).weight(.medium)) .foregroundColor(adaptiveSecondaryTextColor) .multilineTextAlignment(.center) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) } .padding(.bottom, 30) @@ -178,30 +178,11 @@ struct ActivityConfirmationView: View { // MARK: - Header View private var headerView: some View { - HStack { - // Back button - if let onBack = onBack { - ActivityBackButton { - onBack() - } - } - - Spacer() - - // Title - Text("Confirm") - .font(.onestSemiBold(size: 20)) - .foregroundColor(adaptiveTextColor) - - Spacer() - - // Invisible chevron to balance the back button - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .padding(.horizontal, screenEdgePadding) - .padding(.vertical, 12) + ActivityCreationCenteredTitleHeader( + title: "Confirm", + titleColor: adaptiveTextColor, + onBack: onBack + ) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift index 3bb38c8c..e02e97b9 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift @@ -60,7 +60,7 @@ struct ActivityPreConfirmationView: View { // Activity card activityCardView - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activity title Text( @@ -87,7 +87,7 @@ struct ActivityPreConfirmationView: View { .font(.onestMedium(size: 20)) .foregroundColor(adaptiveSecondaryTextColor) .padding(.top, 12) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) Spacer() @@ -130,37 +130,18 @@ struct ActivityPreConfirmationView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 80) // Standard bottom padding .background(adaptiveBackgroundColor) } // MARK: - Header View private var headerView: some View { - HStack { - // Back button - if let onBack = onBack { - ActivityBackButton { - onBack() - } - } - - Spacer() - - // Title - Text("Confirm") - .font(.onestSemiBold(size: 20)) - .foregroundColor(adaptiveTextColor) - - Spacer() - - // Invisible chevron to balance the back button - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .padding(.horizontal, screenEdgePadding) - .padding(.vertical, 12) + ActivityCreationCenteredTitleHeader( + title: "Confirm", + titleColor: adaptiveTextColor, + onBack: onBack + ) } // MARK: - Activity Card View diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift index 0efa9408..c61f4b5a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift @@ -380,7 +380,7 @@ struct ActivityDateTimeView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } else { HStack { @@ -397,7 +397,7 @@ struct ActivityDateTimeView: View { .font(.onestSemiBold(size: 20)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } Text("Set a time for your Activity") @@ -488,7 +488,7 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 24) // Activity Duration Section @@ -508,14 +508,14 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 50) if !viewModel.timeValidationMessage.isEmpty { Text(viewModel.timeValidationMessage) .font(.custom("Onest", size: 12)) .foregroundColor(.red) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 8) } @@ -549,7 +549,7 @@ struct ActivityDateTimeView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Step indicators StepIndicatorView(currentStep: 1, totalSteps: 3) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift index 2d96fb9b..5c75c759 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift @@ -184,7 +184,7 @@ struct ActivityCreationLocationView: View { } } .frame(height: 24) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 6) // Search bar @@ -202,7 +202,7 @@ struct ActivityCreationLocationView: View { RoundedRectangle(cornerRadius: 8) .stroke(figmaBlack300, lineWidth: 1) ) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 2) // Location list @@ -263,7 +263,7 @@ struct ActivityCreationLocationView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) Spacer() } Spacer() @@ -437,7 +437,7 @@ struct ActivityCreationLocationView: View { .padding(.bottom, 8) } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 10) .background( universalBackgroundColor diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift index 09afd615..c81b4bf2 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift @@ -127,11 +127,10 @@ struct ActivityCardPopupView: View { .cornerRadius(isExpanded ? 0 : 20) .shadow(radius: isExpanded ? 0 : 20) .ignoresSafeArea( - (showingChatroom || showingParticipants) && isExpanded - ? .all : .container, + .container, // Not .allβ€”respect keyboard so text input stays visible when typing edges: (showingChatroom || showingParticipants) && isExpanded ? .all : .bottom - ) // Fill entire screen when chatroom/participants are maximized + ) .frame(maxWidth: .infinity, maxHeight: isExpanded ? .infinity : nil) // Only expand to fill when expanded .sheet(isPresented: $showActivityMenu) { ActivityMenuView( diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift index 1ce5d903..28ed7a7b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift @@ -15,7 +15,6 @@ struct ActivityFeedView: View { @State private var activityInPopup: FullFeedActivityDTO? @State private var colorInPopup: Color? @Binding private var selectedTab: TabType - private let horizontalSubHeadingPadding: CGFloat = screenEdgePadding private let bottomSubHeadingPadding: CGFloat = 14 @State private var showFullActivitiesList: Bool = false @Environment(\.dismiss) private var dismiss @@ -64,7 +63,7 @@ struct ActivityFeedView: View { HeaderView(user: user) .padding(.bottom, 30) .padding(.top, 60) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Spawn In! row HStack { @@ -75,7 +74,7 @@ struct ActivityFeedView: View { seeAllActivityTypesButton } .padding(.bottom, 20) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activity Types row activityTypeListView @@ -89,7 +88,7 @@ struct ActivityFeedView: View { } ) .padding(.bottom, 30) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activities in Your Area row HStack { @@ -100,7 +99,7 @@ struct ActivityFeedView: View { seeAllActivitiesButton } .padding(.bottom, 14) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activities - no container padding, cards will handle their own ActivityListView( diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift index e6aa971c..39f80cfb 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift @@ -34,7 +34,7 @@ struct FullscreenActivityListView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) ActivityListView(viewModel: viewModel, user: user, callback: callback) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 660d271e..ec10888d 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift @@ -520,7 +520,9 @@ struct UserProfileView: View { shareProfile: shareProfile ) .background(universalBackgroundColor) - .presentationDetents([.height(profileViewModel.friendshipStatus == .friends ? 364 : 276)]) + .presentationDetents([ + .height(profileViewModel.friendshipStatus == .friends ? 520 : 380) + ]) } private var removeFriendConfirmationAlert: some View { From 8fed21357f9b4af2645dee4847cd8d5c72d5b6d4 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 18:14:03 -0700 Subject: [PATCH 51/53] refactor: consolidate reused components --- .../Components/ParticipantsBackButton.swift | 10 +- .../ActivityCreationCenteredTitleHeader.swift | 36 ++++++ .../Friends/Shared/FriendsTabMenuView.swift | 111 ++--------------- .../Profile/Shared/ProfileMenuView.swift | 95 ++------------- .../Views/Shared/UI/SheetMenuComponents.swift | 115 ++++++++++++++++++ .../Views/Shared/UI/UnifiedButton.swift | 16 +-- 6 files changed, 177 insertions(+), 206 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift create mode 100644 Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift index acbb0308..030cc7ca 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift @@ -7,14 +7,6 @@ struct ParticipantsBackButton: View { let action: () -> Void var body: some View { - Button(action: { - HapticFeedbackService.shared.light() - action() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.white.opacity(0.6)) - } - .buttonStyle(PlainButtonStyle()) + UnifiedBackButton(foregroundColor: .white.opacity(0.6), action: action) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift new file mode 100644 index 00000000..bca5d572 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift @@ -0,0 +1,36 @@ +// +// ActivityCreationCenteredTitleHeader.swift +// Spawn-App-iOS-SwiftUI +// +// Centered title bar with optional back control and trailing balance spacer. +// + +import SwiftUI + +struct ActivityCreationCenteredTitleHeader: View { + let title: String + let titleColor: Color + let onBack: (() -> Void)? + + var body: some View { + HStack { + if let onBack = onBack { + ActivityBackButton { + onBack() + } + } + + Spacer() + + Text(title) + .font(.onestSemiBold(size: 20)) + .foregroundColor(titleColor) + + Spacer() + + InvisibleBalanceButton() + } + .padding(.horizontal, screenEdgePadding()) + .padding(.vertical, 12) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift index 608366ec..3097a001 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift @@ -19,20 +19,12 @@ struct FriendsTabMenuView: View { @Environment(\.dismiss) private var dismiss @State private var isLoading: Bool = true - private var firstName: String { - if let name = user.name, !name.isEmpty { - return name.components(separatedBy: " ").first ?? user.username ?? "User" - } - return user.username ?? "User" - } - var body: some View { - FriendsTabMenuContainer { + SheetMenuContainer { if isLoading { loadingContent } else { FriendsTabMenuContent( - user: user, showReportDialog: $showReportDialog, showBlockDialog: $showBlockDialog, showRemoveFriendConfirmation: $showRemoveFriendConfirmation, @@ -53,57 +45,12 @@ struct FriendsTabMenuView: View { } private var loadingContent: some View { - VStack(spacing: 16) { - ForEach(0..<4) { _ in - HStack { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 20) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - Divider() - - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - .background(universalBackgroundColor) - .redacted(reason: .placeholder) - .shimmering() - } -} - -// Container view that provides the background and layout -private struct FriendsTabMenuContainer: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(spacing: 8) { - content - .background(universalBackgroundColor) - .cornerRadius(12) - } - .fixedSize(horizontal: false, vertical: true) - .background(universalBackgroundColor) + SheetMenuLoadingPlaceholder(rowCount: 4, dismiss: dismiss) } } // Content view that contains the actual menu items private struct FriendsTabMenuContent: View { - let user: Nameable @Binding var showReportDialog: Bool @Binding var showBlockDialog: Bool @Binding var showRemoveFriendConfirmation: Bool @@ -113,15 +60,8 @@ private struct FriendsTabMenuContent: View { let navigateToProfile: () -> Void let dismiss: DismissAction - private var firstName: String { - if let name = user.name, !name.isEmpty { - return name.components(separatedBy: " ").first ?? user.username ?? "User" - } - return user.username ?? "User" - } - var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { menuItems .background(universalBackgroundColor) @@ -132,7 +72,7 @@ private struct FriendsTabMenuContent: View { private var menuItems: some View { VStack(spacing: 0) { - menuItem( + SheetMenuRow( icon: "person.crop.circle", text: "View Profile", color: universalAccentColor @@ -144,7 +84,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "tag", text: "Add to Activity Type", color: universalAccentColor @@ -156,7 +96,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "link", text: "Copy profile URL", color: universalAccentColor @@ -168,7 +108,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "square.and.arrow.up", text: "Share this Profile", color: universalAccentColor @@ -180,7 +120,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "exclamationmark.triangle", text: "Report", color: .red @@ -192,7 +132,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "hand.raised.slash", text: "Block", color: .red @@ -204,7 +144,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "person.badge.minus", text: "Remove Friend", color: .red @@ -218,36 +158,7 @@ private struct FriendsTabMenuContent: View { } private var cancelButton: some View { - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - - private func menuItem( - icon: String, - text: String, - color: Color, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - - Text(text) - .foregroundColor(color) - - Spacer() - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - } + SheetMenuCancelRow(dismiss: dismiss) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift index fea4fdaf..32060558 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift @@ -20,12 +20,11 @@ struct ProfileMenuView: View { @State private var isLoading: Bool = true var body: some View { - ProfileMenuContainer { + SheetMenuContainer { if isLoading { loadingContent } else { ProfileMenuContent( - user: user, showRemoveFriendConfirmation: $showRemoveFriendConfirmation, showReportDialog: $showReportDialog, showBlockDialog: $showBlockDialog, @@ -47,57 +46,12 @@ struct ProfileMenuView: View { } private var loadingContent: some View { - VStack(spacing: 16) { - ForEach(0..<5) { _ in - HStack { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 20) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - Divider() - - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - .background(universalBackgroundColor) - .redacted(reason: .placeholder) - .shimmering() - } -} - -// Container view that provides the background and layout -private struct ProfileMenuContainer: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(spacing: 8) { - content - .background(universalBackgroundColor) - .cornerRadius(12) - } - .fixedSize(horizontal: false, vertical: true) - .background(universalBackgroundColor) + SheetMenuLoadingPlaceholder(rowCount: 5, dismiss: dismiss) } } // Content view that contains the actual menu items private struct ProfileMenuContent: View { - let user: Nameable @Binding var showRemoveFriendConfirmation: Bool @Binding var showReportDialog: Bool @Binding var showBlockDialog: Bool @@ -108,7 +62,7 @@ private struct ProfileMenuContent: View { let dismiss: DismissAction var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { menuItems .background(universalBackgroundColor) @@ -121,7 +75,7 @@ private struct ProfileMenuContent: View { VStack(spacing: 0) { // Only show "Add to Activity Type" for friends if isFriend { - menuItem( + SheetMenuRow( icon: "tag", text: "Add to Activity Type", color: universalAccentColor @@ -134,7 +88,7 @@ private struct ProfileMenuContent: View { Divider() } - menuItem( + SheetMenuRow( icon: "link", text: "Copy profile URL", color: universalAccentColor @@ -146,7 +100,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "square.and.arrow.up", text: "Share this Profile", color: universalAccentColor @@ -158,7 +112,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "exclamationmark.triangle", text: "Report user", color: .red @@ -170,7 +124,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "hand.raised.slash", text: "Block user", color: .red @@ -183,7 +137,7 @@ private struct ProfileMenuContent: View { if isFriend { Divider() - menuItem( + SheetMenuRow( icon: "person.badge.minus", text: "Remove Friend", color: .red @@ -198,36 +152,7 @@ private struct ProfileMenuContent: View { } private var cancelButton: some View { - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - - private func menuItem( - icon: String, - text: String, - color: Color, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - - Text(text) - .foregroundColor(color) - - Spacer() - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - } + SheetMenuCancelRow(dismiss: dismiss) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift new file mode 100644 index 00000000..18c8f774 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift @@ -0,0 +1,115 @@ +// +// SheetMenuComponents.swift +// Spawn-App-iOS-SwiftUI +// +// Shared building blocks for profile/friends sheet-style action menus. +// + +import SwiftUI + +/// Container for sheet-style menus with rounded corners and fixed vertical sizing. +struct SheetMenuContainer: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(spacing: 8) { + content + .background(universalBackgroundColor) + .cornerRadius(12) + } + .fixedSize(horizontal: false, vertical: true) + .background(universalBackgroundColor) + } +} + +/// Single row in a sheet menu (icon + label, leading-aligned). +struct SheetMenuRow: View { + let icon: String + let text: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(color) + + Text(text) + .font(.system(size: 17, weight: .medium)) + .foregroundColor(color) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 63, alignment: .leading) + } + } +} + +/// Cancel row with leading xmark, matching sheet menu styling. +struct SheetMenuCancelRow: View { + let dismiss: DismissAction + + var body: some View { + Button(action: { dismiss() }) { + HStack(spacing: 10) { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(universalAccentColor) + + Text("Cancel") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(universalAccentColor) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 63, alignment: .leading) + } + .background(universalBackgroundColor) + .cornerRadius(12) + } +} + +/// Placeholder shimmer while menu content loads. +struct SheetMenuLoadingPlaceholder: View { + let rowCount: Int + let dismiss: DismissAction + + var body: some View { + VStack(spacing: 16) { + ForEach(0.. Date: Fri, 10 Apr 2026 18:14:32 -0700 Subject: [PATCH 52/53] fix: various onboarding glitches --- .../Spawn_App_iOS_SwiftUIApp.swift | 95 +++++----- .../Views/Pages/AuthFlow/CoreInputView.swift | 1 - .../Views/Pages/AuthFlow/LaunchView.swift | 162 ++++++++---------- .../Views/Pages/AuthFlow/LoginInputView.swift | 1 - 4 files changed, 122 insertions(+), 137 deletions(-) diff --git a/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift b/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift index 244253da..1367e429 100644 --- a/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift +++ b/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift @@ -19,6 +19,8 @@ struct Spawn_App_iOS_SwiftUIApp: App { @Environment(\.scenePhase) private var scenePhase init() { + // Verbose logs: set `AppLog.minimumLevel` to `.debug` or `.info` while investigating (default is `.warning`). + // Register custom fonts Font.registerFonts() @@ -50,58 +52,57 @@ struct Spawn_App_iOS_SwiftUIApp: App { ], for: .normal) } + @ViewBuilder private var rootView: some View { - Group { - if !userAuth.hasCheckedSpawnUserExistence { - // Show loading screen while checking user existence - LoadingView() - .onAppear { - // Connect the app delegate to the app - appDelegate.app = self - } - .task { - // If we're mocking, simulate a login with mock user - if MockAPIService.isMocking { - await userAuth.setMockUser() - } + if !userAuth.hasCheckedSpawnUserExistence { + // Show loading screen while checking user existence + LoadingView() + .onAppear { + // Connect the app delegate to the app + appDelegate.app = self + } + .task { + // If we're mocking, simulate a login with mock user + if MockAPIService.isMocking { + await userAuth.setMockUser() } - .onOpenURL { url in + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) + } + .onestFontTheme() + } else if userAuth.isLoggedIn, let spawnUser = userAuth.spawnUser, userAuth.hasCompletedOnboarding { + // User is logged in, has user data, and has completed onboarding - go to main content + ContentView(user: spawnUser, deepLinkManager: deepLinkManager) + .task { + // Initialize and validate the cache + await appCache.validateCache() + // Clean up any expired activities after cache validation + appCache.cleanupExpiredActivities() + } + .onOpenURL { url in + AppLog.debug("App received URL: \(url.absoluteString)", category: "DeepLink") + // Handle Google Sign-In URLs + if url.scheme == UserAuthViewModel.googleIOSURLScheme { GIDSignIn.sharedInstance.handle(url) } - .onestFontTheme() - } else if userAuth.isLoggedIn, let spawnUser = userAuth.spawnUser, userAuth.hasCompletedOnboarding { - // User is logged in, has user data, and has completed onboarding - go to main content - ContentView(user: spawnUser, deepLinkManager: deepLinkManager) - .task { - // Initialize and validate the cache - await appCache.validateCache() - // Clean up any expired activities after cache validation - appCache.cleanupExpiredActivities() - } - .onOpenURL { url in - print("πŸ”— App: Received URL: \(url.absoluteString)") - // Handle Google Sign-In URLs - if url.scheme == "com.googleusercontent.apps.822760465266-hl53d2rku66uk4cljschig9ld0ur57na" { - GIDSignIn.sharedInstance.handle(url) - } - // Handle Spawn deep links (both custom URL schemes and Universal Links) - else if url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") { - deepLinkManager.handleURL(url) - } + // Handle Spawn deep links (both custom URL schemes and Universal Links) + else if url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") { + deepLinkManager.handleURL(url) } - .onestFontTheme() - } else { - // User is not logged in or has no user data - show welcome screen - WelcomeView() - .onAppear { - // Connect the app delegate to the app - appDelegate.app = self - } - .onOpenURL { url in - GIDSignIn.sharedInstance.handle(url) - } - .onestFontTheme() - } + } + .onestFontTheme() + } else { + // User is not logged in or has no user data - show welcome screen + WelcomeView() + .onAppear { + // Connect the app delegate to the app + appDelegate.app = self + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) + } + .onestFontTheme() } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift index bc3e26f8..8e3ff873 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift @@ -174,7 +174,6 @@ struct CoreInputView: View { .background(universalBackgroundColor(from: themeService, environment: colorScheme)) } .navigationBarHidden(true) - .withAuthNavigation(viewModel) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift index 405b7cb8..7c7120ab 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift @@ -18,111 +18,97 @@ struct LaunchView: View { @Environment(\.dismiss) private var dismiss @State private var showAuthButtons = false @State private var animationCompleted = false - @State private var navigationPath = NavigationPath() var body: some View { - NavigationStack(path: $navigationPath) { - VStack(spacing: 16) { - Spacer() + VStack(spacing: 16) { + Spacer() - if !animationCompleted { - // Initial Rive animation for new users - RiveAnimationView.logoAnimation(fileName: "spawn_logo_animation") - .frame(width: 300, height: 300) - .task { - // Show auth buttons after animation completes - try? await Task.sleep(for: .seconds(2.0)) - withAnimation(.easeInOut(duration: 0.5)) { - animationCompleted = true - showAuthButtons = true - } + if !animationCompleted { + // Initial Rive animation for new users + RiveAnimationView.logoAnimation(fileName: "spawn_logo_animation") + .frame(width: 300, height: 300) + .task { + // Show auth buttons after animation completes + try? await Task.sleep(for: .seconds(2.0)) + withAnimation(.easeInOut(duration: 0.5)) { + animationCompleted = true + showAuthButtons = true } - } else { - // Static logo after animation - Image("spawn_branding_logo") - .resizable() - .scaledToFit() - .frame(width: 200, height: 100) - .transition(.opacity) - } + } + } else { + // Static logo after animation + Image("spawn_branding_logo") + .resizable() + .scaledToFit() + .frame(width: 200, height: 100) + .transition(.opacity) + } - if showAuthButtons { - Image("spontaneity_made_easy") - .resizable() - .scaledToFit() - .frame(width: 300, height: 100) - .transition(.opacity) + if showAuthButtons { + Image("spontaneity_made_easy") + .resizable() + .scaledToFit() + .frame(width: 300, height: 100) + .transition(.opacity) - Spacer().frame(height: 32) - } + Spacer().frame(height: 32) + } - // Google Sign-In Button - if showAuthButtons && !userAuth.isAutoSigningIn { - Button(action: { - // Haptic feedback - let impactGenerator = UIImpactFeedbackGenerator(style: .medium) - impactGenerator.impactOccurred() + // Google Sign-In Button + if showAuthButtons && !userAuth.isAutoSigningIn { + Button(action: { + // Haptic feedback + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + impactGenerator.impactOccurred() - Task { - await userAuth.googleRegister() - } - }) { - AuthProviderButtonView(authProviderType: .google) + Task { + await userAuth.googleRegister() } - .buttonStyle(AuthProviderButtonStyle()) - .transition(.opacity) + }) { + AuthProviderButtonView(authProviderType: .google) + } + .buttonStyle(AuthProviderButtonStyle()) + .transition(.opacity) - // Apple Sign-In Button - Button(action: { - // Haptic feedback - let impactGenerator = UIImpactFeedbackGenerator(style: .medium) - impactGenerator.impactOccurred() + // Apple Sign-In Button + Button(action: { + // Haptic feedback + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + impactGenerator.impactOccurred() - userAuth.appleRegister() - }) { - AuthProviderButtonView(authProviderType: .apple) - } - .buttonStyle(AuthProviderButtonStyle()) - .transition(.opacity) + userAuth.appleRegister() + }) { + AuthProviderButtonView(authProviderType: .apple) } + .buttonStyle(AuthProviderButtonStyle()) + .transition(.opacity) + } - // Auto Sign-In Loading State - if userAuth.isAutoSigningIn { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle( - CircularProgressViewStyle( - tint: universalAccentColor(from: themeService, environment: colorScheme)) - ) - .scaleEffect(1.2) + // Auto Sign-In Loading State + if userAuth.isAutoSigningIn { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle( + CircularProgressViewStyle( + tint: universalAccentColor(from: themeService, environment: colorScheme)) + ) + .scaleEffect(1.2) - Text("Account found! Signing you in...") - .font(.onestMedium(size: 16)) - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 40) - .transition(.opacity) + Text("Account found! Signing you in...") + .font(.onestMedium(size: 16)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .multilineTextAlignment(.center) } - - Spacer() - } - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) - .ignoresSafeArea(.all) - .onAppear { - print("πŸ”„ DEBUG: LaunchView appeared") + .padding(.horizontal, 40) + .transition(.opacity) } + + Spacer() } - .withAuthNavigation(userAuth) - .onReceive(userAuth.$navigationState) { newState in - if newState != .none { - navigationPath.append(newState) - print("πŸ“ DEBUG: Appending navigation state to path: \(newState.description)") - } else { - // Clear navigation path when state is reset to none - navigationPath = NavigationPath() - print("πŸ“ DEBUG: Clearing navigation path") - } + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) + .ignoresSafeArea(.all) + .onAppear { + print("πŸ”„ DEBUG: LaunchView appeared") } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift index bcd5d3ea..6bf7ad74 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift @@ -225,7 +225,6 @@ struct LoginInputView: View { } .background(universalBackgroundColor(from: themeService, environment: colorScheme)) .navigationBarHidden(true) - .withAuthNavigation(userAuth) .onAppear { // Reset any previous error state userAuth.errorMessage = nil From e5fd3dc05aff85ac94eeb789129aaf55e389aecb Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Fri, 10 Apr 2026 18:14:41 -0700 Subject: [PATCH 53/53] fix: popup and button styling --- .../Extensions/View+KeyboardOverlap.swift | 57 +++++++ .../ActivityTypeOptionsPopup.swift | 156 +++++++++--------- .../Chatroom/ChatroomContentView.swift | 40 +---- .../Shared/UI/AnimatedActionButton.swift | 14 +- .../Views/Shared/UI/UnifiedBackButton.swift | 6 +- 5 files changed, 158 insertions(+), 115 deletions(-) create mode 100644 Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift diff --git a/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift b/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift new file mode 100644 index 00000000..a28a84ff --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift @@ -0,0 +1,57 @@ +import Combine +import SwiftUI +import UIKit + +/// Tracks how much of the screen bottom is covered by the keyboard (screen coordinates). +/// Use when ancestors apply `.ignoresSafeArea(.keyboard)` so system avoidance does not run. +final class KeyboardOverlapHeight: ObservableObject { + @Published var value: CGFloat = 0 + + private var observers: [NSObjectProtocol] = [] + + init() { + let center = NotificationCenter.default + observers.append( + center.addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { + [weak self] notification in + self?.update(from: notification) + } + ) + observers.append( + center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { + [weak self] notification in + self?.update(from: notification) + } + ) + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + private func update(from notification: Notification) { + guard + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { + return + } + + let screenHeight = UIScreen.main.bounds.height + let overlap = max(0, screenHeight - frame.minY) + + let duration = + (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue + ?? 0.25 + + withAnimation(.easeOut(duration: duration)) { + value = overlap + } + } +} + +extension View { + /// Bottom padding matching on-screen keyboard overlap (for views under `.ignoresSafeArea(.keyboard)`). + func keyboardOverlapPadding(_ keyboard: KeyboardOverlapHeight) -> some View { + padding(.bottom, keyboard.value) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift index 4970039e..b0e45d7f 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift @@ -6,106 +6,112 @@ struct ActivityTypeOptionsPopup: View { let onDeleteActivityType: () -> Void @State private var showDeleteConfirmation = false + /// Bottom padding to place drawer above the tab bar (matches WithTabBarBinding tabBarSpacing) + private let tabBarHeight: CGFloat = 92 // 64 button + 8 padding + 20 spacing + var body: some View { - ZStack { - // Semi-transparent background overlay - matching Figma exactly - Rectangle() - .foregroundColor(.clear) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.60)) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false + GeometryReader { geometry in + let bottomInset = geometry.safeAreaInsets.bottom + tabBarHeight + 16 + ZStack { + // Semi-transparent background overlay - matching Figma exactly + Rectangle() + .foregroundColor(.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.60)) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } } - } - // Popup content positioned at bottom - VStack { - Spacer() + // Popup content positioned at bottom, above nav bar + VStack { + Spacer() - VStack(alignment: .leading, spacing: 16) { - // Main options group - VStack(alignment: .leading, spacing: 0) { - // Manage People option - Button(action: { - onManagePeople() - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false + VStack(alignment: .leading, spacing: 16) { + // Main options group + VStack(alignment: .leading, spacing: 0) { + // Manage People option + Button(action: { + onManagePeople() + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } + }) { + HStack(spacing: 10) { + Text("Manage People") + .font(Font.custom("Onest", size: 20).weight(.medium)) + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .frame(height: 63) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .overlay( + Rectangle() + .inset(by: 0.50) + .stroke(Color(red: 0.52, green: 0.49, blue: 0.49), lineWidth: 0.50) + ) + .shadow( + color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 + ) } - }) { - HStack(spacing: 10) { - Text("Manage People") - .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + .buttonStyle(PlainButtonStyle()) + + // Delete Activity Type option + Button(action: { + showDeleteConfirmation = true + }) { + HStack(spacing: 10) { + Image(systemName: "trash") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.red) + Text("Delete Activity Type") + .font(Font.custom("Onest", size: 20).weight(.medium)) + .foregroundColor(.red) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .frame(height: 63) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .shadow( + color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 + ) } - .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .frame(height: 63) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color(red: 0.95, green: 0.93, blue: 0.93)) - .overlay( - Rectangle() - .inset(by: 0.50) - .stroke(Color(red: 0.52, green: 0.49, blue: 0.49), lineWidth: 0.50) - ) - .shadow( - color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 - ) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) + .cornerRadius(16) - // Delete Activity Type option + // Cancel button Button(action: { - showDeleteConfirmation = true + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } }) { HStack(spacing: 10) { - Image(systemName: "trash") + Image(systemName: "xmark") .font(.system(size: 20, weight: .medium)) - .foregroundColor(.red) - Text("Delete Activity Type") + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + Text("Cancel") .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(.red) + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) } .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) .frame(height: 63) .frame(maxWidth: .infinity, alignment: .center) .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .cornerRadius(16) .shadow( color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 ) } .buttonStyle(PlainButtonStyle()) } - .cornerRadius(16) - - // Cancel button - Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false - } - }) { - HStack(spacing: 10) { - Image(systemName: "xmark") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) - Text("Cancel") - .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) - } - .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .frame(height: 63) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color(red: 0.95, green: 0.93, blue: 0.93)) - .cornerRadius(16) - .shadow( - color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 - ) - } - .buttonStyle(PlainButtonStyle()) + .frame(width: 380) + .padding(.horizontal, 24) + .padding(.bottom, bottomInset) } - .frame(width: 380) - .padding(.horizontal, 24) - .padding(.bottom, 40) } } .alert("Delete Activity Type", isPresented: $showDeleteConfirmation) { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift index f7990189..739cfa03 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift @@ -3,6 +3,7 @@ import SwiftUI // MARK: - Embedded Chatroom Content View (for use within drawers) struct ChatroomContentView: View { @State private var messageText = "" + @StateObject private var keyboardOverlap = KeyboardOverlapHeight() var user: BaseUserDTO = UserAuthViewModel.shared.spawnUser ?? BaseUserDTO.danielAgapov @ObservedObject var activity: FullFeedActivityDTO @@ -48,9 +49,9 @@ struct ChatroomContentView: View { headerView .padding(.top, geometry.safeAreaInsets.top + 16) - // Messages area - fixed size based on expansion state + // Messages fill remaining space (flexible when keyboard overlaps) messagesScrollView - .frame(height: expandedMessagesHeight) + .frame(maxHeight: .infinity) .clipped() // Input area - always anchored at bottom with no spacing below @@ -86,6 +87,7 @@ struct ChatroomContentView: View { .frame(height: minimizedContentHeight) // Fixed total height to match drawer } } + .keyboardOverlapPadding(keyboardOverlap) .onAppear { Task { await viewModel.refreshChat() @@ -98,47 +100,23 @@ struct ChatroomContentView: View { // But we need to account for the drawer offset (20% of screen), so visible content is less // Using a fixed total height ensures consistent sizing that matches main card content private let minimizedContentHeight: CGFloat = 600 // Total height for chatroom when minimized - private let expandedMessagesHeight: CGFloat = 710 // MARK: - View Components private var headerView: some View { HStack { - backButton + UnifiedBackButton(foregroundColor: .white.opacity(0.6), action: onBack) Spacer() - titleText + Text("Chatroom") + .font(Font.custom("Onest", size: 20).weight(.semibold)) + .foregroundColor(.white) Spacer() - invisibleBalanceButton + InvisibleBalanceButton() } .padding(.horizontal, 24) .padding(.bottom, 8) // Reduced from 16 to fit content better } - private var backButton: some View { - Button(action: { - onBack() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.white.opacity(0.6)) - } - } - - private var titleText: some View { - Text("Chatroom") - .font(Font.custom("Onest", size: 20).weight(.semibold)) - .foregroundColor(.white) - } - - private var invisibleBalanceButton: some View { - Button(action: {}) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .disabled(true) - } - private var messagesScrollView: some View { ScrollViewReader { proxy in ScrollView { diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift index c65f8412..c7c26c04 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift @@ -33,8 +33,8 @@ enum FriendActionButtonStyle { var activeColor: Color { switch self { - case .accept: return .white - case .remove, .cancel, .add: return figmaGreen + case .accept, .add: return .white + case .remove, .cancel: return figmaGreen } } @@ -42,11 +42,11 @@ enum FriendActionButtonStyle { return { isActive in switch self { case .accept: - return isActive ? Color(hex: colorsIndigo800) : universalSecondaryColor + return universalSecondaryColor case .remove, .cancel: return Color.clear case .add: - return Color.clear + return isActive ? universalSecondaryColor : Color.clear } } } @@ -57,7 +57,7 @@ enum FriendActionButtonStyle { case .accept: return Color.clear case .add: - return isActive ? figmaGreen : universalSecondaryTextColor + return isActive ? Color.clear : universalSecondaryTextColor case .remove, .cancel: return isActive ? figmaGreen : universalSecondaryTextColor } @@ -74,7 +74,7 @@ enum FriendActionButtonStyle { } /// Animated action button with consistent behavior across friend-related actions -/// - Shows checkmark animation +/// - Shows checkmark animation (except for cancel style) /// - Fades out smoothly /// - Calls completion handler after animation struct AnimatedActionButton: View { @@ -130,7 +130,7 @@ struct AnimatedActionButton: View { } }) { HStack(spacing: 6) { - if isActive { + if isActive && style != .cancel { Image(systemName: "checkmark") .foregroundColor(style.activeColor) .font(.system(size: 14, weight: style == .add ? .regular : .semibold)) diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedBackButton.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedBackButton.swift index 9e612f6d..cf244ff2 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedBackButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedBackButton.swift @@ -9,10 +9,12 @@ import SwiftUI /// Unified back button with consistent styling and behavior struct UnifiedBackButton: View { let title: String? + let foregroundColor: Color let action: () -> Void - init(title: String? = nil, action: @escaping () -> Void) { + init(title: String? = nil, foregroundColor: Color = universalAccentColor, action: @escaping () -> Void) { self.title = title + self.foregroundColor = foregroundColor self.action = action } @@ -30,7 +32,7 @@ struct UnifiedBackButton: View { .font(.system(size: 17)) } } - .foregroundColor(universalAccentColor) + .foregroundColor(foregroundColor) } .buttonStyle(PlainButtonStyle()) }