diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json new file mode 100644 index 00000000..773ad2d5 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "instagram-1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "instagram-2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "instagram-3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png new file mode 100644 index 00000000..c7e64f9a Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-1x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-2x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-2x.png new file mode 100644 index 00000000..6a4e98bf Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-2x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-3x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-3x.png new file mode 100644 index 00000000..a33d0fde Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/Image 1.imageset/instagram-3x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json index a519a2bd..7c8a27ad 100644 --- a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json +++ b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/Contents.json @@ -1,8 +1,19 @@ { "images" : [ { - "filename" : "whatsapp_logo.png", - "idiom" : "universal" + "filename" : "whatsapp-1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "whatsapp-2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "whatsapp-3x.png", + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png new file mode 100644 index 00000000..c2878283 Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-1x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-2x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-2x.png new file mode 100644 index 00000000..67a73d04 Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-2x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png new file mode 100644 index 00000000..3b2b89a0 Binary files /dev/null and b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp-3x.png differ diff --git a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp_logo.png b/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp_logo.png deleted file mode 100644 index 2afb9ce6..00000000 Binary files a/Spawn-App-iOS-SwiftUI/Assets.xcassets/whatsapp.imageset/whatsapp_logo.png and /dev/null differ diff --git a/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift b/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift new file mode 100644 index 00000000..a28a84ff --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Extensions/View+KeyboardOverlap.swift @@ -0,0 +1,57 @@ +import Combine +import SwiftUI +import UIKit + +/// Tracks how much of the screen bottom is covered by the keyboard (screen coordinates). +/// Use when ancestors apply `.ignoresSafeArea(.keyboard)` so system avoidance does not run. +final class KeyboardOverlapHeight: ObservableObject { + @Published var value: CGFloat = 0 + + private var observers: [NSObjectProtocol] = [] + + init() { + let center = NotificationCenter.default + observers.append( + center.addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { + [weak self] notification in + self?.update(from: notification) + } + ) + observers.append( + center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { + [weak self] notification in + self?.update(from: notification) + } + ) + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + private func update(from notification: Notification) { + guard + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { + return + } + + let screenHeight = UIScreen.main.bounds.height + let overlap = max(0, screenHeight - frame.minY) + + let duration = + (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue + ?? 0.25 + + withAnimation(.easeOut(duration: duration)) { + value = overlap + } + } +} + +extension View { + /// Bottom padding matching on-screen keyboard overlap (for views under `.ignoresSafeArea(.keyboard)`). + func keyboardOverlapPadding(_ keyboard: KeyboardOverlapHeight) -> some View { + padding(.bottom, keyboard.value) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift index 04ceeef4..656fdbee 100644 --- a/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/API/APIService.swift @@ -114,7 +114,7 @@ final class APIService: IAPIService, @unchecked Sendable { UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn } guard isAuthenticated else { - print("❌ Cannot make API call to \(urlString): User is not logged in") + AppLog.warning("Cannot make API call to \(urlString): User is not logged in", category: "API") throw APIError.invalidStatusCode(statusCode: 401) } } @@ -133,7 +133,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "Invalid URL", category: "API") throw APIError.URLError } var request = URLRequest(url: finalURL) @@ -158,7 +158,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(finalURL)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -212,33 +212,23 @@ final class APIService: IAPIService, @unchecked Sendable { // Only attempt array handling if we're not already expecting an array type if !isArrayType { - // Try to decode as an array of that type - print( - "Attempting to decode as array and extract the first item for entity type '\(T.self)' from URL: \(finalURL)" - ) - do { - // Use JSONSerialization first to check if it's an array if let jsonObject = try JSONSerialization.jsonObject(with: data) as? [Any], !jsonObject.isEmpty { - - // It is an array, try to decode it as such and take the first element if let firstItemData = try? JSONSerialization.data(withJSONObject: jsonObject[0]) { let firstItem = try decoder.decode(T.self, from: firstItemData) - print("Successfully decoded first item from array response") return firstItem } } } catch { - print("Failed to extract first item from array: \(error)") + AppLog.debug("Failed to extract first item from array: \(error)", category: "API") } } - // If we got here, throw the original decoding error - print("JSON Decoding Error: \(decodingError)") + AppLog.error("JSON decoding failed for \(finalURL): \(decodingError)", category: "API") if let jsonString = String(data: data, encoding: .utf8) { - print("Received JSON: \(jsonString)") + AppLog.debug("Response body: \(jsonString)", category: "API") } throw APIError.failedJSONParsing(url: finalURL) } @@ -258,23 +248,11 @@ final class APIService: IAPIService, @unchecked Sendable { urlString.contains("/optional-details") || urlString.contains("/contacts/cross-reference") if !isAuthEndpoint && !isWhitelistedEndpoint { - let (isAuthenticated, spawnUserId, isLoggedIn) = await MainActor.run { - ( - UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn, - UserAuthViewModel.shared.spawnUser?.id.uuidString, - UserAuthViewModel.shared.isLoggedIn - ) + let isAuthenticated = await MainActor.run { + UserAuthViewModel.shared.spawnUser != nil && UserAuthViewModel.shared.isLoggedIn } guard isAuthenticated else { - print("❌ Cannot make API call to \(urlString): User is not logged in") - - // DEBUG: Extra logging for blocking endpoints - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ CRITICAL - User not logged in when attempting to block user!") - print("🚫 DEBUG: spawnUser: \(spawnUserId ?? "nil")") - print("🚫 DEBUG: isLoggedIn: \(isLoggedIn)") - } - + AppLog.warning("Cannot make API call to \(urlString): User is not logged in", category: "API") throw APIError.invalidStatusCode(statusCode: 401) } } @@ -293,7 +271,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } @@ -321,7 +299,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(finalURL)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -332,37 +310,15 @@ final class APIService: IAPIService, @unchecked Sendable { if httpResponse.statusCode == 401 && !(urlString.contains("/auth/sign-in") || urlString.contains("/auth/login")) { - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: Received 401 for blocking request, attempting token refresh...") - } - - // Handle token refresh logic here do { let newAccessToken: String = try await handleRefreshToken() - // Retry the request with the new access token let newData = try await retryRequest(request: &request, bearerAccessToken: newAccessToken) - - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: βœ… Token refresh successful, blocking request retried") - } - return try APIService.makeDecoder().decode(U.self, from: newData) } catch { - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ Token refresh failed for blocking request: \(error)") - } throw error } } - // DEBUG: Log for blocking endpoints specifically - if urlString.contains("blocked-users") { - print("🚫 DEBUG: ❌ Blocking request failed with status code: \(httpResponse.statusCode)") - } - throw createAPIError(statusCode: httpResponse.statusCode, data: data) } // Handle auth tokens if present @@ -391,7 +347,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorMessage = APIError.failedJSONParsing(url: finalURL) .localizedDescription - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedJSONParsing(url: finalURL) } } @@ -420,7 +376,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Ensure the URL is valid after adding query items guard let finalURL = urlComponents?.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } @@ -448,7 +404,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { let message = "HTTP request failed for \(finalURL)" - print(message) + AppLog.error(message, category: "API") throw APIError.failedHTTPRequest(description: message) } @@ -463,7 +419,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorStatusCode = httpResponse.statusCode let message = "Invalid status code \(httpResponse.statusCode) for \(finalURL)" - print(message) + AppLog.error(message, category: "API") throw APIError.invalidStatusCode( statusCode: httpResponse.statusCode) } @@ -493,13 +449,10 @@ final class APIService: IAPIService, @unchecked Sendable { components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } guard let urlWithParams = components.url else { errorMessage = "Invalid URL after adding query parameters" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.URLError } finalUrl = urlWithParams - print("πŸ”“ [APIService] DELETE URL with parameters: \(finalUrl.absoluteString)") - } else { - print("πŸ”“ [APIService] DELETE URL (no parameters): \(finalUrl.absoluteString)") } var request = URLRequest(url: finalUrl) @@ -510,9 +463,6 @@ final class APIService: IAPIService, @unchecked Sendable { let encoder = APIService.makeEncoder() request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try encoder.encode(object) - print("πŸ”“ [APIService] DELETE with body") - } else { - print("πŸ”“ [APIService] DELETE without body") } let response: URLResponse @@ -524,21 +474,18 @@ final class APIService: IAPIService, @unchecked Sendable { // For cancelled DELETE requests, just return without logging return } - print("πŸ”“ [APIService] DELETE request failed with error: \(error)") - print("πŸ”“ [APIService] Error details: \(error.localizedDescription)") + AppLog.warning("DELETE request failed: \(error.localizedDescription)", category: "API") // Re-throw non-cancellation errors throw error } guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } - print("πŸ”“ [APIService] DELETE response status code: \(httpResponse.statusCode)") - // Check for a successful status code (204 is commonly used for successful deletions) guard httpResponse.statusCode == 204 || httpResponse.statusCode == 200 else { @@ -552,7 +499,7 @@ final class APIService: IAPIService, @unchecked Sendable { errorStatusCode = httpResponse.statusCode errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode( statusCode: httpResponse.statusCode) } @@ -595,19 +542,16 @@ final class APIService: IAPIService, @unchecked Sendable { if let imageData = image.jpegData(compressionQuality: 0.9) { let base64String = imageData.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Including profile picture data of size: \(imageData.count) bytes") } else if let pngData = image.pngData() { // Try PNG as a fallback let base64String = pngData.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Including PNG profile picture data of size: \(pngData.count) bytes") } } else if let profilePicUrl = parameters?["profilePicUrl"], !profilePicUrl.isEmpty { // If we have a URL from Google/Apple but no selected image, include it // Try both field names that the backend might be expecting userCreationDTO["profilePictureUrl"] = profilePicUrl userCreationDTO["profilePicture"] = profilePicUrl // Try this field name as well - print("Including profile picture URL from provider: \(profilePicUrl)") // Try to download the image from the URL and convert to base64 if let url = URL(string: profilePicUrl) { @@ -615,9 +559,9 @@ final class APIService: IAPIService, @unchecked Sendable { let (data, _) = try await URLSession.shared.data(from: url) let base64String = data.base64EncodedString() userCreationDTO["profilePictureData"] = base64String - print("Successfully downloaded and included Google profile picture data: \(data.count) bytes") } catch { - print("Failed to download Google profile picture: \(error.localizedDescription)") + AppLog.warning( + "Failed to download provider profile picture: \(error.localizedDescription)", category: "API") } } } @@ -681,7 +625,7 @@ final class APIService: IAPIService, @unchecked Sendable { let accessToken = response.allHeaderFields["Authorization"] as? String ?? response.allHeaderFields[ "authorization"] as? String else { - print("⚠️ WARNING: No access token found in response headers") + AppLog.warning("No access token found in response headers", category: "API") return } @@ -691,7 +635,7 @@ final class APIService: IAPIService, @unchecked Sendable { as? String if refreshToken == nil { - print("⚠️ WARNING: No refresh token found in response headers") + AppLog.debug("No refresh token found in response headers", category: "API") // Don't throw error here - some endpoints might only return access tokens } @@ -708,7 +652,7 @@ final class APIService: IAPIService, @unchecked Sendable { saveSuccessful = KeychainService.shared.save(key: "accessToken", data: accessTokenData) if !saveSuccessful { saveAttempts += 1 - print("⚠️ Access token save attempt \(saveAttempts) failed") + AppLog.warning("Access token save attempt \(saveAttempts) failed", category: "API") if saveAttempts < maxSaveAttempts { // Brief delay before retry Thread.sleep(forTimeInterval: 0.1) @@ -717,7 +661,8 @@ final class APIService: IAPIService, @unchecked Sendable { } if !saveSuccessful { - print("❌ ERROR: Failed to save access token to keychain after \(maxSaveAttempts) attempts") + AppLog.error( + "Failed to save access token to keychain after \(maxSaveAttempts) attempts", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } } @@ -734,7 +679,7 @@ final class APIService: IAPIService, @unchecked Sendable { saveSuccessful = KeychainService.shared.save(key: "refreshToken", data: refreshTokenData) if !saveSuccessful { saveAttempts += 1 - print("⚠️ Refresh token save attempt \(saveAttempts) failed") + AppLog.warning("Refresh token save attempt \(saveAttempts) failed", category: "API") if saveAttempts < maxSaveAttempts { // Brief delay before retry Thread.sleep(forTimeInterval: 0.1) @@ -743,7 +688,8 @@ final class APIService: IAPIService, @unchecked Sendable { } if !saveSuccessful { - print("❌ ERROR: Failed to save refresh token to keychain after \(maxSaveAttempts) attempts") + AppLog.error( + "Failed to save refresh token to keychain after \(maxSaveAttempts) attempts", category: "API") throw APIError.failedTokenSaving(tokenType: "refreshToken") } } @@ -751,7 +697,7 @@ final class APIService: IAPIService, @unchecked Sendable { fileprivate func setAuthHeader(request: inout URLRequest) { guard let url = request.url else { - print("❌ ERROR: URL is nil") + AppLog.error("URLRequest URL is nil", category: "API") return } @@ -770,22 +716,12 @@ final class APIService: IAPIService, @unchecked Sendable { return } - // DEBUG: Log for blocking endpoints specifically - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: Setting auth header for blocking endpoint: \(url.absoluteString)") - } - // Get the access token from keychain guard let accessTokenData = KeychainService.shared.load(key: "accessToken"), let accessToken = String(data: accessTokenData, encoding: .utf8) else { - print("⚠️ Missing access token for authenticated endpoint: \(url.absoluteString)") - - // DEBUG: Extra logging for blocking endpoints - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: ❌ CRITICAL - No access token available for blocking request!") - } + AppLog.debug("Missing access token for authenticated endpoint: \(url.absoluteString)", category: "API") // Check if we have a refresh token available if let refreshTokenData = KeychainService.shared.load(key: "refreshToken"), @@ -793,21 +729,18 @@ final class APIService: IAPIService, @unchecked Sendable { { // We have a refresh token, but we'll let the API call handle the refresh // This will happen in the 401 handler in fetchData/sendData methods - print("πŸ”„ Missing access token but refresh token exists - will refresh during API call") + AppLog.debug( + "Missing access token but refresh token exists - will refresh during API call", category: "API") } else { - print("❌ ERROR: Missing both access token and refresh token in Keychain") - print("πŸ”„ Will let API call fail and higher-level error handling manage re-authentication") + AppLog.warning( + "Missing both access token and refresh token in Keychain for \(url.absoluteString)", category: "API" + ) // Don't immediately sign out - let the API call fail and higher-level error handling // manage re-authentication. This preserves OAuth credentials during onboarding. } return } - // DEBUG: Log for blocking endpoints specifically - if url.absoluteString.contains("blocked-users") { - print("🚫 DEBUG: βœ… Successfully set Authorization header for blocking request") - } - // Set the auth headers request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } @@ -821,9 +754,8 @@ final class APIService: IAPIService, @unchecked Sendable { let encoder = APIService.makeEncoder() let encodedData = try encoder.encode(object) - // Debug: Log the request details - print("πŸ” PATCH REQUEST: \(url.absoluteString)") - print("πŸ” REQUEST BODY: \(String(data: encodedData, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.debug( + "PATCH \(url.absoluteString) body: \(String(data: encodedData, encoding: .utf8) ?? "")", category: "API") var request = URLRequest(url: url) request.httpMethod = "PATCH" @@ -844,13 +776,11 @@ final class APIService: IAPIService, @unchecked Sendable { throw error } - // Debug: Log the response details - print("πŸ” RESPONSE: \(response)") - print("πŸ” RESPONSE DATA: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.debug("PATCH response status: \((response as? HTTPURLResponse)?.statusCode ?? -1)", category: "API") guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print("❌ ERROR: HTTP request failed for \(url)") + AppLog.error("HTTP request failed for \(url)", category: "API") throw APIError.failedHTTPRequest( description: "The HTTP request has failed.") } @@ -864,10 +794,10 @@ final class APIService: IAPIService, @unchecked Sendable { errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print("❌ ERROR: Invalid status code \(httpResponse.statusCode) for \(url)") + AppLog.error("Invalid status code \(httpResponse.statusCode) for \(url)", category: "API") if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - print("❌ ERROR DETAILS: \(errorJson)") + AppLog.debug("Error details: \(errorJson)", category: "API") if httpResponse.statusCode == 400, let message = errorJson["message"] as? String @@ -891,10 +821,8 @@ final class APIService: IAPIService, @unchecked Sendable { } catch { errorMessage = APIError.failedJSONParsing(url: url).localizedDescription - print("❌ ERROR: JSON parsing failed for \(url): \(error)") - - print( - "❌ DATA THAT FAILED TO PARSE: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.error("JSON parsing failed for \(url): \(error)", category: "API") + AppLog.debug("Response body: \(String(data: data, encoding: .utf8) ?? "")", category: "API") throw APIError.failedJSONParsing(url: url) } @@ -927,12 +855,11 @@ final class APIService: IAPIService, @unchecked Sendable { func updateProfilePicture(_ imageData: Data, userId: UUID) async throws -> BaseUserDTO { guard let url = URL(string: APIService.baseURL + "users/\(userId)/profile-picture") else { - print("❌ ERROR: Failed to create URL for profile picture update") + AppLog.error("Failed to create URL for profile picture update", category: "API") throw APIError.URLError } - print("πŸ” UPDATING PROFILE PICTURE: Starting request to \(url.absoluteString)") - print("πŸ” REQUEST DATA SIZE: \(imageData.count) bytes") + AppLog.debug("Profile picture PATCH \(url.absoluteString) size: \(imageData.count) bytes", category: "API") // Create the request var request = URLRequest(url: url) @@ -940,10 +867,8 @@ final class APIService: IAPIService, @unchecked Sendable { request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") request.httpBody = imageData setAuthHeader(request: &request) // Set auth headers if needed - // Log request headers - print("πŸ” REQUEST HEADERS: \(request.allHTTPHeaderFields ?? [:])") - // Perform the request with detailed logging + // Perform the request let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession.shared.data(for: request) @@ -956,18 +881,9 @@ final class APIService: IAPIService, @unchecked Sendable { throw error } - print("πŸ” RESPONSE RECEIVED: \(response)") - - // Check if we can read the response as JSON or text - if let responseString = String(data: data, encoding: .utf8) { - print("πŸ” RESPONSE DATA: \(responseString)") - } else { - print("πŸ” RESPONSE DATA: Unable to convert to string (binary data of \(data.count) bytes)") - } - // Check the HTTP response guard let httpResponse = response as? HTTPURLResponse else { - print("❌ ERROR: Failed HTTP request - unable to get HTTP response") + AppLog.error("Profile picture update: no HTTP response", category: "API") throw APIError.failedHTTPRequest(description: "HTTP request failed") } @@ -979,11 +895,10 @@ final class APIService: IAPIService, @unchecked Sendable { let newData = try await retryRequest(request: &request, bearerAccessToken: newAccessToken) return try JSONDecoder().decode(BaseUserDTO.self, from: newData) } - print("❌ ERROR: Invalid status code \(httpResponse.statusCode)") + AppLog.error("Profile picture update: invalid status \(httpResponse.statusCode)", category: "API") - // Try to parse error details if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - print("❌ ERROR DETAILS: \(errorJson)") + AppLog.debug("Error details: \(errorJson)", category: "API") } throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) @@ -995,9 +910,8 @@ final class APIService: IAPIService, @unchecked Sendable { let updatedUser = try decoder.decode(BaseUserDTO.self, from: data) return updatedUser } catch { - print("❌ ERROR: Failed to decode user data after profile picture update: \(error)") - print( - "❌ DATA THAT FAILED TO PARSE: \(String(data: data, encoding: .utf8) ?? "Unable to convert to string")") + AppLog.error("Failed to decode user after profile picture update: \(error)", category: "API") + AppLog.debug("Response body: \(String(data: data, encoding: .utf8) ?? "")", category: "API") throw APIError.failedJSONParsing(url: url) } } @@ -1065,7 +979,7 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } @@ -1081,7 +995,7 @@ final class APIService: IAPIService, @unchecked Sendable { } errorMessage = "Invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) } @@ -1090,9 +1004,9 @@ final class APIService: IAPIService, @unchecked Sendable { /// Refresh Token fileprivate func handleRefreshToken() async throws -> String { - print("πŸ”„ Attempting to refresh access token...") + AppLog.debug("Attempting to refresh access token", category: "API") guard let url = URL(string: APIService.baseURL + "auth/refresh-token") else { - print("❌ ERROR: Failed to create refresh token URL") + AppLog.error("Failed to create refresh token URL", category: "API") throw APIError.URLError } @@ -1100,8 +1014,7 @@ final class APIService: IAPIService, @unchecked Sendable { let refreshTokenData = KeychainService.shared.load(key: "refreshToken"), let refreshToken = String(data: refreshTokenData, encoding: .utf8) else { - print("❌ ERROR: Missing refresh token in Keychain - cannot refresh") - print("πŸ”„ Logging out user due to missing refresh token") + AppLog.error("Missing refresh token in Keychain β€” signing out", category: "API") // If refresh token is missing, immediately log out the user to prevent endless retry loops await MainActor.run { UserAuthViewModel.shared.signOut() @@ -1109,7 +1022,7 @@ final class APIService: IAPIService, @unchecked Sendable { throw APIError.failedTokenSaving(tokenType: "refreshToken") } - print("πŸ”„ Making refresh token request...") + AppLog.debug("Making refresh token request", category: "API") var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") @@ -1128,16 +1041,14 @@ final class APIService: IAPIService, @unchecked Sendable { guard let httpResponse = response as? HTTPURLResponse else { let message = "HTTP request failed for refresh token endpoint" - print("❌ ERROR: \(message)") + AppLog.error(message, category: "API") throw APIError.failedHTTPRequest(description: message) } - print("πŸ”„ Refresh token response status: \(httpResponse.statusCode)") + AppLog.debug("Refresh token response status: \(httpResponse.statusCode)", category: "API") if httpResponse.statusCode == 401 { - // Refresh token is invalid/expired - print("❌ ERROR: Refresh token invalid (401) - token may be expired") - print("πŸ”„ Clearing invalid tokens but preserving OAuth credentials for potential re-authentication") + AppLog.warning("Refresh token invalid (401)", category: "API") // Clear the invalid tokens let _ = KeychainService.shared.delete(key: "accessToken") @@ -1154,48 +1065,46 @@ final class APIService: IAPIService, @unchecked Sendable { ?? httpResponse.allHeaderFields["authorization"] as? String { let cleanAccessToken = newAccessToken.replacingOccurrences(of: "Bearer ", with: "") - print("πŸ” Received new access token, saving to keychain...") + AppLog.debug("Received new access token, saving to keychain", category: "API") if let accessTokenData = cleanAccessToken.data(using: .utf8) { if !KeychainService.shared.save(key: "accessToken", data: accessTokenData) { - print("❌ ERROR: Failed to save refreshed access token to keychain") + AppLog.error("Failed to save refreshed access token to keychain", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } return "Bearer \(cleanAccessToken)" } else { - print("❌ ERROR: Failed to convert access token to data") + AppLog.error("Failed to convert access token to data", category: "API") throw APIError.failedTokenSaving(tokenType: "accessToken") } } else { - print("❌ ERROR: No access token found in refresh response headers") + AppLog.error("No access token in refresh response headers", category: "API") throw APIError.failedHTTPRequest(description: "No access token in refresh response") } } - print("❌ ERROR: Unexpected status code \(httpResponse.statusCode) from refresh endpoint") + AppLog.error("Unexpected status code \(httpResponse.statusCode) from refresh endpoint", category: "API") - // Log response body for debugging if let responseBody = String(data: data, encoding: .utf8) { - print("πŸ” Refresh response body: \(responseBody)") + AppLog.debug("Refresh response body: \(responseBody)", category: "API") } throw APIError.failedHTTPRequest(description: "Failed to refresh token - status \(httpResponse.statusCode)") } fileprivate func retryRequest(request: inout URLRequest, bearerAccessToken: String) async throws -> Data { - // Retry the request with the new access token - print("πŸ”„ Retrying request with new access token") + AppLog.debug("Retrying request with new access token", category: "API") request.setValue(bearerAccessToken, forHTTPHeaderField: "Authorization") let (newData, newResponse) = try await URLSession.shared.data(for: request) guard let newHttpResponse = newResponse as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(request.url?.absoluteString ?? "unknown URL")" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } guard (200...299).contains(newHttpResponse.statusCode) else { errorMessage = "Invalid status code \(newHttpResponse.statusCode) for \(request.url?.absoluteString ?? "unknown URL")" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: newHttpResponse.statusCode) } return newData @@ -1206,7 +1115,6 @@ final class APIService: IAPIService, @unchecked Sendable { // Don't send validation request if there are no cached items to validate if cachedItems.isEmpty { - print("No cached items to validate, returning empty response") return [:] } @@ -1256,7 +1164,7 @@ final class APIService: IAPIService, @unchecked Sendable { // Validate the response guard let httpResponse = response as? HTTPURLResponse else { errorMessage = "HTTP request failed for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.failedHTTPRequest(description: "The HTTP request has failed.") } @@ -1266,7 +1174,7 @@ final class APIService: IAPIService, @unchecked Sendable { } else if httpResponse.statusCode != 200 { errorStatusCode = httpResponse.statusCode errorMessage = "invalid status code \(httpResponse.statusCode) for \(url)" - print(errorMessage ?? "no error message to log") + AppLog.error(errorMessage ?? "no error message to log", category: "API") throw APIError.invalidStatusCode(statusCode: httpResponse.statusCode) } @@ -1306,9 +1214,9 @@ final class APIService: IAPIService, @unchecked Sendable { if let message = errorMessage { // Store the error message for the view model to use self.errorMessage = message - print("API Error (\(statusCode)): \(message)") + AppLog.warning("API error \(statusCode): \(message)", category: "API") } else { - print("API Error (\(statusCode)): No message available") + AppLog.warning("API error \(statusCode): (no message)", category: "API") } return APIError.invalidStatusCode(statusCode: statusCode) diff --git a/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift b/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift index 440529c3..18a88bd2 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Cache/FriendshipCacheService.swift @@ -48,7 +48,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get friends for the current user func getCurrentUserFriends() -> [FullFriendUserDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserFriends: No user ID") return [] } let userFriends = friends[userId] ?? [] @@ -150,7 +149,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb let startTime = Date() guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh friends: No logged in user") return } @@ -161,7 +159,7 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb let duration = Date().timeIntervalSince(startTime) if duration > 0.5 { - print("⏱️ [FRIENDSHIP-CACHE] refreshFriends took \(String(format: "%.2f", duration))s") + AppLog.debug("refreshFriends took \(String(format: "%.2f", duration))s", category: "FriendshipCache") } } @@ -211,7 +209,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh recommended friends from the backend func refreshRecommendedFriends() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh recommended friends: No logged in user") return } @@ -233,12 +230,9 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get friend requests for the current user func getCurrentUserFriendRequests() -> [FetchFriendRequestDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserFriendRequests: No user ID") return [] } let requests = friendRequests[userId] ?? [] - print( - "πŸ“¦ [FRIENDSHIP-CACHE] getCurrentUserFriendRequests returned \(requests.count) requests for user \(userId)") return requests } @@ -297,21 +291,16 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh friend requests from the backend func refreshFriendRequests() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh friend requests: No logged in user") return } guard UserAuthViewModel.shared.isLoggedIn else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh friend requests: User is not logged in") return } - print("πŸ”„ [FRIENDSHIP-CACHE] Refreshing incoming friend requests for user: \(userId)") - await genericRefresh(endpoint: "friend-requests/incoming/\(userId)") { [weak self] (fetchedFriendRequests: [FetchFriendRequestDTO]) in guard let self = self else { return } - print("πŸ”„ [FRIENDSHIP-CACHE] Retrieved \(fetchedFriendRequests.count) incoming friend requests from API") self.updateFriendRequestsForUser(fetchedFriendRequests, userId: userId) } } @@ -327,22 +316,14 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Get sent friend requests for the current user func getCurrentUserSentFriendRequests() -> [FetchSentFriendRequestDTO] { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("⚠️ [FRIENDSHIP-CACHE] getCurrentUserSentFriendRequests: No user ID") return [] } let requests = sentFriendRequests[userId] ?? [] - print( - "πŸ“¦ [FRIENDSHIP-CACHE] getCurrentUserSentFriendRequests returned \(requests.count) requests for user \(userId)" - ) return requests } /// Update sent friend requests for a specific user func updateSentFriendRequestsForUser(_ newSentFriendRequests: [FetchSentFriendRequestDTO], userId: UUID) { - print( - "πŸ’Ύ [FRIENDSHIP-CACHE] Updating sent friend requests cache for user \(userId): \(newSentFriendRequests.count) requests" - ) - // Normalize: remove zero UUIDs and unique by id let zeroUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! var seen = Set() @@ -395,21 +376,16 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh sent friend requests from the backend func refreshSentFriendRequests() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh sent friend requests: No logged in user") return } guard UserAuthViewModel.shared.isLoggedIn else { - print("πŸ”„ [FRIENDSHIP-CACHE] Cannot refresh sent friend requests: User is not logged in") return } - print("πŸ”„ [FRIENDSHIP-CACHE] Refreshing sent friend requests for user: \(userId)") - await genericRefresh(endpoint: "friend-requests/sent/\(userId)") { [weak self] (fetchedSentFriendRequests: [FetchSentFriendRequestDTO]) in guard let self = self else { return } - print("πŸ”„ [FRIENDSHIP-CACHE] Retrieved \(fetchedSentFriendRequests.count) sent friend requests from API") self.updateSentFriendRequestsForUser(fetchedSentFriendRequests, userId: userId) } } @@ -422,7 +398,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Force refresh both incoming and sent friend requests func forceRefreshAllFriendRequests() async { - print("πŸ”„ [FRIENDSHIP-CACHE] Force refreshing all friend request data") async let incomingTask: () = refreshFriendRequests() async let sentTask: () = refreshSentFriendRequests() @@ -452,7 +427,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Refresh recently spawned with users from the backend func refreshRecentlySpawnedWith() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot refresh recently spawned with: No logged in user") return } @@ -500,7 +474,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb // Clear cache timestamps for this user clearLastCheckedForUser(userId) - print("πŸ’Ύ [FRIENDSHIP-CACHE] Cleared all cached data for user \(userId)") saveToDisk() } @@ -509,7 +482,6 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb } func forceRefreshAll() async { - print("πŸ”„ [FRIENDSHIP-CACHE] Force refreshing all friendship data") async let friendsTask: () = refreshFriends() async let recommendedTask: () = refreshRecommendedFriends() async let requestsTask: () = refreshFriendRequests() @@ -526,26 +498,18 @@ final class FriendshipCacheService: BaseCacheService, CacheService, ObservableOb /// Diagnostic method to force refresh all data with detailed logging func diagnosticForceRefresh() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("❌ [DIAGNOSTIC] Cannot run diagnostic: No user ID available") return } - print("πŸ” [DIAGNOSTIC] Starting diagnostic refresh for user: \(userId)") - print("πŸ” [DIAGNOSTIC] Current state before refresh:") - print(" - Friends: \(friends[userId]?.count ?? 0)") - print(" - Friend requests: \(friendRequests[userId]?.count ?? 0)") - print(" - Sent friend requests: \(sentFriendRequests[userId]?.count ?? 0)") - print(" - Recommended friends: \(recommendedFriends[userId]?.count ?? 0)") - print(" - Recently spawned with: \(recentlySpawnedWith[userId]?.count ?? 0)") - + AppLog.debug( + "diagnostic refresh: friends=\(friends[userId]?.count ?? 0) incoming=\(friendRequests[userId]?.count ?? 0) sent=\(sentFriendRequests[userId]?.count ?? 0)", + category: "FriendshipCache" + ) await forceRefreshAll() - - print("πŸ” [DIAGNOSTIC] State after refresh:") - print(" - Friends: \(friends[userId]?.count ?? 0)") - print(" - Friend requests: \(friendRequests[userId]?.count ?? 0)") - print(" - Sent friend requests: \(sentFriendRequests[userId]?.count ?? 0)") - print(" - Recommended friends: \(recommendedFriends[userId]?.count ?? 0)") - print(" - Recently spawned with: \(recentlySpawnedWith[userId]?.count ?? 0)") + AppLog.debug( + "diagnostic after: friends=\(friends[userId]?.count ?? 0) incoming=\(friendRequests[userId]?.count ?? 0) sent=\(sentFriendRequests[userId]?.count ?? 0)", + category: "FriendshipCache" + ) } // MARK: - Persistence diff --git a/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift b/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift new file mode 100644 index 00000000..5524a0c7 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Services/Core/AppLog.swift @@ -0,0 +1,47 @@ +// +// AppLog.swift +// Spawn-App-iOS-SwiftUI +// +// Centralized logging with severity levels. Adjust `minimumLevel` to control console noise +// (e.g. `.warning` for errors/warnings only, `.info` or `.debug` when investigating issues). +// + +import Foundation + +enum AppLog { + enum Level: Int, Comparable { + case debug = 0 + case info = 1 + case warning = 2 + case error = 3 + + static func < (lhs: Level, rhs: Level) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + /// Only messages at this level or higher are printed. Default is quiet (warnings and errors). + nonisolated(unsafe) static var minimumLevel: Level = .warning + + static func log(_ level: Level, category: String? = nil, _ message: @autoclosure () -> String) { + guard level >= minimumLevel else { return } + let prefix = category.map { "[\($0)] " } ?? "" + print("\(prefix)\(message())") + } + + static func debug(_ message: @autoclosure () -> String, category: String? = nil) { + log(.debug, category: category, message()) + } + + static func info(_ message: @autoclosure () -> String, category: String? = nil) { + log(.info, category: category, message()) + } + + static func warning(_ message: @autoclosure () -> String, category: String? = nil) { + log(.warning, category: category, message()) + } + + static func error(_ message: @autoclosure () -> String, category: String? = nil) { + log(.error, category: category, message()) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Services/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/Services/Integration/DeepLinkManager.swift b/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift index 4bbfc83d..a0fb2f2d 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Integration/DeepLinkManager.swift @@ -23,11 +23,13 @@ final class DeepLinkManager: ObservableObject { // MARK: - URL Handling func handleURL(_ url: URL) { - print("πŸ”— DeepLinkManager: Handling incoming URL: \(url.absoluteString)") + AppLog.debug("πŸ”— DeepLinkManager: Handling incoming URL: \(url.absoluteString)", category: "DeepLink") // Handle both custom URL schemes (spawn://) and Universal Links (https://) guard url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") else { - print("❌ DeepLinkManager: Invalid URL scheme or host: \(url.scheme ?? "nil")://\(url.host ?? "nil")") + AppLog.warning( + "❌ DeepLinkManager: Invalid URL scheme or host: \(url.scheme ?? "nil")://\(url.host ?? "nil")", + category: "DeepLink") return } @@ -40,8 +42,9 @@ final class DeepLinkManager: ObservableObject { let host = url.host ?? "" let pathComponents = url.pathComponents.filter { $0 != "/" } - print( - "πŸ”— DeepLinkManager: Parsing URL - Scheme: \(url.scheme ?? "nil"), Host: \(host), Path components: \(pathComponents)" + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing URL - Scheme: \(url.scheme ?? "nil"), Host: \(host), Path components: \(pathComponents)", + category: "DeepLink" ) // Handle Universal Links: https://getspawn.com/activity/{activityId} or https://getspawn.com/profile/{profileId} @@ -54,19 +57,21 @@ final class DeepLinkManager: ObservableObject { return parseCustomURLScheme(host: host, pathComponents: pathComponents) } - print("❌ DeepLinkManager: Unknown URL format") + AppLog.warning("❌ DeepLinkManager: Unknown URL format", category: "DeepLink") return .unknown } // MARK: - Universal Link Parsing private func parseUniversalLink(pathComponents: [String]) -> DeepLinkType { - print("πŸ”— DeepLinkManager: Parsing Universal Link with path components: \(pathComponents)") + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing Universal Link with path components: \(pathComponents)", category: "DeepLink") // Universal Link format: https://getspawn.com/activity/{activityId} or https://getspawn.com/profile/{profileId} // Share code format: https://getspawn.com/activity/{shareCode} or https://getspawn.com/profile/{shareCode} guard pathComponents.count >= 2 else { - print("❌ DeepLinkManager: Invalid Universal Link - not enough path components") + AppLog.warning( + "❌ DeepLinkManager: Invalid Universal Link - not enough path components", category: "DeepLink") return .unknown } @@ -79,7 +84,7 @@ final class DeepLinkManager: ObservableObject { return .activity(activityId) } else { // New share code format: https://getspawn.com/activity/{shareCode} - print("πŸ”— DeepLinkManager: Detected activity share code: \(identifier)") + AppLog.debug("πŸ”— DeepLinkManager: Detected activity share code: \(identifier)", category: "DeepLink") resolveActivityShareCode(shareCode: identifier) return .unknown // Return unknown for now, will be handled by resolution } @@ -89,19 +94,21 @@ final class DeepLinkManager: ObservableObject { return .profile(profileId) } else { // New share code format: https://getspawn.com/profile/{shareCode} - print("πŸ”— DeepLinkManager: Detected profile share code: \(identifier)") + AppLog.debug("πŸ”— DeepLinkManager: Detected profile share code: \(identifier)", category: "DeepLink") resolveProfileShareCode(shareCode: identifier) return .unknown // Return unknown for now, will be handled by resolution } } else { - print("❌ DeepLinkManager: Unknown Universal Link type: \(type)") + AppLog.warning("❌ DeepLinkManager: Unknown Universal Link type: \(type)", category: "DeepLink") } return .unknown } private func parseCustomURLScheme(host: String, pathComponents: [String]) -> DeepLinkType { - print("πŸ”— DeepLinkManager: Parsing custom URL scheme with host: \(host), path components: \(pathComponents)") + AppLog.debug( + "πŸ”— DeepLinkManager: Parsing custom URL scheme with host: \(host), path components: \(pathComponents)", + category: "DeepLink") // Custom URL scheme format: spawn://activity/{activityId} or spawn://profile/{profileId} if host == "activity" { @@ -113,8 +120,9 @@ final class DeepLinkManager: ObservableObject { { return .activity(activityId) } else { - print( - "❌ DeepLinkManager: Failed to parse custom URL scheme activity ID from: \(activityIdString ?? "nil")" + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme activity ID from: \(activityIdString ?? "nil")", + category: "DeepLink" ) } } else if host == "profile" { @@ -126,8 +134,10 @@ final class DeepLinkManager: ObservableObject { { return .profile(profileId) } else { - print( - "❌ DeepLinkManager: Failed to parse custom URL scheme profile ID from: \(profileIdString ?? "nil")") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme profile ID from: \(profileIdString ?? "nil")", + category: "DeepLink" + ) } } else if pathComponents.count >= 2 { let type = pathComponents[0] @@ -137,19 +147,24 @@ final class DeepLinkManager: ObservableObject { if let activityId = UUID(uuidString: idString) { return .activity(activityId) } else { - print("❌ DeepLinkManager: Failed to parse custom URL scheme path activity ID from: \(idString)") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme path activity ID from: \(idString)", + category: "DeepLink") } } else if type == "profile" { if let profileId = UUID(uuidString: idString) { return .profile(profileId) } else { - print("❌ DeepLinkManager: Failed to parse custom URL scheme path profile ID from: \(idString)") + AppLog.warning( + "❌ DeepLinkManager: Failed to parse custom URL scheme path profile ID from: \(idString)", + category: "DeepLink") } } } - print( - "❌ DeepLinkManager: Invalid custom URL scheme format - expected spawn://activity/{activityId} or spawn://profile/{profileId}" + AppLog.warning( + "❌ DeepLinkManager: Invalid custom URL scheme format - expected spawn://activity/{activityId} or spawn://profile/{profileId}", + category: "DeepLink" ) return .unknown } @@ -158,7 +173,7 @@ final class DeepLinkManager: ObservableObject { private func processPendingDeepLink(_ deepLink: DeepLinkType) { switch deepLink { case .activity(let activityId): - print("🎯 DeepLinkManager: Processing activity deep link: \(activityId)") + AppLog.debug("🎯 DeepLinkManager: Processing activity deep link: \(activityId)", category: "DeepLink") pendingDeepLink = deepLink activityToShow = activityId shouldShowActivity = true @@ -174,7 +189,7 @@ final class DeepLinkManager: ObservableObject { ) case .profile(let profileId): - print("🎯 DeepLinkManager: Processing profile deep link: \(profileId)") + AppLog.debug("🎯 DeepLinkManager: Processing profile deep link: \(profileId)", category: "DeepLink") pendingDeepLink = deepLink profileToShow = profileId shouldShowProfile = true @@ -190,7 +205,7 @@ final class DeepLinkManager: ObservableObject { ) case .unknown: - print("❌ DeepLinkManager: Unknown deep link type, ignoring") + AppLog.warning("❌ DeepLinkManager: Unknown deep link type, ignoring", category: "DeepLink") pendingDeepLink = nil activityToShow = nil shouldShowActivity = false @@ -204,12 +219,13 @@ final class DeepLinkManager: ObservableObject { private func handleActivityDeepLink(_ activityId: UUID) { // If user has the app and is authenticated, open activity in-app if UserAuthViewModel.shared.isLoggedIn { - print("🎯 DeepLinkManager: User authenticated - opening activity in-app") + AppLog.debug("🎯 DeepLinkManager: User authenticated - opening activity in-app", category: "DeepLink") // The activity will be opened by the ContentView listening to notifications // The deep link will register the user as invited to this activity // and show the activity popup in the UI } else { - print("🎯 DeepLinkManager: User not authenticated - will show activity invite page") + AppLog.debug( + "🎯 DeepLinkManager: User not authenticated - will show activity invite page", category: "DeepLink") // If not authenticated, the app will show the activity invite page // where users can see activity details and join/install the app } @@ -218,21 +234,23 @@ final class DeepLinkManager: ObservableObject { private func handleProfileDeepLink(_ profileId: UUID) { // If user has the app and is authenticated, open profile in Friends page if UserAuthViewModel.shared.isLoggedIn { - print("🎯 DeepLinkManager: User authenticated - opening profile in Friends page") + AppLog.debug( + "🎯 DeepLinkManager: User authenticated - opening profile in Friends page", category: "DeepLink") // The profile will be opened in the Friends tab by ContentView } else { - print("🎯 DeepLinkManager: User not authenticated - will show profile invite page") + AppLog.debug( + "🎯 DeepLinkManager: User not authenticated - will show profile invite page", category: "DeepLink") // If not authenticated, show a profile preview/invite page } } // MARK: - Share Code Resolution private func resolveActivityShareCode(shareCode: String) { - print("πŸ”— DeepLinkManager: Resolving activity share code: \(shareCode)") + AppLog.debug("πŸ”— DeepLinkManager: Resolving activity share code: \(shareCode)", category: "DeepLink") let urlString = "\(ServiceConstants.URLs.apiBase)share/activity/\(shareCode)" guard let url = URL(string: urlString) else { - print("❌ DeepLinkManager: Invalid URL for activity share code: \(shareCode)") + AppLog.warning("❌ DeepLinkManager: Invalid URL for activity share code: \(shareCode)", category: "DeepLink") return } @@ -247,22 +265,24 @@ final class DeepLinkManager: ObservableObject { let activityIdString = json["id"] as? String, let activityId = UUID(uuidString: activityIdString) else { - print("❌ DeepLinkManager: Failed to resolve activity share code: \(shareCode)") + AppLog.warning( + "❌ DeepLinkManager: Failed to resolve activity share code: \(shareCode)", category: "DeepLink") return } self?.processPendingDeepLink(.activity(activityId)) } catch { - print("❌ DeepLinkManager: Network error resolving activity share code: \(error)") + AppLog.warning( + "❌ DeepLinkManager: Network error resolving activity share code: \(error)", category: "DeepLink") } } } private func resolveProfileShareCode(shareCode: String) { - print("πŸ”— DeepLinkManager: Resolving profile share code: \(shareCode)") + AppLog.debug("πŸ”— DeepLinkManager: Resolving profile share code: \(shareCode)", category: "DeepLink") let urlString = "\(ServiceConstants.URLs.apiBase)share/profile/\(shareCode)" guard let url = URL(string: urlString) else { - print("❌ DeepLinkManager: Invalid URL for profile share code: \(shareCode)") + AppLog.warning("❌ DeepLinkManager: Invalid URL for profile share code: \(shareCode)", category: "DeepLink") return } @@ -277,19 +297,21 @@ final class DeepLinkManager: ObservableObject { let profileIdString = json["id"] as? String, let profileId = UUID(uuidString: profileIdString) else { - print("❌ DeepLinkManager: Failed to resolve profile share code: \(shareCode)") + AppLog.warning( + "❌ DeepLinkManager: Failed to resolve profile share code: \(shareCode)", category: "DeepLink") return } self?.processPendingDeepLink(.profile(profileId)) } catch { - print("❌ DeepLinkManager: Network error resolving profile share code: \(error)") + AppLog.warning( + "❌ DeepLinkManager: Network error resolving profile share code: \(error)", category: "DeepLink") } } } // MARK: - State Management func clearPendingDeepLink() { - print("πŸ”— DeepLinkManager: Clearing pending deep link") + AppLog.debug("πŸ”— DeepLinkManager: Clearing pending deep link", category: "DeepLink") pendingDeepLink = nil activityToShow = nil shouldShowActivity = false diff --git a/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift b/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift index 1dca7fbc..0b47faeb 100644 --- a/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift +++ b/Spawn-App-iOS-SwiftUI/Services/Notifications/NotificationService.swift @@ -106,31 +106,31 @@ final class NotificationService: NSObject, ObservableObject { } return granted } catch { - print("Error requesting notification permission: \(error.localizedDescription)") + AppLog.warning("Error requesting notification permission: \(error.localizedDescription)", category: "Push") return false } } // Store device token when received from Apple func registerDeviceToken(_ deviceToken: String) { - print("[PUSH DEBUG] Device token received: \(deviceToken)") + AppLog.debug("Device token received: \(deviceToken)", category: "Push") // Store the token for later use storedDeviceToken = deviceToken // Only try to register with backend if user is already logged in if UserAuthViewModel.shared.isLoggedIn { - print("[PUSH DEBUG] User is logged in, registering token with backend immediately") + AppLog.debug("User is logged in, registering token with backend immediately", category: "Push") registerStoredTokenWithBackend() } else { - print("[PUSH DEBUG] User not logged in yet. Token stored for later registration") + AppLog.debug("User not logged in yet. Token stored for later registration", category: "Push") } } // Register the stored token with the backend func registerStoredTokenWithBackend() { guard let token = storedDeviceToken else { - print("No device token available to register") + AppLog.debug("No device token available to register", category: "Push") return } sendTokenToBackend(token) @@ -157,30 +157,31 @@ final class NotificationService: NSObject, ObservableObject { to: url, parameters: nil ) - print("[PUSH DEBUG] Successfully registered device token with backend") + AppLog.debug("Successfully registered device token with backend", category: "Push") } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print( - "[PUSH DEBUG] Device token registration endpoint not available (404): Backend may not support push notifications yet" + AppLog.debug( + "Device token registration endpoint not available (404): Backend may not support push notifications yet", + category: "Push" ) } else { - print("[PUSH DEBUG] Failed to register device token: \(error.localizedDescription)") - print("[PUSH DEBUG] API Error details: \(error)") + AppLog.debug("Failed to register device token: \(error.localizedDescription)", category: "Push") + AppLog.debug("API Error details: \(error)", category: "Push") } } catch { - print("[PUSH DEBUG] Failed to register device token: \(error.localizedDescription)") - print("[PUSH DEBUG] Error details: \(error)") + AppLog.debug("Failed to register device token: \(error.localizedDescription)", category: "Push") + AppLog.debug("Error details: \(error)", category: "Push") } } } else { - print("[PUSH DEBUG] Cannot register device token: user not logged in or missing ID") + AppLog.debug("Cannot register device token: user not logged in or missing ID", category: "Push") if UserAuthViewModel.shared.spawnUser == nil { - print("[PUSH DEBUG] User not logged in (spawnUser is nil)") + AppLog.debug("User not logged in (spawnUser is nil)", category: "Push") } else if let user = UserAuthViewModel.shared.spawnUser { - print("[PUSH DEBUG] User logged in but ID is nil. Username: \(user.username ?? "N/A")") + AppLog.debug("User logged in but ID is nil. Username: \(user.username ?? "N/A")", category: "Push") } - print("[PUSH DEBUG] APIService baseURL: \(APIService.baseURL)") + AppLog.debug("APIService baseURL: \(APIService.baseURL)", category: "Push") } } @@ -214,7 +215,7 @@ final class NotificationService: NSObject, ObservableObject { // Add to notification center UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error scheduling notification: \(error.localizedDescription)") + AppLog.warning("Error scheduling notification: \(error.localizedDescription)", category: "Push") } } } @@ -257,7 +258,8 @@ final class NotificationService: NSObject, ObservableObject { options: [UNNotificationAttachmentOptionsThumbnailHiddenKey: false] ) } catch { - print("Error creating notification attachment: \(error.localizedDescription)") + AppLog.warning( + "Error creating notification attachment: \(error.localizedDescription)", category: "Push") } } @@ -272,7 +274,10 @@ final class NotificationService: NSObject, ObservableObject { options: [UNNotificationAttachmentOptionsThumbnailHiddenKey: false] ) } catch { - print("Error creating notification attachment from bundle: \(error.localizedDescription)") + AppLog.warning( + "Error creating notification attachment from bundle: \(error.localizedDescription)", + category: "Push" + ) } } @@ -281,17 +286,17 @@ final class NotificationService: NSObject, ObservableObject { // Handle different notification types func handleNotification(userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Handling notification with payload: \(userInfo)") + AppLog.debug("Handling notification with payload: \(userInfo)", category: "Push") guard let typeString = userInfo["type"] as? String, let notificationType = NotificationType(rawValue: typeString) else { - print("[PUSH DEBUG] Error: Notification missing or invalid type info") - print("[PUSH DEBUG] Available keys in payload: \(userInfo.keys)") + AppLog.debug("Error: Notification missing or invalid type info", category: "Push") + AppLog.debug("Available keys in payload: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Processing notification of type: \(notificationType.rawValue)") + AppLog.debug("Processing notification of type: \(notificationType.rawValue)", category: "Push") // Handle different notification types switch notificationType { @@ -304,26 +309,26 @@ final class NotificationService: NSObject, ObservableObject { case .chat: handleChatNotification(userInfo) case .welcome: - print("[PUSH DEBUG] Received welcome notification") + AppLog.debug("Received welcome notification", category: "Push") // No special handling needed case .error: - print("[PUSH DEBUG] Received error notification") + AppLog.debug("Received error notification", category: "Push") // Error notifications are typically local app errors, not push notifications case .success: - print("[PUSH DEBUG] Received success notification") + AppLog.debug("Received success notification", category: "Push") // Success notifications are typically local app confirmations, not push notifications } } // Handle friend request notifications private func handleFriendRequestNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing friend request notification with data: \(userInfo)") + AppLog.debug("Processing friend request notification with data: \(userInfo)", category: "Push") guard let senderId = userInfo["senderId"] as? String, let requestId = userInfo["requestId"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in friend request notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in friend request notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } @@ -332,56 +337,57 @@ final class NotificationService: NSObject, ObservableObject { let user = UserAuthViewModel.shared.spawnUser, user.id == userId { - print( - "[PUSH DEBUG] Friend request from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown")), request ID: \(requestId)" + AppLog.debug( + "Friend request from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown")), request ID: \(requestId)", + category: "Push" ) } else { - print("[PUSH DEBUG] Friend request from user \(senderId), request ID: \(requestId)") + AppLog.debug("Friend request from user \(senderId), request ID: \(requestId)", category: "Push") } // Navigate to friend requests view (implementation will depend on your navigation setup) } // Handle activity invite notifications private func handleActivityInviteNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing activity invite notification with data: \(userInfo)") + AppLog.debug("Processing activity invite notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let activityName = userInfo["activityName"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in activity invite notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in activity invite notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Invited to activity \(activityName), ID: \(activityId)") + AppLog.debug("Invited to activity \(activityName), ID: \(activityId)", category: "Push") // Navigate to activity details (implementation will depend on your navigation setup) } // Handle activity update notifications private func handleActivityUpdateNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing activity update notification with data: \(userInfo)") + AppLog.debug("Processing activity update notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let updateType = userInfo["updateType"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in activity update notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in activity update notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } - print("[PUSH DEBUG] Activity update (\(updateType)) for activity ID: \(activityId)") + AppLog.debug("Activity update (\(updateType)) for activity ID: \(activityId)", category: "Push") // Navigate to updated activity (implementation will depend on your navigation setup) } // Handle chat message notifications private func handleChatNotification(_ userInfo: [AnyHashable: Any]) { - print("[PUSH DEBUG] Processing chat notification with data: \(userInfo)") + AppLog.debug("Processing chat notification with data: \(userInfo)", category: "Push") guard let activityId = userInfo["activityId"] as? String, let senderId = userInfo["senderId"] as? String else { - print("[PUSH DEBUG] Error: Missing required fields in chat notification") - print("[PUSH DEBUG] Available keys: \(userInfo.keys)") + AppLog.debug("Error: Missing required fields in chat notification", category: "Push") + AppLog.debug("Available keys: \(userInfo.keys)", category: "Push") return } @@ -390,11 +396,12 @@ final class NotificationService: NSObject, ObservableObject { let user = UserAuthViewModel.shared.spawnUser, user.id == userId { - print( - "[PUSH DEBUG] New chat message in activity \(activityId) from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))" + AppLog.debug( + "New chat message in activity \(activityId) from user \(senderId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))", + category: "Push" ) } else { - print("[PUSH DEBUG] New chat message in activity \(activityId) from user \(senderId)") + AppLog.debug("New chat message in activity \(activityId) from user \(senderId)", category: "Push") } // Navigate to chat (implementation will depend on your navigation setup) } @@ -419,7 +426,7 @@ final class NotificationService: NSObject, ObservableObject { private func savePreferencesToUserDefaults() { guard let userId = UserAuthViewModel.shared.spawnUser?.id.uuidString else { - print("Cannot save notification preferences: no user logged in") + AppLog.debug("Cannot save notification preferences: no user logged in", category: "Push") return } @@ -444,13 +451,13 @@ final class NotificationService: NSObject, ObservableObject { @MainActor func fetchNotificationPreferences() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot fetch notification preferences: user not logged in") + AppLog.debug("Cannot fetch notification preferences: user not logged in", category: "Push") return } // Don't fetch from backend if in mock mode if MockAPIService.isMocking { - print("Using default notification preferences in mock mode") + AppLog.debug("Using default notification preferences in mock mode", category: "Push") return } @@ -476,13 +483,17 @@ final class NotificationService: NSObject, ObservableObject { } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print("Notification preferences endpoint not available (404): Using UserDefaults values") + AppLog.debug( + "Notification preferences endpoint not available (404): Using UserDefaults values", + category: "Push") // Continue using the UserDefaults values that were loaded in init() } else { - print("Failed to fetch notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to fetch notification preferences: \(error.localizedDescription)", category: "Push") } } catch { - print("Failed to fetch notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to fetch notification preferences: \(error.localizedDescription)", category: "Push") } } } @@ -490,14 +501,14 @@ final class NotificationService: NSObject, ObservableObject { // Update notification preferences on the backend func updateNotificationPreferences() async { guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot update notification preferences: user not logged in") + AppLog.debug("Cannot update notification preferences: user not logged in", category: "Push") return } - // Log user details if let user = UserAuthViewModel.shared.spawnUser { - print( - "Updating notification preferences for user ID: \(userId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))" + AppLog.debug( + "Updating notification preferences for user ID: \(userId) (username: \(user.username ?? "Unknown"), name: \(user.name ?? "Unknown"))", + category: "Push" ) } @@ -506,7 +517,7 @@ final class NotificationService: NSObject, ObservableObject { // Don't update backend if in mock mode if MockAPIService.isMocking { - print("Skipping backend update for notification preferences in mock mode") + AppLog.debug("Skipping backend update for notification preferences in mock mode", category: "Push") return } @@ -529,13 +540,18 @@ final class NotificationService: NSObject, ObservableObject { } catch let error as APIError { // Check if it's a 404 error (endpoint doesn't exist yet) if case .invalidStatusCode(let statusCode) = error, statusCode == 404 { - print("Notification preferences endpoint not available (404): Values saved to UserDefaults only") + AppLog.debug( + "Notification preferences endpoint not available (404): Values saved to UserDefaults only", + category: "Push" + ) // We've already saved to UserDefaults above, so just continue } else { - print("Failed to update notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to update notification preferences: \(error.localizedDescription)", category: "Push") } } catch { - print("Failed to update notification preferences: \(error.localizedDescription)") + AppLog.warning( + "Failed to update notification preferences: \(error.localizedDescription)", category: "Push") } } } @@ -546,13 +562,13 @@ final class NotificationService: NSObject, ObservableObject { // Handle notification data and update cache accordingly func handleNotificationData(_ userInfo: [AnyHashable: Any]) { guard let type = userInfo["type"] as? String else { - print("Notification missing type") + AppLog.debug("Notification missing type", category: "Push") return } // Get the current user ID for DataService calls guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("Cannot handle notification - no logged in user") + AppLog.debug("Cannot handle notification - no logged in user", category: "Push") return } @@ -560,7 +576,6 @@ final class NotificationService: NSObject, ObservableObject { switch type { case "friend-accepted": // When a friend request is accepted, refresh friends and friend requests - print("πŸ”„ [CACHE] Friend request accepted - refreshing friends and friend requests") let _: DataResult<[FullFriendUserDTO]> = await DataService.shared.read( .friends(userId: userId), cachePolicy: .apiOnly) let _: DataResult<[FetchFriendRequestDTO]> = await DataService.shared.read( @@ -570,13 +585,11 @@ final class NotificationService: NSObject, ObservableObject { case "activity-updated": // When an activity is updated, refresh activities - print("πŸ”„ [CACHE] Activity updated - refreshing activities") let _: DataResult<[FullFeedActivityDTO]> = await DataService.shared.read( .activities(userId: userId), cachePolicy: .apiOnly) case "friend-request": // When a new friend request is received/sent, refresh both incoming and sent friend requests - print("πŸ”„ [CACHE] Friend request received/sent - refreshing friend requests") let _: DataResult<[FetchFriendRequestDTO]> = await DataService.shared.read( .friendRequests(userId: userId), cachePolicy: .apiOnly) let _: DataResult<[FetchSentFriendRequestDTO]> = await DataService.shared.read( @@ -589,13 +602,12 @@ final class NotificationService: NSObject, ObservableObject { { // Check if this is a profile we already have cached if appCache.otherProfiles[uuid] != nil { - print("πŸ”„ [CACHE] Profile updated - refreshing other profiles") await appCache.refreshOtherProfiles() } } default: - print("Unknown notification type: \(type) - triggering full cache validation") + AppLog.debug("Unknown notification type: \(type) - triggering full cache validation", category: "Push") // For unknown notification types, validate the entire cache await appCache.validateCache() } @@ -605,18 +617,18 @@ final class NotificationService: NSObject, ObservableObject { // Add a method to unregister the device token when signing out func unregisterDeviceToken() async { guard let token = storedDeviceToken ?? Messaging.messaging().fcmToken else { - print("[PUSH DEBUG] No device token to unregister") + AppLog.debug("No device token to unregister", category: "Push") return } guard let userId = UserAuthViewModel.shared.spawnUser?.id else { - print("[PUSH DEBUG] Cannot unregister token: no user ID available - clearing token locally") + AppLog.debug("Cannot unregister token: no user ID available - clearing token locally", category: "Push") // Clear the stored token locally since we can't unregister from backend storedDeviceToken = nil return } - print("[PUSH DEBUG] Preparing to unregister device token: \(token)") + AppLog.debug("Preparing to unregister device token: \(token)", category: "Push") if let url = URL(string: "\(APIService.baseURL)notifications/device-tokens") { do { @@ -634,11 +646,11 @@ final class NotificationService: NSObject, ObservableObject { object: deviceTokenDTO ) - print("[PUSH DEBUG] Successfully unregistered device token") + AppLog.debug("Successfully unregistered device token", category: "Push") // Clear the stored token after successful unregistration storedDeviceToken = nil } catch { - print("[PUSH DEBUG] Failed to unregister device token: \(error.localizedDescription)") + AppLog.debug("Failed to unregister device token: \(error.localizedDescription)", category: "Push") // Clear the stored token locally even if backend unregistration failed storedDeviceToken = nil } @@ -651,14 +663,13 @@ extension NotificationService { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } let token = tokenParts.joined() - print("Device Token: \(token)") + AppLog.debug("Device token (APNs): \(token)", category: "Push") - // Here you would send the token to your server sendDeviceTokenToServer(token) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("Failed to register for notifications: \(error.localizedDescription)") + AppLog.warning("Failed to register for notifications: \(error.localizedDescription)", category: "Push") } private func sendDeviceTokenToServer(_ token: String) { @@ -674,9 +685,9 @@ extension NotificationService { let tokenData = ["deviceToken": token] _ = try await apiService.patchData(from: url, with: tokenData) as EmptyResponse - print("Device token successfully sent to server") + AppLog.debug("Device token successfully sent to server", category: "Push") } catch { - print("Failed to send device token: \(error)") + AppLog.warning("Failed to send device token: \(error)", category: "Push") } } } 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/Spawn_App_iOS_SwiftUIApp.swift b/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift index 244253da..1367e429 100644 --- a/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift +++ b/Spawn-App-iOS-SwiftUI/Spawn_App_iOS_SwiftUIApp.swift @@ -19,6 +19,8 @@ struct Spawn_App_iOS_SwiftUIApp: App { @Environment(\.scenePhase) private var scenePhase init() { + // Verbose logs: set `AppLog.minimumLevel` to `.debug` or `.info` while investigating (default is `.warning`). + // Register custom fonts Font.registerFonts() @@ -50,58 +52,57 @@ struct Spawn_App_iOS_SwiftUIApp: App { ], for: .normal) } + @ViewBuilder private var rootView: some View { - Group { - if !userAuth.hasCheckedSpawnUserExistence { - // Show loading screen while checking user existence - LoadingView() - .onAppear { - // Connect the app delegate to the app - appDelegate.app = self - } - .task { - // If we're mocking, simulate a login with mock user - if MockAPIService.isMocking { - await userAuth.setMockUser() - } + if !userAuth.hasCheckedSpawnUserExistence { + // Show loading screen while checking user existence + LoadingView() + .onAppear { + // Connect the app delegate to the app + appDelegate.app = self + } + .task { + // If we're mocking, simulate a login with mock user + if MockAPIService.isMocking { + await userAuth.setMockUser() } - .onOpenURL { url in + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) + } + .onestFontTheme() + } else if userAuth.isLoggedIn, let spawnUser = userAuth.spawnUser, userAuth.hasCompletedOnboarding { + // User is logged in, has user data, and has completed onboarding - go to main content + ContentView(user: spawnUser, deepLinkManager: deepLinkManager) + .task { + // Initialize and validate the cache + await appCache.validateCache() + // Clean up any expired activities after cache validation + appCache.cleanupExpiredActivities() + } + .onOpenURL { url in + AppLog.debug("App received URL: \(url.absoluteString)", category: "DeepLink") + // Handle Google Sign-In URLs + if url.scheme == UserAuthViewModel.googleIOSURLScheme { GIDSignIn.sharedInstance.handle(url) } - .onestFontTheme() - } else if userAuth.isLoggedIn, let spawnUser = userAuth.spawnUser, userAuth.hasCompletedOnboarding { - // User is logged in, has user data, and has completed onboarding - go to main content - ContentView(user: spawnUser, deepLinkManager: deepLinkManager) - .task { - // Initialize and validate the cache - await appCache.validateCache() - // Clean up any expired activities after cache validation - appCache.cleanupExpiredActivities() - } - .onOpenURL { url in - print("πŸ”— App: Received URL: \(url.absoluteString)") - // Handle Google Sign-In URLs - if url.scheme == "com.googleusercontent.apps.822760465266-hl53d2rku66uk4cljschig9ld0ur57na" { - GIDSignIn.sharedInstance.handle(url) - } - // Handle Spawn deep links (both custom URL schemes and Universal Links) - else if url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") { - deepLinkManager.handleURL(url) - } + // Handle Spawn deep links (both custom URL schemes and Universal Links) + else if url.scheme == "spawn" || (url.scheme == "https" && url.host == "getspawn.com") { + deepLinkManager.handleURL(url) } - .onestFontTheme() - } else { - // User is not logged in or has no user data - show welcome screen - WelcomeView() - .onAppear { - // Connect the app delegate to the app - appDelegate.app = self - } - .onOpenURL { url in - GIDSignIn.sharedInstance.handle(url) - } - .onestFontTheme() - } + } + .onestFontTheme() + } else { + // User is not logged in or has no user data - show welcome screen + WelcomeView() + .onAppear { + // Connect the app delegate to the app + appDelegate.app = self + } + .onOpenURL { url in + GIDSignIn.sharedInstance.handle(url) + } + .onestFontTheme() } } diff --git a/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md new file mode 100644 index 00000000..d87be02e --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/TERMS_SUGGESTIONS.md @@ -0,0 +1,55 @@ +# 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 / unincorporated (Section 7) + +- **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. Location (Section 6) + +- 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. 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. + +--- + +## 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"). + +--- + +## 5. 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. + +--- + +## 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**. 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. + +--- + +*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/ViewModels/AuthFlow/TutorialViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift index 8790598e..d8a0ab77 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/TutorialViewModel.swift @@ -158,10 +158,14 @@ final class TutorialViewModel { /// Handle activity creation completion during tutorial func handleActivityCreationComplete() { - guard case .activityCreation = tutorialState else { return } - - // Complete the tutorial - completeTutorial() + // Complete tutorial when user finishes creating an activity - handle both activityCreation + // (normal flow) and activityTypeSelection (user selected type directly in activities tab) + switch tutorialState { + case .activityCreation, .activityTypeSelection: + completeTutorial() + case .notStarted, .completed: + break + } } // MARK: - Server Sync Methods diff --git a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift index 19b1f407..4d4508e9 100644 --- a/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift +++ b/Spawn-App-iOS-SwiftUI/ViewModels/AuthFlow/UserAuthViewModel.swift @@ -13,6 +13,9 @@ import UIKit @MainActor final class UserAuthViewModel: NSObject, ObservableObject { + /// Google Sign-In URL scheme β€” must match `CFBundleURLSchemes` in Info.plist. + static let googleIOSURLScheme = "com.googleusercontent.apps.822760465266-hl53d2rku66uk4cljschig9ld0ur57na" + static let shared: UserAuthViewModel = UserAuthViewModel( apiService: MockAPIService.isMocking ? MockAPIService() : APIService()) // Singleton instance @Published var errorMessage: String? diff --git a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift index 56720d6f..d9efb086 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Helpers/Constants.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit // MARK: - Dimensions and Spacing let dimensionScale: CGFloat = 2 @@ -16,8 +17,20 @@ let dimensionLG: CGFloat = 32 let dimensionXL: CGFloat = 64 /// Horizontal padding from screen edges for main content (home feed, activity creation). -/// Increased for physical devices (e.g. iPhone 11) where 32pt felt cramped. -let screenEdgePadding: CGFloat = 40 +/// Scales down on narrower widths so content is not over-inset toward the center. +func screenEdgePadding(for width: CGFloat) -> CGFloat { + switch width { + case ..<376: return 16 + case ..<400: return 20 + case ..<430: return 28 + default: return 36 + } +} + +@MainActor +func screenEdgePadding() -> CGFloat { + screenEdgePadding(for: UIScreen.main.bounds.width) +} let spacingXS: CGFloat = dimensionXS let spacingSM: CGFloat = dimensionSM diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift index e9b41af5..e015ad06 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCard/ActivityCardView.swift @@ -25,7 +25,7 @@ struct ActivityCardView: View { locationManager: LocationManager, callback: @escaping (FullFeedActivityDTO, Color) -> Void, selectedTab: Binding = .constant(nil), - horizontalPadding: CGFloat = screenEdgePadding + horizontalPadding: CGFloat = screenEdgePadding() ) { self.activity = activity self.color = color diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift index d30acd1d..ad43d876 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/ActivityCreationView.swift @@ -218,6 +218,8 @@ struct ActivityCreationView: View { // If we're at activityType step and an activity type gets selected, skip to dateTime if currentStep == .activityType && newActivityType != nil { currentStep = .dateTime + // Progress tutorial from activityTypeSelection β†’ activityCreation so other tabs become enabled + tutorialViewModel.handleActivityTypeSelection(newActivityType!) } } } 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") diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift index 4970039e..b0e45d7f 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeManagement/ActivityTypeOptionsPopup.swift @@ -6,106 +6,112 @@ struct ActivityTypeOptionsPopup: View { let onDeleteActivityType: () -> Void @State private var showDeleteConfirmation = false + /// Bottom padding to place drawer above the tab bar (matches WithTabBarBinding tabBarSpacing) + private let tabBarHeight: CGFloat = 92 // 64 button + 8 padding + 20 spacing + var body: some View { - ZStack { - // Semi-transparent background overlay - matching Figma exactly - Rectangle() - .foregroundColor(.clear) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.60)) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false + GeometryReader { geometry in + let bottomInset = geometry.safeAreaInsets.bottom + tabBarHeight + 16 + ZStack { + // Semi-transparent background overlay - matching Figma exactly + Rectangle() + .foregroundColor(.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.60)) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } } - } - // Popup content positioned at bottom - VStack { - Spacer() + // Popup content positioned at bottom, above nav bar + VStack { + Spacer() - VStack(alignment: .leading, spacing: 16) { - // Main options group - VStack(alignment: .leading, spacing: 0) { - // Manage People option - Button(action: { - onManagePeople() - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false + VStack(alignment: .leading, spacing: 16) { + // Main options group + VStack(alignment: .leading, spacing: 0) { + // Manage People option + Button(action: { + onManagePeople() + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } + }) { + HStack(spacing: 10) { + Text("Manage People") + .font(Font.custom("Onest", size: 20).weight(.medium)) + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .frame(height: 63) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .overlay( + Rectangle() + .inset(by: 0.50) + .stroke(Color(red: 0.52, green: 0.49, blue: 0.49), lineWidth: 0.50) + ) + .shadow( + color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 + ) } - }) { - HStack(spacing: 10) { - Text("Manage People") - .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + .buttonStyle(PlainButtonStyle()) + + // Delete Activity Type option + Button(action: { + showDeleteConfirmation = true + }) { + HStack(spacing: 10) { + Image(systemName: "trash") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.red) + Text("Delete Activity Type") + .font(Font.custom("Onest", size: 20).weight(.medium)) + .foregroundColor(.red) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .frame(height: 63) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .shadow( + color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 + ) } - .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .frame(height: 63) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color(red: 0.95, green: 0.93, blue: 0.93)) - .overlay( - Rectangle() - .inset(by: 0.50) - .stroke(Color(red: 0.52, green: 0.49, blue: 0.49), lineWidth: 0.50) - ) - .shadow( - color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 - ) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) + .cornerRadius(16) - // Delete Activity Type option + // Cancel button Button(action: { - showDeleteConfirmation = true + withAnimation(.easeInOut(duration: 0.3)) { + isPresented = false + } }) { HStack(spacing: 10) { - Image(systemName: "trash") + Image(systemName: "xmark") .font(.system(size: 20, weight: .medium)) - .foregroundColor(.red) - Text("Delete Activity Type") + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) + Text("Cancel") .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(.red) + .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) } .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) .frame(height: 63) .frame(maxWidth: .infinity, alignment: .center) .background(Color(red: 0.95, green: 0.93, blue: 0.93)) + .cornerRadius(16) .shadow( color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 ) } .buttonStyle(PlainButtonStyle()) } - .cornerRadius(16) - - // Cancel button - Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { - isPresented = false - } - }) { - HStack(spacing: 10) { - Image(systemName: "xmark") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) - Text("Cancel") - .font(Font.custom("Onest", size: 20).weight(.medium)) - .foregroundColor(Color(red: 0.11, green: 0.11, blue: 0.11)) - } - .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .frame(height: 63) - .frame(maxWidth: .infinity, alignment: .center) - .background(Color(red: 0.95, green: 0.93, blue: 0.93)) - .cornerRadius(16) - .shadow( - color: Color(red: 0, green: 0, blue: 0, opacity: 0.25), radius: 8, y: 2 - ) - } - .buttonStyle(PlainButtonStyle()) + .frame(width: 380) + .padding(.horizontal, 24) + .padding(.bottom, bottomInset) } - .frame(width: 380) - .padding(.horizontal, 24) - .padding(.bottom, 40) } } .alert("Delete Activity Type", isPresented: $showDeleteConfirmation) { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift index 8fc9e7a8..c593039b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/ActivityTypeSelection/ActivityTypeView.swift @@ -42,11 +42,11 @@ struct ActivityTypeView: View { .font(.caption) .foregroundColor(.red) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 8) .background(Color.red.opacity(0.1)) .cornerRadius(8) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) } if viewModel.isLoading { @@ -165,7 +165,7 @@ extension ActivityTypeView { .font(.title3) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } @@ -190,7 +190,7 @@ extension ActivityTypeView { .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(screenEdgePadding) + .padding(screenEdgePadding()) } private var activityTypeGrid: some View { @@ -202,7 +202,7 @@ extension ActivityTypeView { createNewActivityButton } - .padding(screenEdgePadding) + .padding(screenEdgePadding()) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift index c78738bc..e76b502e 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityConfirmationView.swift @@ -95,7 +95,7 @@ struct ActivityConfirmationView: View { .font(Font.custom("Onest", size: 16).weight(.medium)) .foregroundColor(adaptiveSecondaryTextColor) .multilineTextAlignment(.center) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) } .padding(.bottom, 30) @@ -178,30 +178,11 @@ struct ActivityConfirmationView: View { // MARK: - Header View private var headerView: some View { - HStack { - // Back button - if let onBack = onBack { - ActivityBackButton { - onBack() - } - } - - Spacer() - - // Title - Text("Confirm") - .font(.onestSemiBold(size: 20)) - .foregroundColor(adaptiveTextColor) - - Spacer() - - // Invisible chevron to balance the back button - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .padding(.horizontal, screenEdgePadding) - .padding(.vertical, 12) + ActivityCreationCenteredTitleHeader( + title: "Confirm", + titleColor: adaptiveTextColor, + onBack: onBack + ) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift index 3bb38c8c..e02e97b9 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/Confirmation/ActivityPreConfirmationView.swift @@ -60,7 +60,7 @@ struct ActivityPreConfirmationView: View { // Activity card activityCardView - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activity title Text( @@ -87,7 +87,7 @@ struct ActivityPreConfirmationView: View { .font(.onestMedium(size: 20)) .foregroundColor(adaptiveSecondaryTextColor) .padding(.top, 12) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) Spacer() @@ -130,37 +130,18 @@ struct ActivityPreConfirmationView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 80) // Standard bottom padding .background(adaptiveBackgroundColor) } // MARK: - Header View private var headerView: some View { - HStack { - // Back button - if let onBack = onBack { - ActivityBackButton { - onBack() - } - } - - Spacer() - - // Title - Text("Confirm") - .font(.onestSemiBold(size: 20)) - .foregroundColor(adaptiveTextColor) - - Spacer() - - // Invisible chevron to balance the back button - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .padding(.horizontal, screenEdgePadding) - .padding(.vertical, 12) + ActivityCreationCenteredTitleHeader( + title: "Confirm", + titleColor: adaptiveTextColor, + onBack: onBack + ) } // MARK: - Activity Card View diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift index 0efa9408..c61f4b5a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/DateTimeSelection/ActivityDateTimeView.swift @@ -380,7 +380,7 @@ struct ActivityDateTimeView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } else { HStack { @@ -397,7 +397,7 @@ struct ActivityDateTimeView: View { .font(.onestSemiBold(size: 20)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) } Text("Set a time for your Activity") @@ -488,7 +488,7 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 24) // Activity Duration Section @@ -508,14 +508,14 @@ struct ActivityDateTimeView: View { } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 50) if !viewModel.timeValidationMessage.isEmpty { Text(viewModel.timeValidationMessage) .font(.custom("Onest", size: 12)) .foregroundColor(.red) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 8) } @@ -549,7 +549,7 @@ struct ActivityDateTimeView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Step indicators StepIndicatorView(currentStep: 1, totalSteps: 3) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift index 2d96fb9b..5c75c759 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityCreation/Steps/LocationSelection/ActivityCreationLocationView.swift @@ -184,7 +184,7 @@ struct ActivityCreationLocationView: View { } } .frame(height: 24) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 6) // Search bar @@ -202,7 +202,7 @@ struct ActivityCreationLocationView: View { RoundedRectangle(cornerRadius: 8) .stroke(figmaBlack300, lineWidth: 1) ) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 2) // Location list @@ -263,7 +263,7 @@ struct ActivityCreationLocationView: View { } } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) Spacer() } Spacer() @@ -437,7 +437,7 @@ struct ActivityCreationLocationView: View { .padding(.bottom, 8) } } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.bottom, 10) .background( universalBackgroundColor diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift index 09afd615..c81b4bf2 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityCardPopupView.swift @@ -127,11 +127,10 @@ struct ActivityCardPopupView: View { .cornerRadius(isExpanded ? 0 : 20) .shadow(radius: isExpanded ? 0 : 20) .ignoresSafeArea( - (showingChatroom || showingParticipants) && isExpanded - ? .all : .container, + .container, // Not .allβ€”respect keyboard so text input stays visible when typing edges: (showingChatroom || showingParticipants) && isExpanded ? .all : .bottom - ) // Fill entire screen when chatroom/participants are maximized + ) .frame(maxWidth: .infinity, maxHeight: isExpanded ? .infinity : nil) // Only expand to fill when expanded .sheet(isPresented: $showActivityMenu) { ActivityMenuView( diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift index 337a6651..d1a7ee5a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/ActivityPopup/ActivityPopupDrawer.swift @@ -100,7 +100,7 @@ struct ActivityPopupDrawer: View { } ) .allowsHitTesting(true) // Ensure buttons inside can still be tapped - .ignoresSafeArea(.all, edges: .bottom) // Always extend to bottom edge + .ignoresSafeArea(.container, edges: .bottom) // Extend past home indicator; keyboard handled in ChatroomContentView .zIndex(isExpanded ? 1000 : 1) // Ensure expanded popup appears above tab bar } .transition(.move(edge: .bottom)) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift index f7990189..739cfa03 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Chatroom/ChatroomContentView.swift @@ -3,6 +3,7 @@ import SwiftUI // MARK: - Embedded Chatroom Content View (for use within drawers) struct ChatroomContentView: View { @State private var messageText = "" + @StateObject private var keyboardOverlap = KeyboardOverlapHeight() var user: BaseUserDTO = UserAuthViewModel.shared.spawnUser ?? BaseUserDTO.danielAgapov @ObservedObject var activity: FullFeedActivityDTO @@ -48,9 +49,9 @@ struct ChatroomContentView: View { headerView .padding(.top, geometry.safeAreaInsets.top + 16) - // Messages area - fixed size based on expansion state + // Messages fill remaining space (flexible when keyboard overlaps) messagesScrollView - .frame(height: expandedMessagesHeight) + .frame(maxHeight: .infinity) .clipped() // Input area - always anchored at bottom with no spacing below @@ -86,6 +87,7 @@ struct ChatroomContentView: View { .frame(height: minimizedContentHeight) // Fixed total height to match drawer } } + .keyboardOverlapPadding(keyboardOverlap) .onAppear { Task { await viewModel.refreshChat() @@ -98,47 +100,23 @@ struct ChatroomContentView: View { // But we need to account for the drawer offset (20% of screen), so visible content is less // Using a fixed total height ensures consistent sizing that matches main card content private let minimizedContentHeight: CGFloat = 600 // Total height for chatroom when minimized - private let expandedMessagesHeight: CGFloat = 710 // MARK: - View Components private var headerView: some View { HStack { - backButton + UnifiedBackButton(foregroundColor: .white.opacity(0.6), action: onBack) Spacer() - titleText + Text("Chatroom") + .font(Font.custom("Onest", size: 20).weight(.semibold)) + .foregroundColor(.white) Spacer() - invisibleBalanceButton + InvisibleBalanceButton() } .padding(.horizontal, 24) .padding(.bottom, 8) // Reduced from 16 to fit content better } - private var backButton: some View { - Button(action: { - onBack() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.white.opacity(0.6)) - } - } - - private var titleText: some View { - Text("Chatroom") - .font(Font.custom("Onest", size: 20).weight(.semibold)) - .foregroundColor(.white) - } - - private var invisibleBalanceButton: some View { - Button(action: {}) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.clear) - } - .disabled(true) - } - private var messagesScrollView: some View { ScrollViewReader { proxy in ScrollView { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift index acbb0308..030cc7ca 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Participants/Components/ParticipantsBackButton.swift @@ -7,14 +7,6 @@ struct ParticipantsBackButton: View { let action: () -> Void var body: some View { - Button(action: { - HapticFeedbackService.shared.light() - action() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .semibold)) - .foregroundColor(.white.opacity(0.6)) - } - .buttonStyle(PlainButtonStyle()) + UnifiedBackButton(foregroundColor: .white.opacity(0.6), action: action) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift new file mode 100644 index 00000000..bca5d572 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Activities/Shared/ActivityCreationCenteredTitleHeader.swift @@ -0,0 +1,36 @@ +// +// ActivityCreationCenteredTitleHeader.swift +// Spawn-App-iOS-SwiftUI +// +// Centered title bar with optional back control and trailing balance spacer. +// + +import SwiftUI + +struct ActivityCreationCenteredTitleHeader: View { + let title: String + let titleColor: Color + let onBack: (() -> Void)? + + var body: some View { + HStack { + if let onBack = onBack { + ActivityBackButton { + onBack() + } + } + + Spacer() + + Text(title) + .font(.onestSemiBold(size: 20)) + .foregroundColor(titleColor) + + Spacer() + + InvisibleBalanceButton() + } + .padding(.horizontal, screenEdgePadding()) + .padding(.vertical, 12) + } +} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/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 { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift index bc3e26f8..8e3ff873 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/CoreInputView.swift @@ -174,7 +174,6 @@ struct CoreInputView: View { .background(universalBackgroundColor(from: themeService, environment: colorScheme)) } .navigationBarHidden(true) - .withAuthNavigation(viewModel) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift index 405b7cb8..7c7120ab 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LaunchView.swift @@ -18,111 +18,97 @@ struct LaunchView: View { @Environment(\.dismiss) private var dismiss @State private var showAuthButtons = false @State private var animationCompleted = false - @State private var navigationPath = NavigationPath() var body: some View { - NavigationStack(path: $navigationPath) { - VStack(spacing: 16) { - Spacer() + VStack(spacing: 16) { + Spacer() - if !animationCompleted { - // Initial Rive animation for new users - RiveAnimationView.logoAnimation(fileName: "spawn_logo_animation") - .frame(width: 300, height: 300) - .task { - // Show auth buttons after animation completes - try? await Task.sleep(for: .seconds(2.0)) - withAnimation(.easeInOut(duration: 0.5)) { - animationCompleted = true - showAuthButtons = true - } + if !animationCompleted { + // Initial Rive animation for new users + RiveAnimationView.logoAnimation(fileName: "spawn_logo_animation") + .frame(width: 300, height: 300) + .task { + // Show auth buttons after animation completes + try? await Task.sleep(for: .seconds(2.0)) + withAnimation(.easeInOut(duration: 0.5)) { + animationCompleted = true + showAuthButtons = true } - } else { - // Static logo after animation - Image("spawn_branding_logo") - .resizable() - .scaledToFit() - .frame(width: 200, height: 100) - .transition(.opacity) - } + } + } else { + // Static logo after animation + Image("spawn_branding_logo") + .resizable() + .scaledToFit() + .frame(width: 200, height: 100) + .transition(.opacity) + } - if showAuthButtons { - Image("spontaneity_made_easy") - .resizable() - .scaledToFit() - .frame(width: 300, height: 100) - .transition(.opacity) + if showAuthButtons { + Image("spontaneity_made_easy") + .resizable() + .scaledToFit() + .frame(width: 300, height: 100) + .transition(.opacity) - Spacer().frame(height: 32) - } + Spacer().frame(height: 32) + } - // Google Sign-In Button - if showAuthButtons && !userAuth.isAutoSigningIn { - Button(action: { - // Haptic feedback - let impactGenerator = UIImpactFeedbackGenerator(style: .medium) - impactGenerator.impactOccurred() + // Google Sign-In Button + if showAuthButtons && !userAuth.isAutoSigningIn { + Button(action: { + // Haptic feedback + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + impactGenerator.impactOccurred() - Task { - await userAuth.googleRegister() - } - }) { - AuthProviderButtonView(authProviderType: .google) + Task { + await userAuth.googleRegister() } - .buttonStyle(AuthProviderButtonStyle()) - .transition(.opacity) + }) { + AuthProviderButtonView(authProviderType: .google) + } + .buttonStyle(AuthProviderButtonStyle()) + .transition(.opacity) - // Apple Sign-In Button - Button(action: { - // Haptic feedback - let impactGenerator = UIImpactFeedbackGenerator(style: .medium) - impactGenerator.impactOccurred() + // Apple Sign-In Button + Button(action: { + // Haptic feedback + let impactGenerator = UIImpactFeedbackGenerator(style: .medium) + impactGenerator.impactOccurred() - userAuth.appleRegister() - }) { - AuthProviderButtonView(authProviderType: .apple) - } - .buttonStyle(AuthProviderButtonStyle()) - .transition(.opacity) + userAuth.appleRegister() + }) { + AuthProviderButtonView(authProviderType: .apple) } + .buttonStyle(AuthProviderButtonStyle()) + .transition(.opacity) + } - // Auto Sign-In Loading State - if userAuth.isAutoSigningIn { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle( - CircularProgressViewStyle( - tint: universalAccentColor(from: themeService, environment: colorScheme)) - ) - .scaleEffect(1.2) + // Auto Sign-In Loading State + if userAuth.isAutoSigningIn { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle( + CircularProgressViewStyle( + tint: universalAccentColor(from: themeService, environment: colorScheme)) + ) + .scaleEffect(1.2) - Text("Account found! Signing you in...") - .font(.onestMedium(size: 16)) - .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 40) - .transition(.opacity) + Text("Account found! Signing you in...") + .font(.onestMedium(size: 16)) + .foregroundColor(universalAccentColor(from: themeService, environment: colorScheme)) + .multilineTextAlignment(.center) } - - Spacer() - } - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) - .ignoresSafeArea(.all) - .onAppear { - print("πŸ”„ DEBUG: LaunchView appeared") + .padding(.horizontal, 40) + .transition(.opacity) } + + Spacer() } - .withAuthNavigation(userAuth) - .onReceive(userAuth.$navigationState) { newState in - if newState != .none { - navigationPath.append(newState) - print("πŸ“ DEBUG: Appending navigation state to path: \(newState.description)") - } else { - // Clear navigation path when state is reset to none - navigationPath = NavigationPath() - print("πŸ“ DEBUG: Clearing navigation path") - } + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) + .ignoresSafeArea(.all) + .onAppear { + print("πŸ”„ DEBUG: LaunchView appeared") } } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift index bcd5d3ea..6bf7ad74 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/LoginInputView.swift @@ -225,7 +225,6 @@ struct LoginInputView: View { } .background(universalBackgroundColor(from: themeService, environment: colorScheme)) .navigationBarHidden(true) - .withAuthNavigation(userAuth) .onAppear { // Reset any previous error state userAuth.errorMessage = nil 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..afa8f608 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/PrivacyPolicyPlaceholderView.swift @@ -0,0 +1,73 @@ +// +// 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 + + private var privacyPolicyURL: URL? { URL(string: ServiceConstants.URLs.privacyPolicy) } + + 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() + 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, 24) + .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..8419236a --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/TermsAndConditionsView.swift @@ -0,0 +1,131 @@ +// +// 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 10th 2026") + .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.") + 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. 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 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 the operators of Spawn." + ) + 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/AuthFlow/Registration/VerificationCodeView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift index 0cb42dba..ff73cfc3 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/AuthFlow/Registration/VerificationCodeView.swift @@ -22,46 +22,41 @@ struct VerificationCodeView: View { var body: some View { VStack(spacing: 0) { - // Navigation Bar - matches activity creation flow positioning + Spacer() + mainContent + Spacer() + } + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) + .safeAreaInset(edge: .top, spacing: 0) { HStack { UnifiedBackButton { - // Clear any error states when going back userAuthViewModel.clearAllErrors() dismiss() } Spacer() } .padding(.horizontal, 25) - .padding(.top, 16) - Spacer() - mainContent - Spacer() + .padding(.vertical, 16) + .background(universalBackgroundColor(from: themeService, environment: colorScheme)) } - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) .onAppear { viewModel.initialize() focusedIndex = viewModel.focusedIndex - // Clear any previous error state when this view appears userAuthViewModel.clearAllErrors() } .onDisappear { viewModel.stopTimer() } - .navigationBarHidden(true) - } - - private var navigationBar: some View { - HStack { - UnifiedBackButton { - // Go back one step in the onboarding flow - dismiss() + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + focusedIndex = nil + viewModel.focusedIndex = nil + } } - Spacer() } - .padding(.horizontal, 25) - .padding(.top, 16) - .background(universalBackgroundColor(from: themeService, environment: colorScheme)) - .zIndex(1) + .navigationBarHidden(true) } private var mainContent: some View { @@ -152,7 +147,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 } ) @@ -164,13 +158,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) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift index 1ce5d903..28ed7a7b 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/ActivityFeedView.swift @@ -15,7 +15,6 @@ struct ActivityFeedView: View { @State private var activityInPopup: FullFeedActivityDTO? @State private var colorInPopup: Color? @Binding private var selectedTab: TabType - private let horizontalSubHeadingPadding: CGFloat = screenEdgePadding private let bottomSubHeadingPadding: CGFloat = 14 @State private var showFullActivitiesList: Bool = false @Environment(\.dismiss) private var dismiss @@ -64,7 +63,7 @@ struct ActivityFeedView: View { HeaderView(user: user) .padding(.bottom, 30) .padding(.top, 60) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Spawn In! row HStack { @@ -75,7 +74,7 @@ struct ActivityFeedView: View { seeAllActivityTypesButton } .padding(.bottom, 20) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activity Types row activityTypeListView @@ -89,7 +88,7 @@ struct ActivityFeedView: View { } ) .padding(.bottom, 30) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activities in Your Area row HStack { @@ -100,7 +99,7 @@ struct ActivityFeedView: View { seeAllActivitiesButton } .padding(.bottom, 14) - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) // Activities - no container padding, cards will handle their own ActivityListView( diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift index e6aa971c..39f80cfb 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/FeedAndMap/FullscreenActivityListView.swift @@ -34,7 +34,7 @@ struct FullscreenActivityListView: View { .font(.system(size: 20, weight: .semibold)) .foregroundColor(.clear) } - .padding(.horizontal, screenEdgePadding) + .padding(.horizontal, screenEdgePadding()) .padding(.vertical, 12) ActivityListView(viewModel: viewModel, user: user, callback: callback) diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift index ec3f8ac5..d7e52d90 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendSearchView.swift @@ -386,7 +386,7 @@ struct FriendRowView: View { shareProfile: { shareProfile(for: userForProfile) } ) .background(universalBackgroundColor) - .presentationDetents([.height(364)]) + .presentationDetents([.height(520)]) } .alert("Remove Friend", isPresented: $showRemoveFriendConfirmation) { Button("Cancel", role: .cancel) {} diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift index 426c94ca..25fd108a 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/FriendsTab/FriendsTabView.swift @@ -138,7 +138,7 @@ struct FriendsTabView: View { navigateToProfile: { navigateToProfile = true } ) .background(universalBackgroundColor) - .presentationDetents([.height(420)]) + .presentationDetents([.height(580)]) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift index 608366ec..3097a001 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Friends/Shared/FriendsTabMenuView.swift @@ -19,20 +19,12 @@ struct FriendsTabMenuView: View { @Environment(\.dismiss) private var dismiss @State private var isLoading: Bool = true - private var firstName: String { - if let name = user.name, !name.isEmpty { - return name.components(separatedBy: " ").first ?? user.username ?? "User" - } - return user.username ?? "User" - } - var body: some View { - FriendsTabMenuContainer { + SheetMenuContainer { if isLoading { loadingContent } else { FriendsTabMenuContent( - user: user, showReportDialog: $showReportDialog, showBlockDialog: $showBlockDialog, showRemoveFriendConfirmation: $showRemoveFriendConfirmation, @@ -53,57 +45,12 @@ struct FriendsTabMenuView: View { } private var loadingContent: some View { - VStack(spacing: 16) { - ForEach(0..<4) { _ in - HStack { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 20) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - Divider() - - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - .background(universalBackgroundColor) - .redacted(reason: .placeholder) - .shimmering() - } -} - -// Container view that provides the background and layout -private struct FriendsTabMenuContainer: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(spacing: 8) { - content - .background(universalBackgroundColor) - .cornerRadius(12) - } - .fixedSize(horizontal: false, vertical: true) - .background(universalBackgroundColor) + SheetMenuLoadingPlaceholder(rowCount: 4, dismiss: dismiss) } } // Content view that contains the actual menu items private struct FriendsTabMenuContent: View { - let user: Nameable @Binding var showReportDialog: Bool @Binding var showBlockDialog: Bool @Binding var showRemoveFriendConfirmation: Bool @@ -113,15 +60,8 @@ private struct FriendsTabMenuContent: View { let navigateToProfile: () -> Void let dismiss: DismissAction - private var firstName: String { - if let name = user.name, !name.isEmpty { - return name.components(separatedBy: " ").first ?? user.username ?? "User" - } - return user.username ?? "User" - } - var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { menuItems .background(universalBackgroundColor) @@ -132,7 +72,7 @@ private struct FriendsTabMenuContent: View { private var menuItems: some View { VStack(spacing: 0) { - menuItem( + SheetMenuRow( icon: "person.crop.circle", text: "View Profile", color: universalAccentColor @@ -144,7 +84,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "tag", text: "Add to Activity Type", color: universalAccentColor @@ -156,7 +96,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "link", text: "Copy profile URL", color: universalAccentColor @@ -168,7 +108,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "square.and.arrow.up", text: "Share this Profile", color: universalAccentColor @@ -180,7 +120,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "exclamationmark.triangle", text: "Report", color: .red @@ -192,7 +132,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "hand.raised.slash", text: "Block", color: .red @@ -204,7 +144,7 @@ private struct FriendsTabMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "person.badge.minus", text: "Remove Friend", color: .red @@ -218,36 +158,7 @@ private struct FriendsTabMenuContent: View { } private var cancelButton: some View { - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - - private func menuItem( - icon: String, - text: String, - color: Color, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - - Text(text) - .foregroundColor(color) - - Spacer() - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - } + SheetMenuCancelRow(dismiss: dismiss) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/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 ) 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/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 { diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift index fea4fdaf..32060558 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/Shared/ProfileMenuView.swift @@ -20,12 +20,11 @@ struct ProfileMenuView: View { @State private var isLoading: Bool = true var body: some View { - ProfileMenuContainer { + SheetMenuContainer { if isLoading { loadingContent } else { ProfileMenuContent( - user: user, showRemoveFriendConfirmation: $showRemoveFriendConfirmation, showReportDialog: $showReportDialog, showBlockDialog: $showBlockDialog, @@ -47,57 +46,12 @@ struct ProfileMenuView: View { } private var loadingContent: some View { - VStack(spacing: 16) { - ForEach(0..<5) { _ in - HStack { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 20) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - Divider() - - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - .background(universalBackgroundColor) - .redacted(reason: .placeholder) - .shimmering() - } -} - -// Container view that provides the background and layout -private struct ProfileMenuContainer: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(spacing: 8) { - content - .background(universalBackgroundColor) - .cornerRadius(12) - } - .fixedSize(horizontal: false, vertical: true) - .background(universalBackgroundColor) + SheetMenuLoadingPlaceholder(rowCount: 5, dismiss: dismiss) } } // Content view that contains the actual menu items private struct ProfileMenuContent: View { - let user: Nameable @Binding var showRemoveFriendConfirmation: Bool @Binding var showReportDialog: Bool @Binding var showBlockDialog: Bool @@ -108,7 +62,7 @@ private struct ProfileMenuContent: View { let dismiss: DismissAction var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { menuItems .background(universalBackgroundColor) @@ -121,7 +75,7 @@ private struct ProfileMenuContent: View { VStack(spacing: 0) { // Only show "Add to Activity Type" for friends if isFriend { - menuItem( + SheetMenuRow( icon: "tag", text: "Add to Activity Type", color: universalAccentColor @@ -134,7 +88,7 @@ private struct ProfileMenuContent: View { Divider() } - menuItem( + SheetMenuRow( icon: "link", text: "Copy profile URL", color: universalAccentColor @@ -146,7 +100,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "square.and.arrow.up", text: "Share this Profile", color: universalAccentColor @@ -158,7 +112,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "exclamationmark.triangle", text: "Report user", color: .red @@ -170,7 +124,7 @@ private struct ProfileMenuContent: View { Divider() - menuItem( + SheetMenuRow( icon: "hand.raised.slash", text: "Block user", color: .red @@ -183,7 +137,7 @@ private struct ProfileMenuContent: View { if isFriend { Divider() - menuItem( + SheetMenuRow( icon: "person.badge.minus", text: "Remove Friend", color: .red @@ -198,36 +152,7 @@ private struct ProfileMenuContent: View { } private var cancelButton: some View { - Button(action: { dismiss() }) { - Text("Cancel") - .font(.headline) - .foregroundColor(universalAccentColor) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .background(universalBackgroundColor) - .cornerRadius(12) - } - - private func menuItem( - icon: String, - text: String, - color: Color, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .foregroundColor(color) - - Text(text) - .foregroundColor(color) - - Spacer() - } - .padding(.vertical, 16) - .padding(.horizontal, 16) - } + SheetMenuCancelRow(dismiss: dismiss) } } diff --git a/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift b/Spawn-App-iOS-SwiftUI/Views/Pages/Profile/UserProfile/UserProfileView.swift index 32e9becd..ec10888d 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 @@ -520,7 +520,9 @@ struct UserProfileView: View { shareProfile: shareProfile ) .background(universalBackgroundColor) - .presentationDetents([.height(profileViewModel.friendshipStatus == .friends ? 364 : 276)]) + .presentationDetents([ + .height(profileViewModel.friendshipStatus == .friends ? 520 : 380) + ]) } private var removeFriendConfirmationAlert: some View { diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift index c65f8412..c7c26c04 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/AnimatedActionButton.swift @@ -33,8 +33,8 @@ enum FriendActionButtonStyle { var activeColor: Color { switch self { - case .accept: return .white - case .remove, .cancel, .add: return figmaGreen + case .accept, .add: return .white + case .remove, .cancel: return figmaGreen } } @@ -42,11 +42,11 @@ enum FriendActionButtonStyle { return { isActive in switch self { case .accept: - return isActive ? Color(hex: colorsIndigo800) : universalSecondaryColor + return universalSecondaryColor case .remove, .cancel: return Color.clear case .add: - return Color.clear + return isActive ? universalSecondaryColor : Color.clear } } } @@ -57,7 +57,7 @@ enum FriendActionButtonStyle { case .accept: return Color.clear case .add: - return isActive ? figmaGreen : universalSecondaryTextColor + return isActive ? Color.clear : universalSecondaryTextColor case .remove, .cancel: return isActive ? figmaGreen : universalSecondaryTextColor } @@ -74,7 +74,7 @@ enum FriendActionButtonStyle { } /// Animated action button with consistent behavior across friend-related actions -/// - Shows checkmark animation +/// - Shows checkmark animation (except for cancel style) /// - Fades out smoothly /// - Calls completion handler after animation struct AnimatedActionButton: View { @@ -130,7 +130,7 @@ struct AnimatedActionButton: View { } }) { HStack(spacing: 6) { - if isActive { + if isActive && style != .cancel { Image(systemName: "checkmark") .foregroundColor(style.activeColor) .font(.system(size: 14, weight: style == .add ? .regular : .semibold)) diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift new file mode 100644 index 00000000..18c8f774 --- /dev/null +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/SheetMenuComponents.swift @@ -0,0 +1,115 @@ +// +// SheetMenuComponents.swift +// Spawn-App-iOS-SwiftUI +// +// Shared building blocks for profile/friends sheet-style action menus. +// + +import SwiftUI + +/// Container for sheet-style menus with rounded corners and fixed vertical sizing. +struct SheetMenuContainer: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(spacing: 8) { + content + .background(universalBackgroundColor) + .cornerRadius(12) + } + .fixedSize(horizontal: false, vertical: true) + .background(universalBackgroundColor) + } +} + +/// Single row in a sheet menu (icon + label, leading-aligned). +struct SheetMenuRow: View { + let icon: String + let text: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(color) + + Text(text) + .font(.system(size: 17, weight: .medium)) + .foregroundColor(color) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 63, alignment: .leading) + } + } +} + +/// Cancel row with leading xmark, matching sheet menu styling. +struct SheetMenuCancelRow: View { + let dismiss: DismissAction + + var body: some View { + Button(action: { dismiss() }) { + HStack(spacing: 10) { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(universalAccentColor) + + Text("Cancel") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(universalAccentColor) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 63, alignment: .leading) + } + .background(universalBackgroundColor) + .cornerRadius(12) + } +} + +/// Placeholder shimmer while menu content loads. +struct SheetMenuLoadingPlaceholder: View { + let rowCount: Int + let dismiss: DismissAction + + var body: some View { + VStack(spacing: 16) { + ForEach(0.. Void - init(title: String? = nil, action: @escaping () -> Void) { + init(title: String? = nil, foregroundColor: Color = universalAccentColor, action: @escaping () -> Void) { self.title = title + self.foregroundColor = foregroundColor self.action = action } @@ -30,7 +32,7 @@ struct UnifiedBackButton: View { .font(.system(size: 17)) } } - .foregroundColor(universalAccentColor) + .foregroundColor(foregroundColor) } .buttonStyle(PlainButtonStyle()) } diff --git a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedButton.swift b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedButton.swift index 4fa5628f..0c3de3b6 100644 --- a/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedButton.swift +++ b/Spawn-App-iOS-SwiftUI/Views/Shared/UI/UnifiedButton.swift @@ -175,21 +175,13 @@ extension UnifiedButton { @available(iOS 17, *) #Preview { VStack(spacing: 20) { - UnifiedButton.primary("Primary Button") { - print("Primary tapped") - } + UnifiedButton.primary("Primary Button") {} - UnifiedButton.secondary("Secondary Button") { - print("Secondary tapped") - } + UnifiedButton.secondary("Secondary Button") {} - UnifiedButton.outline("Outline Button") { - print("Outline tapped") - } + UnifiedButton.outline("Outline Button") {} - UnifiedButton.primary("Disabled Button", isEnabled: false) { - print("Should not print") - } + UnifiedButton.primary("Disabled Button", isEnabled: false) {} } .padding() .background(universalBackgroundColor)