diff --git a/.DS_Store b/.DS_Store index 135b76d..8810206 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 9223a7d..c35b5a9 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -113,7 +113,6 @@ 2E8A5A292DBCC82E00B1F281 /* TinyHTTPServer in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */; }; 2E8A5A5D2DBCC87500B1F281 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */; }; 2E8A5A5F2DBCC87500B1F281 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */; }; - 2E8A5A622DBCC87F00B1F281 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A612DBCC87F00B1F281 /* Flow */; }; 2E8A5A652DBCC8A100B1F281 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5A642DBCC8A100B1F281 /* Kingfisher */; }; 2E8A5A662DBCCB0400B1F281 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C191D1B2CFD196C0001D2E0 /* Notification.swift */; }; 2E8A5A672DBCCB0900B1F281 /* Chat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0961AFD2D6E47F200DCC293 /* Chat.swift */; }; @@ -147,7 +146,7 @@ 2E8A5AAE2DBCD16500B1F281 /* FirebaseStorageCombine-Community in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */; }; 2E8A5AB02DBCD16500B1F281 /* FirebaseVertexAI in Frameworks */ = {isa = PBXBuildFile; productRef = 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */; }; 2E8A5AB12DBCD68200B1F281 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D051D8162D7E2E0500C089AF /* GoogleService-Info.plist */; }; - 2E8A5AB22DBCD68700B1F281 /* resell-service.json in Resources */ = {isa = PBXBuildFile; fileRef = D0B4A25F2DA7184C00A1722C /* resell-service.json */; }; + 2E8A5AB22DBCD68700B1F281 /* (null) in Resources */ = {isa = PBXBuildFile; }; 2E8A5AB52DBD5B4300B1F281 /* SavedRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8A5AB42DBD5B3200B1F281 /* SavedRow.swift */; }; 2E8C3D972DBD8A8B0074BFAB /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1CE75D2CF6D04F00D38C25 /* NotificationsView.swift */; }; 2E8C3D992DBEE07B0074BFAB /* DetailedFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8C3D982DBEE06E0074BFAB /* DetailedFilterView.swift */; }; @@ -155,23 +154,10 @@ 2EBB64182D8B783800CCAC48 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBB64172D8B783600CCAC48 /* Filter.swift */; }; 2ECB2F652E749ADD00CAACA2 /* ForYouView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ECB2F642E749AD900CAACA2 /* ForYouView.swift */; }; 2ECB2F672E74E03700CAACA2 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ECB2F662E74E02F00CAACA2 /* SearchViewModel.swift */; }; - D037B9E62D7E308C00EF3024 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9E52D7E308C00EF3024 /* FirebaseAnalytics */; }; - D037B9E82D7E308C00EF3024 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9E72D7E308C00EF3024 /* FirebaseAnalyticsOnDeviceConversion */; }; - D037B9EA2D7E308C00EF3024 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9E92D7E308C00EF3024 /* FirebaseAnalyticsWithoutAdIdSupport */; }; - D037B9EC2D7E308C00EF3024 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9EB2D7E308C00EF3024 /* FirebaseAppCheck */; }; - D037B9EE2D7E308C00EF3024 /* FirebaseAppDistribution-Beta in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9ED2D7E308C00EF3024 /* FirebaseAppDistribution-Beta */; }; - D037B9F02D7E313100EF3024 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9EF2D7E313100EF3024 /* FirebaseMessaging */; }; - D037B9F22D7E314D00EF3024 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9F12D7E314D00EF3024 /* FirebaseFirestore */; }; - D037B9F42D7E317700EF3024 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D037B9F32D7E317700EF3024 /* FirebaseAuth */; }; - D043ED6D2D70CBEB00389DC1 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = D043ED6C2D70CBEB00389DC1 /* GoogleSignIn */; }; - D043ED6F2D70CBEB00389DC1 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = D043ED6E2D70CBEB00389DC1 /* GoogleSignInSwift */; }; + 2EEAAB2B2F1B07B20006FF5C /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 2EEAAB2A2F1B07B20006FF5C /* Flow */; }; + C6B37F592E970D7700A564DB /* FiltersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B37F582E970D7700A564DB /* FiltersViewModel.swift */; }; D051D8172D7E2E0500C089AF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D051D8162D7E2E0500C089AF /* GoogleService-Info.plist */; }; - D0961AF82D6E28D600DCC293 /* MessageDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0961AF72D6E28D300DCC293 /* MessageDocument.swift */; }; - D0961AFC2D6E42D500DCC293 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0961AFB2D6E42D100DCC293 /* Message.swift */; }; - D0961AFE2D6E47F500DCC293 /* Chat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0961AFD2D6E47F200DCC293 /* Chat.swift */; }; D0A25DEE2E5804A900607E1F /* EmptyStateModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A25DED2E5804A900607E1F /* EmptyStateModifier.swift */; }; - D0DAEF292D6F607300641151 /* MessageCluster.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DAEF282D6F606C00641151 /* MessageCluster.swift */; }; - D0DAEF2B2D6FF48800641151 /* MessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DAEF2A2D6FF47E00641151 /* MessagesViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -304,8 +290,8 @@ 2EBB64172D8B783600CCAC48 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; 2ECB2F642E749AD900CAACA2 /* ForYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForYouView.swift; sourceTree = ""; }; 2ECB2F662E74E02F00CAACA2 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + C6B37F582E970D7700A564DB /* FiltersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersViewModel.swift; sourceTree = ""; }; D051D8162D7E2E0500C089AF /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; - D063A0182DBC268700F17A9C /* Untitled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Untitled.swift; sourceTree = ""; }; D0961AF72D6E28D300DCC293 /* MessageDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDocument.swift; sourceTree = ""; }; D0961AFB2D6E42D100DCC293 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; D0961AFD2D6E47F200DCC293 /* Chat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chat.swift; sourceTree = ""; }; @@ -327,6 +313,7 @@ 2E8A5A922DBCD16500B1F281 /* FirebaseCrashlytics in Frameworks */, 2E8A5A5D2DBCC87500B1F281 /* GoogleSignIn in Frameworks */, 2E8A5AA82DBCD16500B1F281 /* FirebasePerformance in Frameworks */, + 2EEAAB2B2F1B07B20006FF5C /* Flow in Frameworks */, 2E8A5A982DBCD16500B1F281 /* FirebaseFirestore in Frameworks */, 2E8A5A292DBCC82E00B1F281 /* TinyHTTPServer in Frameworks */, 2E8A5A842DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion in Frameworks */, @@ -343,7 +330,6 @@ 2CF3CC7C2D017897001B90B5 /* OAuth2 in Frameworks */, 2E8A5A5F2DBCC87500B1F281 /* GoogleSignInSwift in Frameworks */, 2E8A5AAE2DBCD16500B1F281 /* FirebaseStorageCombine-Community in Frameworks */, - 2E8A5A622DBCC87F00B1F281 /* Flow in Frameworks */, 2E8A5A652DBCC8A100B1F281 /* Kingfisher in Frameworks */, 2CF3CC7A2D017897001B90B5 /* OAuth1 in Frameworks */, 2E8A5A9E2DBCD16500B1F281 /* FirebaseFunctionsCombine-Community in Frameworks */, @@ -652,6 +638,7 @@ 2C525B802CB1F195007D5B8E /* SendFeedbackViewModel.swift */, 2C18FFE92CA1E4C900564577 /* SettingsViewModel.swift */, 2C4DD97C2C98D45B0055D0AB /* SetupProfileViewModel.swift */, + C6B37F582E970D7700A564DB /* FiltersViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -735,7 +722,6 @@ 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */, 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */, 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */, - 2E8A5A612DBCC87F00B1F281 /* Flow */, 2E8A5A642DBCC8A100B1F281 /* Kingfisher */, 2E8A5A812DBCD16500B1F281 /* FirebaseAnalytics */, 2E8A5A832DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion */, @@ -761,6 +747,7 @@ 2E8A5AAB2DBCD16500B1F281 /* FirebaseStorage */, 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */, 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */, + 2EEAAB2A2F1B07B20006FF5C /* Flow */, ); productName = Resell; productReference = 2C9B4CC72C8FB7B70029DF61 /* Resell.app */; @@ -837,9 +824,9 @@ packageReferences = ( 2E8A5A212DBCC82E00B1F281 /* XCRemoteSwiftPackageReference "google-auth-library-swift" */, 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, - 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 2EEAAB292F1B07B20006FF5C /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, ); productRefGroup = 2C9B4CC82C8FB7B70029DF61 /* Products */; projectDirPath = ""; @@ -857,7 +844,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2E8A5AB22DBCD68700B1F281 /* resell-service.json in Resources */, + 2E8A5AB22DBCD68700B1F281 /* (null) in Resources */, D051D8172D7E2E0500C089AF /* GoogleService-Info.plist in Resources */, 2C9EAF702CF26DA00010A44C /* Rubik-Regular.ttf in Resources */, 2C9B4D0C2C90EF1D0029DF61 /* Launch Screen.storyboard in Resources */, @@ -915,6 +902,7 @@ 2C9337542C935C9500818C8E /* ChatsView.swift in Sources */, 2E8A5AB52DBD5B4300B1F281 /* SavedRow.swift in Sources */, 2CD7CAB92CE937B10056209E /* Listing.swift in Sources */, + C6B37F592E970D7700A564DB /* FiltersViewModel.swift in Sources */, 2CD6CA8C2CB48286005A4F78 /* PopupModal.swift in Sources */, 2CF3561F2CDE93E00045A173 /* EditProfileViewModel.swift in Sources */, 2C02B3992CC040AE0020DF90 /* PriceInputView.swift in Sources */, @@ -1351,14 +1339,6 @@ minimumVersion = 8.0.0; }; }; - 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tevelee/SwiftUI-Flow.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.0.2; - }; - }; 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; @@ -1375,6 +1355,14 @@ minimumVersion = 11.12.0; }; }; + 2EEAAB292F1B07B20006FF5C /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tevelee/SwiftUI-Flow.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1424,11 +1412,6 @@ package = 2E8A5A5B2DBCC87500B1F281 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; productName = GoogleSignInSwift; }; - 2E8A5A612DBCC87F00B1F281 /* Flow */ = { - isa = XCSwiftPackageProductDependency; - package = 2E8A5A602DBCC87F00B1F281 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; - productName = Flow; - }; 2E8A5A642DBCC8A100B1F281 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = 2E8A5A632DBCC8A100B1F281 /* XCRemoteSwiftPackageReference "Kingfisher" */; @@ -1554,6 +1537,11 @@ package = 2E8A5A802DBCD16500B1F281 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseVertexAI; }; + 2EEAAB2A2F1B07B20006FF5C /* Flow */ = { + isa = XCSwiftPackageProductDependency; + package = 2EEAAB292F1B07B20006FF5C /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; + productName = Flow; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2C9B4CBF2C8FB7B70029DF61 /* Project object */; diff --git a/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cfc3f87..dbc032e 100644 --- a/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Resell.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3040011e224f4c14fbfa4512ed2b36ea1e7850c5437565215d601b5013c92c28", + "originHash" : "90964673195459f91f77de033ac190c815b120d6e627e83fa2dc832ed4539609", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tevelee/SwiftUI-Flow.git", "state" : { - "revision" : "fd755bc852c738d3b726c6a28fc4640c9a74876f", - "version" : "3.0.2" + "revision" : "d227f999b2894ab737ef5786d9b14d02d3e5362e", + "version" : "3.1.1" } } ], diff --git a/Resell/.DS_Store b/Resell/.DS_Store index efa7d13..b513c9b 100644 Binary files a/Resell/.DS_Store and b/Resell/.DS_Store differ diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index f4e4cb1..094929a 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -9,6 +9,9 @@ import Combine import Foundation import os +/// Empty body for POST requests that don't require a body +struct EmptyBody: Codable {} + class NetworkManager: APIClient { // MARK: - Singleton Instance @@ -21,9 +24,23 @@ class NetworkManager: APIClient { // MARK: - Properties - private let hostURL: String = Keys.devServerURL + private let hostURL: String = Keys.localServerURL private let maxAttempts = 2 + /// ISO8601 decoder for date parsing + private lazy var iso8601Decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + /// ISO8601 encoder for date formatting + private lazy var iso8601Encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + // MARK: - Init private init() { } @@ -480,13 +497,112 @@ class NetworkManager: APIClient { return try await post(url: url, body: image) } + // MARK: - Notifications Networking Functions - // func createNotif(notifBody: Notification) async throws -> ListingResponse { - // let url = try constructURL(endpoint: "/notif/") - // - // return try await post(url: url, body: notifBody) - // } + /// Custom GET for notifications with ISO8601 date decoding + private func getNotifications(url: URL, attempt: Int = 1) async throws -> [Notifications] { + let request = try createRequest(url: url, method: "GET") + + let (data, response) = try await URLSession.shared.data(for: request) + + do { + try handleResponse(data: data, response: response) + } catch { + return try await handleNetworkError(error, attempt: attempt) { + try await getNotifications(url: url, attempt: attempt + 1) + } + } + + + // Try decoding as array first (direct response) + if let notifications = try? iso8601Decoder.decode([Notifications].self, from: data) { + return notifications + } + + // Try decoding as wrapped response { "notifications": [...] } + if let wrapped = try? iso8601Decoder.decode(NotificationsResponse.self, from: data) { + return wrapped.notifications + } + + // If both fail, throw the actual decoding error for debugging + return try iso8601Decoder.decode([Notifications].self, from: data) + } + + /// Custom POST for notifications with ISO8601 date decoding + private func postNotification(url: URL, body: some Encodable, attempt: Int = 1) async throws -> T { + var request = try createRequest(url: url, method: "POST") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try iso8601Encoder.encode(body) + + let (data, response) = try await URLSession.shared.data(for: request) + + do { + try handleResponse(data: data, response: response) + } catch { + return try await handleNetworkError(error, attempt: attempt) { + try await postNotification(url: url, body: body, attempt: attempt + 1) + } + } + + + return try iso8601Decoder.decode(T.self, from: data) + } + + /// Get unread notifications + func getNewNotifications() async throws -> [Notifications] { + let url = try constructURL(endpoint: "/notif/new") + return try await getNotifications(url: url) + } + + /// Get recent notifications (last 10) + func getRecentNotifications() async throws -> [Notifications] { + let url = try constructURL(endpoint: "/notif/recent") + return try await getNotifications(url: url) } + /// Get notifications from last 7 days + func getLast7DaysNotifications() async throws -> [Notifications] { + let url = try constructURL(endpoint: "/notif/last7days") + return try await getNotifications(url: url) + } + + /// Get notifications from last 30 days + func getLast30DaysNotifications() async throws -> [Notifications] { + let url = try constructURL(endpoint: "/notif/last30days") + return try await getNotifications(url: url) + } + + /// Mark a notification as read + func markNotificationAsRead(notificationId: String) async throws -> Notifications { + let url = try constructURL(endpoint: "/notif/read/\(notificationId)") + + var request = try createRequest(url: url, method: "POST") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + + do { + try handleResponse(data: data, response: response) + } catch { + throw error + } + + + // Try different response formats + if let wrapped = try? iso8601Decoder.decode(MarkReadResponse.self, from: data) { + return wrapped.notification + } + + if let wrapped = try? iso8601Decoder.decode(SingleNotificationResponse.self, from: data) { + return wrapped.notification + } + + // Try direct notification + return try iso8601Decoder.decode(Notifications.self, from: data) + } + +} + + diff --git a/Resell/Info.plist b/Resell/Info.plist index 357835f..65f4b97 100644 --- a/Resell/Info.plist +++ b/Resell/Info.plist @@ -40,6 +40,8 @@ $(RESELL_DEV_URL) RESELL_PROD_URL $(RESELL_PROD_URL) + RESELL_LOCAL_URL + $(RESELL_LOCAL_URL) UIAppFonts Rubik-Regular.ttf diff --git a/Resell/Models/MessageCluster.swift b/Resell/Models/MessageCluster.swift index c643ee2..be09573 100644 --- a/Resell/Models/MessageCluster.swift +++ b/Resell/Models/MessageCluster.swift @@ -5,7 +5,7 @@ // Created by Peter Bidoshi on 2/26/25. // -import SwiftUICore +import SwiftUI struct MessageCluster: Equatable { diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index 8fc4380..9faebbe 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -6,16 +6,155 @@ // import Foundation -// Original name Notification overrides Foundation definition... -struct Notifications: Codable { - let userID: String + +struct Notifications: Codable, Identifiable { + let id: String + let userId: String let title: String let body: String let data: NotificationData - var isRead: Bool = false + var read: Bool + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id, userId, title, body, data, read, createdAt, updatedAt + } } struct NotificationData: Codable { - let type: String - let messageId: String + // Type might be at different levels or named differently + var type: String? + var notificationType: String? + + // Optional fields that vary by notification type + var imageUrl: String? + var postId: String? + var postTitle: String? + var chatId: String? + var sellerId: String? + var sellerUsername: String? + var sellerPhotoUrl: String? + var buyerId: String? + var buyerUsername: String? + var transactionId: String? + var price: Double? + + // Legacy field for compatibility + var messageId: String? + + /// Returns the notification type from whichever field contains it + var resolvedType: String { + type ?? notificationType ?? "general" + } + + /// Flexible decoding - handles any JSON object structure + init(from decoder: Decoder) throws { + // Try to decode as a keyed container, but don't fail if keys are missing + let container = try? decoder.container(keyedBy: DynamicCodingKeys.self) + + // Extract known fields if they exist + type = container?.decodeIfPresentString(forKey: "type") + notificationType = container?.decodeIfPresentString(forKey: "notificationType") + imageUrl = container?.decodeIfPresentString(forKey: "imageUrl") + postId = container?.decodeIfPresentString(forKey: "postId") + postTitle = container?.decodeIfPresentString(forKey: "postTitle") + chatId = container?.decodeIfPresentString(forKey: "chatId") + sellerId = container?.decodeIfPresentString(forKey: "sellerId") + sellerUsername = container?.decodeIfPresentString(forKey: "sellerUsername") + sellerPhotoUrl = container?.decodeIfPresentString(forKey: "sellerPhotoUrl") + buyerId = container?.decodeIfPresentString(forKey: "buyerId") + buyerUsername = container?.decodeIfPresentString(forKey: "buyerUsername") + transactionId = container?.decodeIfPresentString(forKey: "transactionId") + messageId = container?.decodeIfPresentString(forKey: "messageId") + + // Try to decode price as Double or String + if let priceDouble = try? container?.decodeIfPresent(Double.self, forKey: DynamicCodingKeys(stringValue: "price")!) { + price = priceDouble + } else if let priceString = container?.decodeIfPresentString(forKey: "price"), + let priceValue = Double(priceString) { + price = priceValue + } else { + price = nil + } + } + + // For creating dummy data + init(type: String?, notificationType: String? = nil, imageUrl: String? = nil, postId: String? = nil, + postTitle: String? = nil, chatId: String? = nil, sellerId: String? = nil, + sellerUsername: String? = nil, sellerPhotoUrl: String? = nil, buyerId: String? = nil, + buyerUsername: String? = nil, transactionId: String? = nil, price: Double? = nil, messageId: String? = nil) { + self.type = type + self.notificationType = notificationType + self.imageUrl = imageUrl + self.postId = postId + self.postTitle = postTitle + self.chatId = chatId + self.sellerId = sellerId + self.sellerUsername = sellerUsername + self.sellerPhotoUrl = sellerPhotoUrl + self.buyerId = buyerId + self.buyerUsername = buyerUsername + self.transactionId = transactionId + self.price = price + self.messageId = messageId + } +} + +// Helper for flexible key decoding +struct DynamicCodingKeys: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} + +extension KeyedDecodingContainer where K == DynamicCodingKeys { + func decodeIfPresentString(forKey key: String) -> String? { + guard let codingKey = DynamicCodingKeys(stringValue: key) else { return nil } + return try? decodeIfPresent(String.self, forKey: codingKey) + } +} + +// MARK: - API Response Wrappers + +struct NotificationsResponse: Codable { + let notifications: [Notifications] +} + +struct SingleNotificationResponse: Codable { + let notification: Notifications + let message: String? +} + +struct MarkReadResponse: Codable { + let message: String + let notification: Notifications +} + +enum NotificationSection: String, CaseIterable, Identifiable { + case new = "New" + case last7 = "Last 7 Days" + case last30 = "Last 30 Days" + case older = "Older" + + var id: String {rawValue} } + +enum LoadState { + case idle + case loading + case success + case empty + case error +} + + diff --git a/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Contents.json b/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Contents.json new file mode 100644 index 0000000..abed00c --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Message Round Icon from Figma.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Message Round Icon from Figma.svg b/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Message Round Icon from Figma.svg new file mode 100644 index 0000000..aa6c56d --- /dev/null +++ b/Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Message Round Icon from Figma.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/Bell Icon from Figma.svg b/Resell/Resources/Assets.xcassets/bell.imageset/Bell Icon from Figma.svg new file mode 100644 index 0000000..73ea424 --- /dev/null +++ b/Resell/Resources/Assets.xcassets/bell.imageset/Bell Icon from Figma.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json b/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json index e7387d0..f1e3ce4 100644 --- a/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json +++ b/Resell/Resources/Assets.xcassets/bell.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "bell.png", + "filename" : "Bell Icon from Figma.svg", "idiom" : "universal", "scale" : "1x" }, diff --git a/Resell/Resources/Assets.xcassets/bell.imageset/bell.png b/Resell/Resources/Assets.xcassets/bell.imageset/bell.png deleted file mode 100644 index 6e2b3ac..0000000 Binary files a/Resell/Resources/Assets.xcassets/bell.imageset/bell.png and /dev/null differ diff --git a/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Contents.json b/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Contents.json new file mode 100644 index 0000000..5e28e0a --- /dev/null +++ b/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Ellipse 28 Resell FA25.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Ellipse 28 Resell FA25.png b/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Ellipse 28 Resell FA25.png new file mode 100644 index 0000000..64e4225 Binary files /dev/null and b/Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Ellipse 28 Resell FA25.png differ diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json b/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json index 6d3f655..62cdc74 100644 --- a/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json +++ b/Resell/Resources/Assets.xcassets/read-notification.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "mage_message-round.png", + "filename" : "read-notification-dot.svg", "idiom" : "universal", "scale" : "1x" }, diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png b/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png deleted file mode 100644 index 5d8153f..0000000 Binary files a/Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png and /dev/null differ diff --git a/Resell/Resources/Assets.xcassets/read-notification.imageset/read-notification-dot.svg b/Resell/Resources/Assets.xcassets/read-notification.imageset/read-notification-dot.svg new file mode 100644 index 0000000..aa6c56d --- /dev/null +++ b/Resell/Resources/Assets.xcassets/read-notification.imageset/read-notification-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resell/Utils/Keys.swift b/Resell/Utils/Keys.swift index 4f5e4f5..65fa75f 100644 --- a/Resell/Utils/Keys.swift +++ b/Resell/Utils/Keys.swift @@ -8,7 +8,7 @@ import Foundation struct Keys { - + static let localServerURL = Keys.mainKeyDict(key: "RESELL_LOCAL_URL") static let devServerURL = Keys.mainKeyDict(key: "RESELL_DEV_URL") static let prodServerURL = Keys.mainKeyDict(key: "RESELL_PROD_URL") static let firebaseURL = Keys.mainKeyDict(key: "FIREBASE_URL") diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index bfa29a4..06f1f74 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -14,76 +14,149 @@ class NotificationsViewModel: ObservableObject { // MARK: - Properties - @Published var selectedTab: String = "All" + @Published var selectedTab: String = "All" { + didSet { recalcLoadState() } + } + + /// Unread notification counts by type @Published var unreadNotifs: [String: Int] = [ - "All": 10, - "Messages": 2, - "Requests": 3, - "Bookmarks": 1, - "Your Listings": 5 - ] - - @Published var notifications: [Notifications] = [ - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "New Message", - body: "You have received a new message from Mateo", - data: NotificationData(type: "messages", messageId: "134841-42b4-4fdd-b074-jkfale") - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Request Received", - body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "1") - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Bookmarked Item", - body: "Your bookmarked item is back in stock", - data: NotificationData(type: "bookmarks", messageId: "2") - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Order Update", - body: "Your listing has been bookmarked", - data: NotificationData(type: "your listings", messageId: "3") - ) + "All": 0, + "Messages": 0, + "Requests": 0, + "Bookmarks": 0, + "Transactions": 0 ] + @Published var notifications: [Notifications] = [] { + didSet { + recalcLoadState() + updateUnreadCounts() + } + } + + @Published var loadState: LoadState = .idle + var filteredNotifications: [Notifications] { if selectedTab == "All" { return notifications } else { - return notifications.filter { $0.data.type.lowercased() == selectedTab.lowercased() } + return notifications.filter { $0.data.resolvedType.lowercased() == selectedTab.lowercased() } } } + + private func recalcLoadState() { + switch loadState { + case .loading, .error: + return + default: break + } + loadState = filteredNotifications.isEmpty ? .empty : .success + } + + private func updateUnreadCounts() { + var counts: [String: Int] = [ + "All": 0, + "Messages": 0, + "Requests": 0, + "Bookmarks": 0, + "Transactions": 0 + ] + + for notification in notifications where !notification.read { + counts["All", default: 0] += 1 + let type = notification.data.resolvedType.capitalized + counts[type, default: 0] += 1 + } + + unreadNotifs = counts + } + + var groupedFilteredNotifications: [NotificationSection: [Notifications]] { + let source = filteredNotifications + let now = Date() + let cal = Calendar.current + + var dict: [NotificationSection: [Notifications]] = [:] + + for noti in source { + let days = cal.dateComponents([.day], from: noti.createdAt, to: now).day ?? 0 + let section: NotificationSection + switch days { + case 0: section = .new + case 1...6: section = .last7 + case 7...29: section = .last30 + default: section = .older + } + dict[section, default: []].append(noti) + } + + for section in dict.keys { + dict[section]?.sort { $0.createdAt > $1.createdAt } + } + return dict + } // MARK: - Functions - /// Mark a notification as read + /// Mark a notification as read (calls backend) func markAsRead(notification: Notifications) { - if let index = notifications.firstIndex(where: { $0.data.messageId == notification.data.messageId}) { - notifications[index].isRead = true + // Update locally immediately for responsive UI + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index].read = true + } + + // Then sync with backend + Task { + do { + let updated = try await NetworkManager.shared.markNotificationAsRead(notificationId: notification.id) + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index] = updated + } + } catch { + NetworkManager.shared.logger.error("Error marking notification as read: \(error.localizedDescription)") + // Local update already applied, so user still sees it as read + } } } - - /// Simulate fetching data + + /// Remove a notification from local state + func removeNotification(notification: Notifications) { + withAnimation { + notifications.removeAll(where: { $0.id == notification.id }) + } + } + + /// Fetch notifications from backend (last 30 days) func fetchNotifications() { - notifications = [ - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "New Message", - body: "You have received a new message from Mateo", - data: NotificationData(type: "messages", messageId: "12345") - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "New Request", - body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "23456") - ) - ] + Task { + loadState = .loading + do { + self.notifications = try await NetworkManager.shared.getLast30DaysNotifications() + loadState = notifications.isEmpty ? .empty : .success + } catch { + NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error.localizedDescription)") + + // Fall back to empty state instead of error - notifications might just not exist yet + self.notifications = [] + loadState = .empty + } + } } + + /// Fetch only new/unread notifications + func fetchNewNotifications() { + Task { + loadState = .loading + do { + self.notifications = try await NetworkManager.shared.getNewNotifications() + loadState = notifications.isEmpty ? .empty : .success + } catch { + NetworkManager.shared.logger.error("Error fetching new notifications: \(error.localizedDescription)") + loadState = .error + } + } + } + } diff --git a/Resell/Views/Home/HomeView.swift b/Resell/Views/Home/HomeView.swift index af34868..d6e1747 100644 --- a/Resell/Views/Home/HomeView.swift +++ b/Resell/Views/Home/HomeView.swift @@ -147,7 +147,13 @@ struct HomeView: View { Button(action: { router.push(.notifications) }, label: { - Icon(image: "bell") + ZStack (alignment: .topTrailing){ + Icon(image: "bell") + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + .offset(x: -1, y: 4) + } }) } .padding(.horizontal, Constants.Spacing.horizontalPadding) diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 0bc35ea..b06f424 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -13,27 +13,109 @@ struct NotificationsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = NotificationsViewModel() + @State private var isNavigating = false + + private let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() - + private func timeAgo(_ date: Date) -> String { + relativeFormatter.localizedString(for: date, relativeTo: Date()) + } + var body: some View { VStack { filtersView .padding(.leading, 15) - Text("New") - .font(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 30) - .padding(.vertical, 10) - List(viewModel.filteredNotifications, id: \.data.messageId) { notification in - notificationView(for: notification) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) + .zIndex(1) + + switch viewModel.loadState { + case .idle: + VStack(spacing: 16) { + ProgressView() + Text("Loading notifications...") + .font(.subheadline) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .offset(y: -60) + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .offset(y: -60) + case .success: + List { + ForEach(NotificationSection.allCases) { section in + if let items = viewModel.groupedFilteredNotifications[section], !items.isEmpty { + Text(section.rawValue) + .font(.custom("Rubik-Medium", size: 18)) + .foregroundColor(.primary) + .textCase(nil) + .padding(.leading, 8) + .padding(.top, 5) + .listRowSeparator(.hidden) + ForEach(items) { notification in + notificationView(for: notification) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + } + } + } + .listStyle(.plain) + .listRowSeparator(.hidden) + .refreshable { + viewModel.fetchNotifications() + } + case .empty: + VStack (alignment: .center, spacing: 16) { + Text("You're all caught up!") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + Text("No new notifications right now") + .font(.custom("Rubik", size: 18)) + .foregroundStyle(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .offset(y: -60) + case .error: + VStack (alignment: .center, spacing: 16) { + Text("Something went wrong!") + .font(.custom("Rubik-Medium", size: 22)) + .foregroundStyle(.black) + Text("Please try again. If this problem persists, feel free to let us know") + .font(.custom("Rubik", size: 18)) + .foregroundStyle(.gray) + + Button("Retry") { + viewModel.fetchNotifications() + } + .buttonStyle(.borderedProminent) + .tint(Constants.Colors.resellPurple) + } + .frame(maxWidth: 312, maxHeight: .infinity, alignment: .center) + .offset(y: -60) } - .listStyle(PlainListStyle()) } .padding(.top, 5) .padding(.vertical, 1) .navigationTitle("Notifications") + .onAppear { + viewModel.fetchNotifications() + } + .overlay { + if isNavigating { + Color.black.opacity(0.3) + .ignoresSafeArea() + .overlay { + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + } } // Creates the filter for notifications sorting @@ -48,6 +130,7 @@ struct NotificationsView: View { } } .padding(.top, 20) + .padding(.bottom, 1) } .padding(.leading, 15) } @@ -56,54 +139,176 @@ struct NotificationsView: View { // Creates individual notification rows / components private func notificationView(for notification: Notifications) -> some View { HStack(alignment: .top) { - Image("justin_long") - .resizable() - .frame(width: 56, height: 56) - .cornerRadius(5) + // Use notification image if available, otherwise placeholder + AsyncImage(url: URL(string: notification.data.imageUrl ?? notification.data.sellerPhotoUrl ?? "")) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Image(systemName: notificationIcon(for: notification.data.resolvedType)) + .font(.system(size: 24)) + .foregroundColor(Constants.Colors.resellPurple) + .frame(width: 56, height: 56) + .background(Constants.Colors.wash) + } + .frame(width: 56, height: 56) + .cornerRadius(5) + .clipped() VStack(alignment: .leading) { Spacer() notifText(for: notification) .font(.system(size: 14)) - Text("5 days ago") + Text(timeAgo(notification.createdAt)) .font(.footnote) .foregroundColor(.gray) Spacer() } - .padding(.leading, 20) + .padding(.leading, 10) Spacer() } - .padding(15) - .padding(.horizontal, 15) - .background(notification.isRead ? Color.white : Color.purple.opacity(0.1)) - .swipeActions(edge: .leading) { + .padding(12) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.markAsRead(notification: notification) + // Navigate based on notification type + handleNotificationTap(notification) + } + .listRowBackground( + (notification.read ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) + ) + .swipeActions(edge: .leading, allowsFullSwipe: true) { Button(action: { viewModel.markAsRead(notification: notification) }) { Image("read-notification") } - .tint(Color.purple.opacity(0.7)) + .tint(Constants.Colors.resellPurple.opacity(0.7)) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive, action: { + viewModel.removeNotification(notification: notification) + }) { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .medium)) + } + .tint(.red) + } + } + + private func notificationIcon(for type: String) -> String { + switch type.lowercased() { + case "messages": return "message.fill" + case "requests": return "list.bullet.rectangle" + case "bookmarks": return "bookmark.fill" + case "transactions": return "creditcard.fill" + default: return "bell.fill" + } + } + + private func handleNotificationTap(_ notification: Notifications) { + Task { + isNavigating = true + defer { isNavigating = false } + + switch notification.data.resolvedType.lowercased() { + case "messages": + await navigateToChat(notification: notification) + case "bookmarks", "transactions": + await navigateToPost(notification: notification) + case "requests": + // TODO: Navigate to requests tab when implemented + break + default: + break + } + } + } + + @MainActor + private func navigateToChat(notification: Notifications) async { + guard let postId = notification.data.postId, + let sellerId = notification.data.sellerId, + let buyerId = notification.data.buyerId else { + // Fallback: if we have postId, at least navigate to the post + if notification.data.postId != nil { + await navigateToPost(notification: notification) + } + return + } + + do { + // Fetch the post and users needed for ChatInfo + async let postResponse = NetworkManager.shared.getPostByID(id: postId) + async let buyerResponse = NetworkManager.shared.getUserByID(id: buyerId) + async let sellerResponse = NetworkManager.shared.getUserByID(id: sellerId) + + let (postRes, buyerRes, sellerRes) = try await (postResponse, buyerResponse, sellerResponse) + + guard let post = postRes.post else { + return + } + + let chatInfo = ChatInfo(listing: post, buyer: buyerRes.user, seller: sellerRes.user) + router.push(.messages(chatInfo: chatInfo)) + } catch { + // Fallback to post details if available + await navigateToPost(notification: notification) + } + } + + @MainActor + private func navigateToPost(notification: Notifications) async { + guard let postId = notification.data.postId else { + return + } + + // Check if it looks like a valid UUID (basic check) + guard postId.count > 10 && !postId.hasPrefix("test-") else { + return + } + + do { + let response = try await NetworkManager.shared.getPostByID(id: postId) + guard let post = response.post else { + return + } + router.push(.productDetails(post)) + } catch { + // Silently fail - post may have been deleted } } private func notifText(for notification: Notifications) -> some View { - switch notification.data.type { - case "message": - return Text(notification.userID).bold() + Text(" sent you a message") + // Use the notification body directly from the API + // The backend already formats nice messages like "Test User sent you a message about 'iPhone 13 Pro'" + switch notification.data.resolvedType.lowercased() { + case "messages": + if let sellerUsername = notification.data.sellerUsername, + let postTitle = notification.data.postTitle { + return Text(sellerUsername).bold() + Text(" sent you a message about ") + Text("'\(postTitle)'").bold() + } + return Text(notification.body) case "requests": - return Text("Your request for ") - + Text(notification.data.messageId).bold() - + Text(" has been met") + if let postTitle = notification.data.postTitle { + return Text("Your request for ") + Text(postTitle).bold() + Text(" has been met") + } + return Text(notification.body) case "bookmarks": - return Text("\(notification.userID) discounted ") - + Text(notification.data.messageId).bold() - case "your listings": - return Text("\(notification.userID) bookmarked ") - + Text(notification.data.messageId).bold() + if let sellerUsername = notification.data.sellerUsername, + let postTitle = notification.data.postTitle { + return Text(sellerUsername).bold() + Text(" discounted ") + Text(postTitle).bold() + } + return Text(notification.body) + case "transactions": + if let postTitle = notification.data.postTitle { + return Text("Transaction update for ") + Text(postTitle).bold() + } + return Text(notification.body) default: - return Text(notification.title) + return Text(notification.body) } } - }