From 2f0e0622c29e2d053189a4d0df8959123910fb21 Mon Sep 17 00:00:00 2001 From: Daggerpov Date: Sun, 8 Feb 2026 01:00:45 -0800 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 zcmV-@1A_dCP)~y;Su$`k2%p%sZ6! zF&*+khbX9X?@zO=x9)5$5!hAz#u3UNCeC%FG~y)YY%K;rS(D=!$}xZNi>hA;%ynnq z$QD8cBh@Qcg0J^E%eq{@Es8FU;y3)wq;3OP@4B;dJaX>b=Nq|)_wDHh=f0}yBfh0K zMbX6fF1}yFz(Mt{Xb{p{mKPBoLT(pysjvz-1Li%ZN0q&uJ)|k{M3e!%3_yFLw{~ba zv3!THuQ=NT&{8c8511IBO4>9!3WU2jh{OLe8@ziLlRv*l_1F9w9B7Q~zKv4w8>NLv zoXw{I(nB=b)%+4CQUbI&r?`u~Kleq7jZ#n*pI?y+DEJ^{1q>0=R=~l0$_$|25ygi^ z#Pp&nFmF@NZ}PnGG+dTki)#cQl@liiBIlyAcn!m zG(D6xvJ!+;&RTY5IV}|C(!oF}y*Y8#lzhoW$WjC7y_>bw1fWWn?Ny^+EtUHoK;n^Y z3n+!M)GJN z;-#LyF=qDv0^HtTln&OmmgF)x2pus(d=(hl^FSI#wU?i=>^f{Du^1Q=Tbj~o*yHa( z3Su52L3{*^YMWuPs+{6^8#~O~zV@qSxd~Rl*)0tsN9&m60Ah}`Ye-ejN)5P&vk|D0~kr*9R0MO#s zwclTw$JylH%C*bgW0$zLttA|U1VMQ0^CN4@OEh^MMX~L=50lBKqH6+nnO_`;ac=^E zxqcfA?vzsZCZh|a%=|wWB|7*G*81OMPnEOl`ES;wf6%D_@W5IA&J1tUoOqD=3#D$W zvb;^JDcK!f!_;=H|&b}WF*5NR$Sej~KJhCvJ zE%)~a5E94eq=vS$*pBelHlabIgqb`-7Fi{1MyivjG(X_AC5BwSZ1d0rQrjVumal?F z3vdqC_?O-7tlZr~U2kmaiM@wGzM;2DP9mtb0NXYGpv&1!GqdET5W>tgbeVAAo7i-L zPDl_q?iP&*jl12moQPqH=XAJVC%DbVQ|7>@S#o^oHbEXho`SQkduw%_1$e~@MK zuDOK1_HfXoi4DtQXs6+UZZZ3OB7WkT*WR`nn;o+y(r5PNC&Yi(1_d@E8y@B;%+fBK z_?i~CbAdG} zA!_3V%4B=b0JDM#c}8skAQ`dM`UqbzT}fb=^f0R@P5bt?Q5aC>8f{Q10BisoSy?u} zS{EQGKEH|MF^QBGLV(adPN$%I^}%fc`$AzJpc|YcIkU?FE%D|fc}+=2DCKjL zZ>!a`4v~o=h}YO}Vn+Z~8wX(Szs9kZ>1(vbY6&9mA7uCA(AU+Al3M~mZcn>|N^^1T zx|=sJ9;}m?uwY4P^J8C-98mNY7Kkb4nO*G{6L{Si6`d;f-Eh&-yMIj}1&|i3!w7o> zGNYQvT`4GerDfR>vp0c_11e3IN#KE5e^zjwASl-Uxa6kJEiB)#MgbkNqB23CQez_A z$;%NkiwLw&8_!W%4p#|6_U_(KCdmHoEjQ`i#&gu_2N;5o9I#>Xf(TOSE{8^~R2VT> zvwAE}{oe)J&Hb}UY@as|H0}torpL5x38(|!20{S7nwDLoaXE~pg{=JK=6yCTyGCOH z*eX8>%Nt$2g;Ph!Vg(x4XzbI41lDZ2t< z7#J8B@InAP!iUA;DoxW%`Z@)ezX`*)&;oWs0PpAXKk={2AHJEU*KeX|3ZCh`0N%y% zWE2FSC6^wbr|JG_6g`4RM$mJ{07uNFy?+5h@J#OokOsj8cMUD$C3vRSTaaS=`Nld; zkI$m$0X)%b3m6B%RoOkLfhXXRUJD@Q_y2DML2&*tj`zS5y%oT19EbSVMB~=Ie*g}^ z6aBRS)NI{bb+#&3^wJ6IINu_e6TI+|6ZFyo6pl5)Ytlu2DrU+J(N6&|u|F>U1BRUo z<{>s;a7ph3@F9+mfN{Pntp8wSbjLip_ipoSqx4PyDerujm~bVObde>$9 z0OWjAroMw)&&}4?pJeyovnNgG1icc#SekyEp2x+W)Ap19 zo5IbMv4W-xbiEOPD)U`QBA+!1MRqThFPe15y-37F;&l&l0VLRd+?PFfJD=Y(^`b;n zNfU5P=PjUQ@yX#{bI-20oB&^oUXxxM9MfqFpqPIh1TpYMH&**|uwVRH^?ZN=d>ut| zaKtm{7!)Q_q(aVeT~cmF?T?@Yx<^=$j0rmS7=?62f>>cBWqVudj0KRkizMp=_wCYH zowlAc&8Y|6YNjL2iYP@?D2&A`OI@^93xKr!|Iz4z=6Q4$J;!A6b4-LFNbr5#P-&x% zRkTh3*`i~EgkpM~j#iN>i=R_L2pL&v1FfQpuV{?`ghPFaZzwd;_AMxMjOsSnDF+y- z9lP>9kT6yoB}jB0@G5|9hgwD33GoXr&MZeND2Py4!vw0aL|<`J=D58HAfMH&qV1xN zb0-%50UVN91l5P?RZ3^vEUFg)NM|)Q5Dk@2R7`8SeJGML+McL!W8TDPW>LKe080R0 zbTyx}ow`^e+IWB#kY(QhQ?!cTL4S*SL@Rhv{ATM*5xDS=sCB>LmkG3lXSC=+@Fl1g zEEHd>c_@Ac{cta9vY-$`+`65Tt{rnahKLN#JGx-l0zIx6X(Y|KNUe4u~G;MpONy}_%N1Yf)IkV0nZIusivsc1`UU@$H55@txpXN6-Kpo2De6i|TveP2K!l63 zrpu+Pi8I})XWc34CU7PI)yYFwrxJCio>Tq^)4b_2x05_T^CJTGUHr0Qs^OmbnOU^N zy*D2pTj0b3bTr(R6$oc6%~q+dr1-gskCvx(0jM0@Hgu0GUyyDcN7^P(R9LxZTw`4T zCYtTa3Z7$ylDR~Y&_s4YaeC4zR((K~6Y8#ff#UGRPf-5wIt-7&16D1diblJ#f|Wr$ zuQ;gT9BTql?fn$q0rN|`@&(kL;+%1TMl;_c)&ziC&qwYWY2%vuy$s_`57sJ_6Rb5f zJ7_rZDq=+dxZlzON~ap~mmZ4La>BHAkoI!UPV`L=b0LQ361Z|^;LQk0m*wbI3>g{Pm1-KwaMi%(Rl zhU!`b?$vLLiJ8t}h(2q4k=o^G(TL%+`G^0hNR&4I<{Yj#Yt>tAmr?7Wwpggc_i;RD ztbz7=>iK4615sC!F8+FR>?edB!`f7Ys_~Ke4#?V;+{A?U#)vPheN@3}^Wk^W1C$A^-pY07*qoM6N<$f>&G2#{d8T 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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) }