From 3721eee91991e0ab293912dc46fdd8389a2e10ed Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 8 Oct 2025 16:38:58 -0400 Subject: [PATCH 01/17] Refactored dummy data for networking prep. --- Resell/Models/Notification.swift | 40 +++++++++++++++++++ .../ViewModels/NotificationsViewModel.swift | 35 ++++------------ 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index 8fc4380..154f856 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -13,9 +13,49 @@ struct Notifications: Codable { let body: String let data: NotificationData var isRead: Bool = false + let createdAt: Date + let updatedAt: Date } struct NotificationData: Codable { let type: String let messageId: String } + + +extension Notifications { + static let dummydata: [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"), + createdAt: Date(), + updatedAt: Date() + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Request Received", + body: "You have a new request from Angelina", + data: NotificationData(type: "requests", messageId: "1"), + createdAt: Date(), + updatedAt: Date() + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Bookmarked Item", + body: "Your bookmarked item is back in stock", + data: NotificationData(type: "bookmarks", messageId: "2"), + createdAt: Date(), + updatedAt: Date() + ), + Notifications( + userID: "381527oef-42b4-4fdd-b074-dfwbejko229", + title: "Order Update", + body: "Your listing has been bookmarked", + data: NotificationData(type: "your listings", messageId: "3"), + createdAt: Date(), + updatedAt: Date() + ) + ] +} diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index bfa29a4..1636238 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -23,32 +23,7 @@ class NotificationsViewModel: ObservableObject { "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") - ) - ] + @Published var notifications: [Notifications] = Notifications.dummydata var filteredNotifications: [Notifications] { if selectedTab == "All" { @@ -74,13 +49,17 @@ class NotificationsViewModel: ObservableObject { userID: "381527oef-42b4-4fdd-b074-dfwbejko229", title: "New Message", body: "You have received a new message from Mateo", - data: NotificationData(type: "messages", messageId: "12345") + data: NotificationData(type: "messages", messageId: "12345"), + createdAt: Date(), + updatedAt: Date() ), Notifications( userID: "381527oef-42b4-4fdd-b074-dfwbejko229", title: "New Request", body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "23456") + data: NotificationData(type: "requests", messageId: "23456"), + createdAt: Date(), + updatedAt: Date() ) ] } From 8198728a125bb5427ed6c0a9fcf683639b6be435 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 8 Oct 2025 19:30:54 -0400 Subject: [PATCH 02/17] Implemented networking to fetch notifications. --- .DS_Store | Bin 10244 -> 10244 bytes Resell.xcodeproj/project.pbxproj | 24 +++++------------- Resell/.DS_Store | Bin 10244 -> 10244 bytes Resell/API/NetworkManager.swift | 8 ++++++ Resell/Models/Notification.swift | 4 +++ .../ViewModels/NotificationsViewModel.swift | 15 ++++++++++- Resell/Views/Home/NotificationsView.swift | 5 +++- 7 files changed, 36 insertions(+), 20 deletions(-) diff --git a/.DS_Store b/.DS_Store index 135b76d34b91ad7b3f3af6b64b5c400a106f2ab2..a0f0ca8d1a11ecec8e52cfffd784afe5f8819bb2 100644 GIT binary patch delta 82 zcmZn(XbIS`Tb!Bu>blAM#0@ywt)K30^FHnfFy73QX3OFlKbvoG1~+4^t}! SQ_H}>AixmBkjhXD#5n*!QypCZ delta 82 zcmZn(XbIS`Tb!B4>(%6a;szYQ{!d+3c>kCqkfSzPMuLkSDll1B!kBTx=0u4oewbP@ Tm|6w~1_6d3hE#@PAkF~*;d~)E diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 9223a7d..32f2796 100644 --- a/Resell.xcodeproj/project.pbxproj +++ b/Resell.xcodeproj/project.pbxproj @@ -147,7 +147,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 +155,9 @@ 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 */; }; + 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 = ""; }; @@ -652,6 +638,7 @@ 2C525B802CB1F195007D5B8E /* SendFeedbackViewModel.swift */, 2C18FFE92CA1E4C900564577 /* SettingsViewModel.swift */, 2C4DD97C2C98D45B0055D0AB /* SetupProfileViewModel.swift */, + C6B37F582E970D7700A564DB /* FiltersViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -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 */, diff --git a/Resell/.DS_Store b/Resell/.DS_Store index efa7d13bde0e0a19de1b44c97d67d65b548260ee..a9edb070d7f44bca4e06a4616273c768408a1a9c 100644 GIT binary patch delta 18 ZcmZn(XbITRF2%?=d4rV1<^@tIyZ}IT22TJ0 delta 18 ZcmZn(XbITRF2%?&d4rV1<^@tIyZ}IJ22KC~ diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index f4e4cb1..64a5422 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -480,13 +480,21 @@ class NetworkManager: APIClient { return try await post(url: url, body: image) } + // MARK: - Notifications Networking Functions + func getNotifications() async throws -> [Notifications] { + let url = try constructURL(endpoint: "/notif/recent/") + + return try await get(url: url) + } + // func createNotif(notifBody: Notification) async throws -> ListingResponse { // let url = try constructURL(endpoint: "/notif/") // // return try await post(url: url, body: notifBody) // } } + diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index 154f856..84d5361 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -22,6 +22,10 @@ struct NotificationData: Codable { let messageId: String } +struct NotifcationResponse: Codable { + let notifications: [Notifications] +} + extension Notifications { static let dummydata: [Notifications] = [ diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index 1636238..1be0a4b 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -41,9 +41,22 @@ class NotificationsViewModel: ObservableObject { notifications[index].isRead = true } } + + func fetchNotifications() { + Task { + do { + // MARK: - Check with backend to see if there are actually any notis + print(self.notifications) + self.notifications = try await NetworkManager.shared.getNotifications() + print(self.notifications) + } catch { + NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error)") + } + } + } /// Simulate fetching data - func fetchNotifications() { + func dummyFetchNotifications() { notifications = [ Notifications( userID: "381527oef-42b4-4fdd-b074-dfwbejko229", diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 0bc35ea..1454463 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -34,6 +34,9 @@ struct NotificationsView: View { .padding(.top, 5) .padding(.vertical, 1) .navigationTitle("Notifications") + .onAppear { + viewModel.fetchNotifications() + } } // Creates the filter for notifications sorting @@ -65,7 +68,7 @@ struct NotificationsView: View { Spacer() notifText(for: notification) .font(.system(size: 14)) - Text("5 days ago") + Text("6 days ago") .font(.footnote) .foregroundColor(.gray) Spacer() From 793330daa123a087f9341e68294fef1bd198fa78 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 12 Oct 2025 14:34:13 -0400 Subject: [PATCH 03/17] Implemented notification filtering by date for sections. --- .DS_Store | Bin 10244 -> 10244 bytes Resell/Models/Notification.swift | 10 ++++--- .../ViewModels/NotificationsViewModel.swift | 25 ++++++++++++++++++ Resell/Views/Home/NotificationsView.swift | 23 ++++++++-------- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/.DS_Store b/.DS_Store index a0f0ca8d1a11ecec8e52cfffd784afe5f8819bb2..88102060dc5285dc5657370b777db439a4379081 100644 GIT binary patch delta 22 ccmZn(XbIS`N1W+~+~$4a|M-FQn+a diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index 84d5361..d7863b2 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -22,11 +22,15 @@ struct NotificationData: Codable { let messageId: String } -struct NotifcationResponse: Codable { - let notifications: [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} } - extension Notifications { static let dummydata: [Notifications] = [ Notifications( diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index 1be0a4b..fd22160 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -32,6 +32,31 @@ class NotificationsViewModel: ObservableObject { return notifications.filter { $0.data.type.lowercased() == selectedTab.lowercased() } } } + + var groupedFilterNotifications: [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 diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 1454463..668406a 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -19,17 +19,18 @@ struct NotificationsView: 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) - } - .listStyle(PlainListStyle()) +// 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) +// } +// .listStyle(PlainListStyle()) + // MARK: - Implement displaying grouped filtered messages by section and real data. } .padding(.top, 5) .padding(.vertical, 1) From cb7da856f4e9f7acf71497e5278f210c645eeff4 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 12 Oct 2025 17:45:45 -0400 Subject: [PATCH 04/17] Implemented view for date filtered Notis. --- Resell/Models/Notification.swift | 200 +++++++++++++++--- .../Contents.json | 21 ++ .../Message Round Icon from Figma.svg | 3 + .../read-notification.imageset/Contents.json | 2 +- .../mage_message-round.png | Bin 560 -> 0 bytes .../read-notification-dot.svg | 3 + .../ViewModels/NotificationsViewModel.swift | 2 +- Resell/Views/Home/NotificationsView.swift | 59 ++++-- 8 files changed, 236 insertions(+), 54 deletions(-) create mode 100644 Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Contents.json create mode 100644 Resell/Resources/Assets.xcassets/Message Round Icon from Figma.imageset/Message Round Icon from Figma.svg delete mode 100644 Resell/Resources/Assets.xcassets/read-notification.imageset/mage_message-round.png create mode 100644 Resell/Resources/Assets.xcassets/read-notification.imageset/read-notification-dot.svg diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index d7863b2..0ee38cd 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -32,38 +32,170 @@ enum NotificationSection: String, CaseIterable, Identifiable { } extension Notifications { - static let dummydata: [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"), - createdAt: Date(), - updatedAt: Date() - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Request Received", - body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "1"), - createdAt: Date(), - updatedAt: Date() - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Bookmarked Item", - body: "Your bookmarked item is back in stock", - data: NotificationData(type: "bookmarks", messageId: "2"), - createdAt: Date(), - updatedAt: Date() - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "Order Update", - body: "Your listing has been bookmarked", - data: NotificationData(type: "your listings", messageId: "3"), - createdAt: Date(), - updatedAt: Date() - ) - ] + static let dummydata: [Notifications] = { + let now = Date() + let cal = Calendar.current + + func hoursAgo(_ h: Int) -> Date { + now.addingTimeInterval(TimeInterval(-h * 3600)) + } + func daysAgo(_ d: Int) -> Date { + cal.date(byAdding: .day, value: -d, to: now)! + } + + return [ + // === New (same day, within 24h) === + Notifications( + userID: "user-mateo", + title: "New Message", + body: "You have received a new message from Mateo", + data: NotificationData(type: "message", messageId: "msg-0001"), + createdAt: hoursAgo(1), + updatedAt: hoursAgo(1) + ), + Notifications( + userID: "user-angelina", + title: "Request Received", + body: "You have a new request from Angelina", + data: NotificationData(type: "requests", messageId: "req-0001"), + createdAt: hoursAgo(5), + updatedAt: hoursAgo(5) + ), + Notifications( + userID: "user-lina", + title: "Bookmarked Item", + body: "Your bookmarked item is back in stock", + data: NotificationData(type: "bookmarks", messageId: "bm-0001"), + createdAt: hoursAgo(12), + updatedAt: hoursAgo(12) + ), + Notifications( + userID: "user-jay", + title: "Listing Activity", + body: "Your listing has been bookmarked", + data: NotificationData(type: "your listings", messageId: "yl-0001"), + createdAt: hoursAgo(20), + updatedAt: hoursAgo(20) + ), + + // === Last 7 Days (1โ€“6 days) === + Notifications( + userID: "user-sam", + title: "New Message", + body: "Sam: Is this still available?", + data: NotificationData(type: "message", messageId: "msg-0002"), + createdAt: daysAgo(1), + updatedAt: daysAgo(1) + ), + Notifications( + userID: "user-zoe", + title: "Request Updated", + body: "Zoe updated her request", + data: NotificationData(type: "requests", messageId: "req-0002"), + createdAt: daysAgo(2), + updatedAt: daysAgo(2) + ), + Notifications( + userID: "user-rio", + title: "Discount Alert", + body: "An item you bookmarked was discounted", + data: NotificationData(type: "bookmarks", messageId: "bm-0002"), + createdAt: daysAgo(3), + updatedAt: daysAgo(3) + ), + Notifications( + userID: "user-noah", + title: "Listing Saved", + body: "Noah bookmarked your listing", + data: NotificationData(type: "your listings", messageId: "yl-0002"), + createdAt: daysAgo(4), + updatedAt: daysAgo(4) + ), + Notifications( + userID: "user-ivy", + title: "New Message", + body: "Ivy sent you a follow-up", + data: NotificationData(type: "message", messageId: "msg-0003"), + createdAt: daysAgo(6), + updatedAt: daysAgo(6) + ), + + // === Last 30 Days (7โ€“29 days) === + Notifications( + userID: "user-ken", + title: "Request Accepted", + body: "Ken accepted your offer", + data: NotificationData(type: "requests", messageId: "req-0003"), + createdAt: daysAgo(7), + updatedAt: daysAgo(7) + ), + Notifications( + userID: "user-luca", + title: "Price Drop", + body: "Bookmarked item dropped in price", + data: NotificationData(type: "bookmarks", messageId: "bm-0003"), + createdAt: daysAgo(10), + updatedAt: daysAgo(10) + ), + Notifications( + userID: "user-mia", + title: "Listing Saved", + body: "Mia bookmarked your listing", + data: NotificationData(type: "your listings", messageId: "yl-0003"), + createdAt: daysAgo(15), + updatedAt: daysAgo(15) + ), + Notifications( + userID: "user-omar", + title: "New Message", + body: "Omar sent a question about size", + data: NotificationData(type: "message", messageId: "msg-0004"), + createdAt: daysAgo(20), + updatedAt: daysAgo(20) + ), + Notifications( + userID: "user-pia", + title: "Request Withdrawn", + body: "Pia withdrew a request", + data: NotificationData(type: "requests", messageId: "req-0004"), + createdAt: daysAgo(28), + updatedAt: daysAgo(28) + ), + + // === Older (30+ days) === + Notifications( + userID: "user-quinn", + title: "Old Message", + body: "Quinn asked about shipping", + data: NotificationData(type: "message", messageId: "msg-0005"), + createdAt: daysAgo(31), + updatedAt: daysAgo(31) + ), + Notifications( + userID: "user-ryan", + title: "Past Request", + body: "Ryan's request expired", + data: NotificationData(type: "requests", messageId: "req-0005"), + createdAt: daysAgo(45), + updatedAt: daysAgo(45) + ), + Notifications( + userID: "user-sara", + title: "Old Bookmark", + body: "Sara bookmarked a while ago", + data: NotificationData(type: "bookmarks", messageId: "bm-0004"), + createdAt: daysAgo(60), + updatedAt: daysAgo(60) + ), + Notifications( + userID: "user-tim", + title: "Older Listing Activity", + body: "Tim bookmarked your listing previously", + data: NotificationData(type: "your listings", messageId: "yl-0004"), + createdAt: daysAgo(120), + updatedAt: daysAgo(120) + ) + ] + }() } + 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/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 5d8153fd264c92012c0f815c623f39c226af42bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 560 zcmV-00?+-4P)n4Q>fDvwj6BD#-P$r;E&~6ZH)dinvN^(u5li*2KezxLC#xd-# zv3P+Yh#SgxCP&+BQEuQg6X-m+L%kXTSD@K1Mdf0j1n%eias>4q-RWWGL+2u1=7h{c y#=@|@jZP;wL^qAoCz5vz{Q{D1o$5vQ_wWg9YX%oYg;+uW0000 + + diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index fd22160..e2ae11b 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -33,7 +33,7 @@ class NotificationsViewModel: ObservableObject { } } - var groupedFilterNotifications: [NotificationSection: [Notifications]] { + var groupedFilteredNotifications: [NotificationSection: [Notifications]] { let source = filteredNotifications let now = Date() let cal = Calendar.current diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 668406a..cd23d6b 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -13,31 +13,52 @@ struct NotificationsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = NotificationsViewModel() + + 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) -// } -// .listStyle(PlainListStyle()) - // MARK: - Implement displaying grouped filtered messages by section and real data. + .zIndex(1) + + 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) + .fontWeight(.medium) + .textCase(nil) + .padding(.leading, 8) + .padding(.top, 5) + .listRowSeparator(.hidden) + ForEach(items, id: \.data.messageId) { notification in + notificationView(for: notification) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + } + } + } + .listStyle(.plain) + .listRowSeparator(.hidden) } .padding(.top, 5) .padding(.vertical, 1) .navigationTitle("Notifications") - .onAppear { - viewModel.fetchNotifications() - } + // MARK: - Uncomment when confirm notification data in backend +// .onAppear { +// viewModel.fetchNotifications() +// } } // Creates the filter for notifications sorting @@ -52,6 +73,7 @@ struct NotificationsView: View { } } .padding(.top, 20) + .padding(.bottom, 1) } .padding(.leading, 15) } @@ -69,7 +91,7 @@ struct NotificationsView: View { Spacer() notifText(for: notification) .font(.system(size: 14)) - Text("6 days ago") + Text(timeAgo(notification.createdAt)) .font(.footnote) .foregroundColor(.gray) Spacer() @@ -79,8 +101,9 @@ struct NotificationsView: View { } .padding(15) .padding(.horizontal, 15) + .contentShape(Rectangle()) .background(notification.isRead ? Color.white : Color.purple.opacity(0.1)) - .swipeActions(edge: .leading) { + .swipeActions(edge: .leading, allowsFullSwipe: true) { Button(action: { viewModel.markAsRead(notification: notification) }) { From a5c23e6e3643a526d20b29d47eb17051f36f39fa Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 13 Oct 2025 14:16:45 -0400 Subject: [PATCH 05/17] Fixed padding for notis. --- Resell/Views/Home/NotificationsView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index cd23d6b..93487b9 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -96,11 +96,11 @@ struct NotificationsView: View { .foregroundColor(.gray) Spacer() } - .padding(.leading, 20) + .padding(.leading, 10) Spacer() } - .padding(15) - .padding(.horizontal, 15) + .padding(12) + .padding(.horizontal, 12) .contentShape(Rectangle()) .background(notification.isRead ? Color.white : Color.purple.opacity(0.1)) .swipeActions(edge: .leading, allowsFullSwipe: true) { From d96b5c7c225dba80cf80d125785fcaf9026981ec Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 21 Oct 2025 20:34:31 -0400 Subject: [PATCH 06/17] Implemented error handling and error screen for Notifications. --- Resell/Models/Notification.swift | 82 ++++++++++--------- .../ViewModels/NotificationsViewModel.swift | 30 +++++-- Resell/Views/Home/NotificationsView.swift | 69 ++++++++++++---- 3 files changed, 119 insertions(+), 62 deletions(-) diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index 0ee38cd..522076b 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -31,6 +31,14 @@ enum NotificationSection: String, CaseIterable, Identifiable { var id: String {rawValue} } +enum LoadState { + case idle + case loading + case success + case empty + case error +} + extension Notifications { static let dummydata: [Notifications] = { let now = Date() @@ -49,7 +57,7 @@ extension Notifications { userID: "user-mateo", title: "New Message", body: "You have received a new message from Mateo", - data: NotificationData(type: "message", messageId: "msg-0001"), + data: NotificationData(type: "messages", messageId: "msg-0001"), createdAt: hoursAgo(1), updatedAt: hoursAgo(1) ), @@ -69,21 +77,21 @@ extension Notifications { createdAt: hoursAgo(12), updatedAt: hoursAgo(12) ), - Notifications( - userID: "user-jay", - title: "Listing Activity", - body: "Your listing has been bookmarked", - data: NotificationData(type: "your listings", messageId: "yl-0001"), - createdAt: hoursAgo(20), - updatedAt: hoursAgo(20) - ), +// Notifications( +// userID: "user-jay", +// title: "Listing Activity", +// body: "Your listing has been bookmarked", +// data: NotificationData(type: "your listings", messageId: "yl-0001"), +// createdAt: hoursAgo(20), +// updatedAt: hoursAgo(20) +// ), // === Last 7 Days (1โ€“6 days) === Notifications( userID: "user-sam", title: "New Message", body: "Sam: Is this still available?", - data: NotificationData(type: "message", messageId: "msg-0002"), + data: NotificationData(type: "messages", messageId: "msg-0002"), createdAt: daysAgo(1), updatedAt: daysAgo(1) ), @@ -103,19 +111,19 @@ extension Notifications { createdAt: daysAgo(3), updatedAt: daysAgo(3) ), - Notifications( - userID: "user-noah", - title: "Listing Saved", - body: "Noah bookmarked your listing", - data: NotificationData(type: "your listings", messageId: "yl-0002"), - createdAt: daysAgo(4), - updatedAt: daysAgo(4) - ), +// Notifications( +// userID: "user-noah", +// title: "Listing Saved", +// body: "Noah bookmarked your listing", +// data: NotificationData(type: "your listings", messageId: "yl-0002"), +// createdAt: daysAgo(4), +// updatedAt: daysAgo(4) +// ), Notifications( userID: "user-ivy", title: "New Message", body: "Ivy sent you a follow-up", - data: NotificationData(type: "message", messageId: "msg-0003"), + data: NotificationData(type: "messages", messageId: "msg-0003"), createdAt: daysAgo(6), updatedAt: daysAgo(6) ), @@ -137,19 +145,19 @@ extension Notifications { createdAt: daysAgo(10), updatedAt: daysAgo(10) ), - Notifications( - userID: "user-mia", - title: "Listing Saved", - body: "Mia bookmarked your listing", - data: NotificationData(type: "your listings", messageId: "yl-0003"), - createdAt: daysAgo(15), - updatedAt: daysAgo(15) - ), +// Notifications( +// userID: "user-mia", +// title: "Listing Saved", +// body: "Mia bookmarked your listing", +// data: NotificationData(type: "your listings", messageId: "yl-0003"), +// createdAt: daysAgo(15), +// updatedAt: daysAgo(15) +// ), Notifications( userID: "user-omar", title: "New Message", body: "Omar sent a question about size", - data: NotificationData(type: "message", messageId: "msg-0004"), + data: NotificationData(type: "messages", messageId: "msg-0004"), createdAt: daysAgo(20), updatedAt: daysAgo(20) ), @@ -167,7 +175,7 @@ extension Notifications { userID: "user-quinn", title: "Old Message", body: "Quinn asked about shipping", - data: NotificationData(type: "message", messageId: "msg-0005"), + data: NotificationData(type: "messages", messageId: "msg-0005"), createdAt: daysAgo(31), updatedAt: daysAgo(31) ), @@ -187,14 +195,14 @@ extension Notifications { createdAt: daysAgo(60), updatedAt: daysAgo(60) ), - Notifications( - userID: "user-tim", - title: "Older Listing Activity", - body: "Tim bookmarked your listing previously", - data: NotificationData(type: "your listings", messageId: "yl-0004"), - createdAt: daysAgo(120), - updatedAt: daysAgo(120) - ) +// Notifications( +// userID: "user-tim", +// title: "Older Listing Activity", +// body: "Tim bookmarked your listing previously", +// data: NotificationData(type: "your listings", messageId: "yl-0004"), +// createdAt: daysAgo(120), +// updatedAt: daysAgo(120) +// ) ] }() } diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index e2ae11b..1bc2e65 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -14,7 +14,11 @@ class NotificationsViewModel: ObservableObject { // MARK: - Properties - @Published var selectedTab: String = "All" + @Published var selectedTab: String = "All" { + didSet { recalcLoadState() } + } + + // MARK: - What is this for @Published var unreadNotifs: [String: Int] = [ "All": 10, "Messages": 2, @@ -23,14 +27,26 @@ class NotificationsViewModel: ObservableObject { "Your Listings": 5 ] - @Published var notifications: [Notifications] = Notifications.dummydata - + @Published var notifications: [Notifications] = Notifications.dummydata { + didSet { recalcLoadState() } + } + // MARK: - turn back to .idle when we use actual backend networking + @Published var loadState: LoadState = .success + var filteredNotifications: [Notifications] { if selectedTab == "All" { return notifications } else { - return notifications.filter { $0.data.type.lowercased() == selectedTab.lowercased() } + return notifications.filter { $0.data.type.lowercased() == selectedTab.lowercased() } } + } + + private func recalcLoadState() { + switch loadState { + case .loading, .error: + return + default: break } + loadState = filteredNotifications.isEmpty ? .empty : .success } var groupedFilteredNotifications: [NotificationSection: [Notifications]] { @@ -69,13 +85,13 @@ class NotificationsViewModel: ObservableObject { func fetchNotifications() { Task { + loadState = .loading do { // MARK: - Check with backend to see if there are actually any notis - print(self.notifications) self.notifications = try await NetworkManager.shared.getNotifications() - print(self.notifications) } catch { - NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error)") + NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error.localizedDescription)") + loadState = .error } } } diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 93487b9..95c0330 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -29,28 +29,61 @@ struct NotificationsView: View { filtersView .padding(.leading, 15) .zIndex(1) - - 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) - .fontWeight(.medium) - .textCase(nil) - .padding(.leading, 8) - .padding(.top, 5) - .listRowSeparator(.hidden) - ForEach(items, id: \.data.messageId) { notification in - notificationView(for: notification) - .listRowInsets(EdgeInsets()) + + switch viewModel.loadState { + case .idle: + ProgressView() + .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) + .fontWeight(.medium) + .textCase(nil) + .padding(.leading, 8) + .padding(.top, 5) .listRowSeparator(.hidden) + ForEach(items, id: \.data.messageId) { notification in + notificationView(for: notification) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } } } } + .listStyle(.plain) + .listRowSeparator(.hidden) + 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) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .offset(y: -60) } - .listStyle(.plain) - .listRowSeparator(.hidden) } .padding(.top, 5) .padding(.vertical, 1) @@ -115,7 +148,7 @@ struct NotificationsView: View { private func notifText(for notification: Notifications) -> some View { switch notification.data.type { - case "message": + case "messages": return Text(notification.userID).bold() + Text(" sent you a message") case "requests": return Text("Your request for ") From 7a27a7f0f875548cc5fd07925696f43bd9fcc651 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 21 Oct 2025 21:07:03 -0400 Subject: [PATCH 07/17] Added small details to notification UI. --- .../bell.imageset/Bell Icon from Figma.svg | 5 +++++ .../bell.imageset/Contents.json | 2 +- .../Assets.xcassets/bell.imageset/bell.png | Bin 403 -> 0 bytes .../Contents.json | 21 ++++++++++++++++++ .../Ellipse 28 Resell FA25.png | Bin 0 -> 219 bytes Resell/Views/Home/HomeView.swift | 8 ++++++- Resell/Views/Home/NotificationsView.swift | 6 ++--- 7 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 Resell/Resources/Assets.xcassets/bell.imageset/Bell Icon from Figma.svg delete mode 100644 Resell/Resources/Assets.xcassets/bell.imageset/bell.png create mode 100644 Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Contents.json create mode 100644 Resell/Resources/Assets.xcassets/ellipse-notification.imageset/Ellipse 28 Resell FA25.png 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 6e2b3accb4b93c9c51faae79e9107e419a12fc0e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 403 zcmV;E0c`$>P)b00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPJbmeomisR|=wsa0IFYX&pAnNI70lUFwKp2?SbTLb@7;`G@nHE1pL&~RFc=XxeHA}%rm4w$?c=Xe^p|AzHwwhG!fpkHAZlt}D z!`ob__5^>C0|A>EdQk_-i0k`sea=%m83OkW$DL9wDZ(+!E0Gox+$s` xji@&m8ymb&t53auk|3OvG(s_zTyj_VCm!^CW+r9r>VW_N002ovPDHLkV1l3Hp}_zE 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 0000000000000000000000000000000000000000..64e4225613015c365947bfa98b331513ef517713 GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4aTa()7Bet#3xhBt!>l*8o|0J>k`30UXjv*C{y%QRF8x(k0qSX^w+>%(#3s}ty zMBFa$7BJs^pv&dKvoYpU%LMOVwToC9Jk=gNoDiA9bM-{K`|N;}N{c^_H}7yd&i5$l z($1rso$ow;6&WdAn%KH^%T Date: Tue, 21 Oct 2025 21:35:31 -0400 Subject: [PATCH 08/17] Changed to resellPurple constant color. --- Resell/Views/Home/NotificationsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 400fe93..308fae8 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -134,14 +134,14 @@ struct NotificationsView: View { .padding(12) .padding(.horizontal, 12) .contentShape(Rectangle()) - .background(notification.isRead ? Color.white : Color(red: 0.62, green: 0.44, blue: 0.96).opacity(0.1)) + .background(notification.isRead ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button(action: { viewModel.markAsRead(notification: notification) }) { Image("read-notification") } - .tint(Color(red: 0.62, green: 0.44, blue: 0.96).opacity(0.7)) + .tint(Constants.Colors.resellPurple.opacity(0.7)) } } From d4e6af0fd49b7fb2cd440869d09f92e623d5b118 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 22 Oct 2025 17:39:03 -0400 Subject: [PATCH 09/17] Fixed text width for error screen. --- Resell/Views/Home/NotificationsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 308fae8..751a0fe 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -80,7 +80,7 @@ struct NotificationsView: View { .font(.custom("Rubik", size: 18)) .foregroundStyle(.gray) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .frame(maxWidth: 312, maxHeight: .infinity, alignment: .center) .offset(y: -60) } } From 33f9460abfbdea16189a58fa01080471005ae0eb Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 22 Oct 2025 18:11:27 -0400 Subject: [PATCH 10/17] Deleted comments. --- Resell/.DS_Store | Bin 10244 -> 10244 bytes Resell/Models/Notification.swift | 4 ---- 2 files changed, 4 deletions(-) diff --git a/Resell/.DS_Store b/Resell/.DS_Store index a9edb070d7f44bca4e06a4616273c768408a1a9c..b513c9b65583f164e4d6b8de7c93e9fe1f145b00 100644 GIT binary patch delta 45 xcmZn(XbIThBEZPF*;SyL5l9^r Date: Wed, 22 Oct 2025 18:13:36 -0400 Subject: [PATCH 11/17] Deleted comments. --- Resell/Models/Notification.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index fec4079..de8dc78 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -6,7 +6,7 @@ // import Foundation -// Original name Notification overrides Foundation definition... + struct Notifications: Codable { let userID: String let title: String From 9bf77640c7f5dcfb66702e2ee32aa59772e41ad6 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 16 Jan 2026 16:10:29 -0800 Subject: [PATCH 12/17] removing notifications --- Resell.xcodeproj/project.pbxproj | 34 +++++++++---------- .../xcshareddata/swiftpm/Package.resolved | 6 ++-- Resell/Models/MessageCluster.swift | 2 +- .../ViewModels/NotificationsViewModel.swift | 15 ++++++++ Resell/Views/Home/NotificationsView.swift | 9 +++++ 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/Resell.xcodeproj/project.pbxproj b/Resell.xcodeproj/project.pbxproj index 32f2796..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 */; }; @@ -155,6 +154,7 @@ 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 */; }; + 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 */; }; D0A25DEE2E5804A900607E1F /* EmptyStateModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A25DED2E5804A900607E1F /* EmptyStateModifier.swift */; }; @@ -313,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 */, @@ -329,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 */, @@ -722,7 +722,6 @@ 2E8A5A282DBCC82E00B1F281 /* TinyHTTPServer */, 2E8A5A5C2DBCC87500B1F281 /* GoogleSignIn */, 2E8A5A5E2DBCC87500B1F281 /* GoogleSignInSwift */, - 2E8A5A612DBCC87F00B1F281 /* Flow */, 2E8A5A642DBCC8A100B1F281 /* Kingfisher */, 2E8A5A812DBCD16500B1F281 /* FirebaseAnalytics */, 2E8A5A832DBCD16500B1F281 /* FirebaseAnalyticsOnDeviceConversion */, @@ -748,6 +747,7 @@ 2E8A5AAB2DBCD16500B1F281 /* FirebaseStorage */, 2E8A5AAD2DBCD16500B1F281 /* FirebaseStorageCombine-Community */, 2E8A5AAF2DBCD16500B1F281 /* FirebaseVertexAI */, + 2EEAAB2A2F1B07B20006FF5C /* Flow */, ); productName = Resell; productReference = 2C9B4CC72C8FB7B70029DF61 /* Resell.app */; @@ -824,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 = ""; @@ -1339,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"; @@ -1363,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 */ @@ -1412,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" */; @@ -1542,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/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/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index 1bc2e65..1f139a5 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -83,6 +83,21 @@ class NotificationsViewModel: ObservableObject { } } + /// Remove a notification + func removeNotification(notification: Notifications) { + withAnimation { + notifications.removeAll(where: { $0.data.messageId == notification.data.messageId }) + } + // TODO: Add backend call to delete notification when backend is ready + // Task { + // do { + // try await NetworkManager.shared.deleteNotification(id: notification.data.messageId) + // } catch { + // NetworkManager.shared.logger.error("Error deleting notification: \(error.localizedDescription)") + // } + // } + } + func fetchNotifications() { Task { loadState = .loading diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 751a0fe..ecd4832 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -143,6 +143,15 @@ struct NotificationsView: View { } .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 notifText(for notification: Notifications) -> some View { From 7ed9fe061a410b644ba7aec49ad7d964a9eaa404 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 16 Jan 2026 16:26:26 -0800 Subject: [PATCH 13/17] slightly better styling --- Resell/Views/Home/NotificationsView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index ecd4832..3d3b2ed 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -133,8 +133,9 @@ struct NotificationsView: View { } .padding(12) .padding(.horizontal, 12) - .contentShape(Rectangle()) - .background(notification.isRead ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) + .listRowBackground( + (notification.isRead ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) + ) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button(action: { viewModel.markAsRead(notification: notification) From bd1ed72727b831c6a41c1ac91e976037d26633ac Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 25 Jan 2026 18:48:30 -0500 Subject: [PATCH 14/17] notis testing... --- Resell/API/NetworkManager.swift | 120 ++++++- Resell/Info.plist | 2 + Resell/Models/Notification.swift | 328 ++++++++++-------- Resell/Utils/Keys.swift | 2 +- .../ViewModels/NotificationsViewModel.swift | 144 +++++--- Resell/Views/Home/NotificationsView.swift | 155 +++++++-- 6 files changed, 518 insertions(+), 233 deletions(-) diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index 64a5422..1c027e6 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() { } @@ -483,18 +500,103 @@ class NetworkManager: APIClient { // MARK: - Notifications Networking Functions - func getNotifications() async throws -> [Notifications] { - let url = try constructURL(endpoint: "/notif/recent/") + /// 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") - return try await get(url: url) + 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) + } + } + + // Debug: print raw response + if let jsonString = String(data: data, encoding: .utf8) { + print("๐Ÿ“ฅ Notification response: \(jsonString.prefix(1000))") + } + + // 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) } - // func createNotif(notifBody: Notification) async throws -> ListingResponse { - // let url = try constructURL(endpoint: "/notif/") - // - // return try await post(url: url, body: notifBody) - // } + /// 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) + } + } + + // Debug: print raw response + if let jsonString = String(data: data, encoding: .utf8) { + print("๐Ÿ“ฅ Notification POST response: \(jsonString.prefix(1000))") + } + + 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 -> MarkReadResponse { + let url = try constructURL(endpoint: "/notif/read/\(notificationId)") + return try await postNotification(url: url, body: EmptyBody()) + } + + // MARK: - Test Notification Endpoints + + /// Create a test notification (for development/testing) + /// - Parameter type: One of "messages", "requests", "bookmarks", "transactions" + func createTestNotification(type: String) async throws -> TestNotificationResponse { + let url = try constructURL(endpoint: "/notif/test/\(type)") + print("๐Ÿ“ค POST to: \(url.absoluteString)") + return try await postNotification(url: url, body: EmptyBody()) } +} 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/Notification.swift b/Resell/Models/Notification.swift index de8dc78..cc031b9 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -7,19 +7,142 @@ import Foundation -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 TestNotificationResponse: Codable { + let message: String + let notification: Notifications +} + +struct MarkReadResponse: Codable { + let message: String + let notification: Notifications } enum NotificationSection: String, CaseIterable, Identifiable { @@ -40,6 +163,40 @@ enum LoadState { } extension Notifications { + /// Creates dummy notification data for testing/preview purposes + static func makeDummyData( + id: String = UUID().uuidString, + type: String, + title: String, + body: String, + createdAt: Date + ) -> Notifications { + Notifications( + id: id, + userId: "dummy-user", + title: title, + body: body, + data: NotificationData( + type: type, + imageUrl: nil, + postId: nil, + postTitle: nil, + chatId: nil, + sellerId: nil, + sellerUsername: nil, + sellerPhotoUrl: nil, + buyerId: nil, + buyerUsername: nil, + transactionId: nil, + price: nil, + messageId: id + ), + read: false, + createdAt: createdAt, + updatedAt: createdAt + ) + } + static let dummydata: [Notifications] = { let now = Date() let cal = Calendar.current @@ -52,153 +209,20 @@ extension Notifications { } return [ - Notifications( - userID: "user-mateo", - title: "New Message", - body: "You have received a new message from Mateo", - data: NotificationData(type: "messages", messageId: "msg-0001"), - createdAt: hoursAgo(1), - updatedAt: hoursAgo(1) - ), - Notifications( - userID: "user-angelina", - title: "Request Received", - body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "req-0001"), - createdAt: hoursAgo(5), - updatedAt: hoursAgo(5) - ), - Notifications( - userID: "user-lina", - title: "Bookmarked Item", - body: "Your bookmarked item is back in stock", - data: NotificationData(type: "bookmarks", messageId: "bm-0001"), - createdAt: hoursAgo(12), - updatedAt: hoursAgo(12) - ), -// Notifications( -// userID: "user-jay", -// title: "Listing Activity", -// body: "Your listing has been bookmarked", -// data: NotificationData(type: "your listings", messageId: "yl-0001"), -// createdAt: hoursAgo(20), -// updatedAt: hoursAgo(20) -// ), - - Notifications( - userID: "user-sam", - title: "New Message", - body: "Sam: Is this still available?", - data: NotificationData(type: "messages", messageId: "msg-0002"), - createdAt: daysAgo(1), - updatedAt: daysAgo(1) - ), - Notifications( - userID: "user-zoe", - title: "Request Updated", - body: "Zoe updated her request", - data: NotificationData(type: "requests", messageId: "req-0002"), - createdAt: daysAgo(2), - updatedAt: daysAgo(2) - ), - Notifications( - userID: "user-rio", - title: "Discount Alert", - body: "An item you bookmarked was discounted", - data: NotificationData(type: "bookmarks", messageId: "bm-0002"), - createdAt: daysAgo(3), - updatedAt: daysAgo(3) - ), -// Notifications( -// userID: "user-noah", -// title: "Listing Saved", -// body: "Noah bookmarked your listing", -// data: NotificationData(type: "your listings", messageId: "yl-0002"), -// createdAt: daysAgo(4), -// updatedAt: daysAgo(4) -// ), - Notifications( - userID: "user-ivy", - title: "New Message", - body: "Ivy sent you a follow-up", - data: NotificationData(type: "messages", messageId: "msg-0003"), - createdAt: daysAgo(6), - updatedAt: daysAgo(6) - ), - - Notifications( - userID: "user-ken", - title: "Request Accepted", - body: "Ken accepted your offer", - data: NotificationData(type: "requests", messageId: "req-0003"), - createdAt: daysAgo(7), - updatedAt: daysAgo(7) - ), - Notifications( - userID: "user-luca", - title: "Price Drop", - body: "Bookmarked item dropped in price", - data: NotificationData(type: "bookmarks", messageId: "bm-0003"), - createdAt: daysAgo(10), - updatedAt: daysAgo(10) - ), -// Notifications( -// userID: "user-mia", -// title: "Listing Saved", -// body: "Mia bookmarked your listing", -// data: NotificationData(type: "your listings", messageId: "yl-0003"), -// createdAt: daysAgo(15), -// updatedAt: daysAgo(15) -// ), - Notifications( - userID: "user-omar", - title: "New Message", - body: "Omar sent a question about size", - data: NotificationData(type: "messages", messageId: "msg-0004"), - createdAt: daysAgo(20), - updatedAt: daysAgo(20) - ), - Notifications( - userID: "user-pia", - title: "Request Withdrawn", - body: "Pia withdrew a request", - data: NotificationData(type: "requests", messageId: "req-0004"), - createdAt: daysAgo(28), - updatedAt: daysAgo(28) - ), - - Notifications( - userID: "user-quinn", - title: "Old Message", - body: "Quinn asked about shipping", - data: NotificationData(type: "messages", messageId: "msg-0005"), - createdAt: daysAgo(31), - updatedAt: daysAgo(31) - ), - Notifications( - userID: "user-ryan", - title: "Past Request", - body: "Ryan's request expired", - data: NotificationData(type: "requests", messageId: "req-0005"), - createdAt: daysAgo(45), - updatedAt: daysAgo(45) - ), - Notifications( - userID: "user-sara", - title: "Old Bookmark", - body: "Sara bookmarked a while ago", - data: NotificationData(type: "bookmarks", messageId: "bm-0004"), - createdAt: daysAgo(60), - updatedAt: daysAgo(60) - ), -// Notifications( -// userID: "user-tim", -// title: "Older Listing Activity", -// body: "Tim bookmarked your listing previously", -// data: NotificationData(type: "your listings", messageId: "yl-0004"), -// createdAt: daysAgo(120), -// updatedAt: daysAgo(120) -// ) + makeDummyData(id: "msg-0001", type: "messages", title: "New Message", body: "You have received a new message from Mateo", createdAt: hoursAgo(1)), + makeDummyData(id: "req-0001", type: "requests", title: "Request Received", body: "You have a new request from Angelina", createdAt: hoursAgo(5)), + makeDummyData(id: "bm-0001", type: "bookmarks", title: "Bookmarked Item", body: "Your bookmarked item is back in stock", createdAt: hoursAgo(12)), + makeDummyData(id: "msg-0002", type: "messages", title: "New Message", body: "Sam: Is this still available?", createdAt: daysAgo(1)), + makeDummyData(id: "req-0002", type: "requests", title: "Request Updated", body: "Zoe updated her request", createdAt: daysAgo(2)), + makeDummyData(id: "bm-0002", type: "bookmarks", title: "Discount Alert", body: "An item you bookmarked was discounted", createdAt: daysAgo(3)), + makeDummyData(id: "msg-0003", type: "messages", title: "New Message", body: "Ivy sent you a follow-up", createdAt: daysAgo(6)), + makeDummyData(id: "req-0003", type: "requests", title: "Request Accepted", body: "Ken accepted your offer", createdAt: daysAgo(7)), + makeDummyData(id: "bm-0003", type: "bookmarks", title: "Price Drop", body: "Bookmarked item dropped in price", createdAt: daysAgo(10)), + makeDummyData(id: "msg-0004", type: "messages", title: "New Message", body: "Omar sent a question about size", createdAt: daysAgo(20)), + makeDummyData(id: "req-0004", type: "requests", title: "Request Withdrawn", body: "Pia withdrew a request", createdAt: daysAgo(28)), + makeDummyData(id: "msg-0005", type: "messages", title: "Old Message", body: "Quinn asked about shipping", createdAt: daysAgo(31)), + makeDummyData(id: "req-0005", type: "requests", title: "Past Request", body: "Ryan's request expired", createdAt: daysAgo(45)), + makeDummyData(id: "bm-0004", type: "bookmarks", title: "Old Bookmark", body: "Sara bookmarked a while ago", createdAt: daysAgo(60)), ] }() } 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 1f139a5..5e154ed 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -18,26 +18,30 @@ class NotificationsViewModel: ObservableObject { didSet { recalcLoadState() } } - // MARK: - What is this for + /// Unread notification counts by type @Published var unreadNotifs: [String: Int] = [ - "All": 10, - "Messages": 2, - "Requests": 3, - "Bookmarks": 1, - "Your Listings": 5 + "All": 0, + "Messages": 0, + "Requests": 0, + "Bookmarks": 0, + "Transactions": 0 ] - @Published var notifications: [Notifications] = Notifications.dummydata { - didSet { recalcLoadState() } + @Published var notifications: [Notifications] = [] { + didSet { + recalcLoadState() + updateUnreadCounts() + } } - // MARK: - turn back to .idle when we use actual backend networking - @Published var loadState: LoadState = .success + + @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() { @@ -49,6 +53,24 @@ class NotificationsViewModel: ObservableObject { 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() @@ -76,61 +98,89 @@ class NotificationsViewModel: ObservableObject { // 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 + Task { + do { + let response = try await NetworkManager.shared.markNotificationAsRead(notificationId: notification.id) + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index] = response.notification + } + } catch { + NetworkManager.shared.logger.error("Error marking notification as read: \(error.localizedDescription)") + // Update locally as fallback + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index].read = true + } + } } } - /// Remove a notification + /// Remove a notification from local state func removeNotification(notification: Notifications) { withAnimation { - notifications.removeAll(where: { $0.data.messageId == notification.data.messageId }) + notifications.removeAll(where: { $0.id == notification.id }) } - // TODO: Add backend call to delete notification when backend is ready - // Task { - // do { - // try await NetworkManager.shared.deleteNotification(id: notification.data.messageId) - // } catch { - // NetworkManager.shared.logger.error("Error deleting notification: \(error.localizedDescription)") - // } - // } } + /// Fetch notifications from backend (last 30 days) func fetchNotifications() { Task { loadState = .loading do { - // MARK: - Check with backend to see if there are actually any notis - self.notifications = try await NetworkManager.shared.getNotifications() + self.notifications = try await NetworkManager.shared.getLast30DaysNotifications() + loadState = notifications.isEmpty ? .empty : .success + print("โœ… Fetched \(notifications.count) notifications") } catch { NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error.localizedDescription)") + print("โŒ Fetch notifications error: \(error)") + + // 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 } } } - - /// Simulate fetching data - func dummyFetchNotifications() { - 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"), - createdAt: Date(), - updatedAt: Date() - ), - Notifications( - userID: "381527oef-42b4-4fdd-b074-dfwbejko229", - title: "New Request", - body: "You have a new request from Angelina", - data: NotificationData(type: "requests", messageId: "23456"), - createdAt: Date(), - updatedAt: Date() - ) - ] + + // MARK: - Test Functions + + /// Create a test notification (for development only) + /// - Parameter type: One of "messages", "requests", "bookmarks", "transactions" + func createTestNotification(type: String) { + Task { + do { + let response = try await NetworkManager.shared.createTestNotification(type: type) + // Add to local list immediately + notifications.insert(response.notification, at: 0) + print("โœ… Test notification created: \(response.notification.title)") + } catch let urlError as URLError where urlError.code.rawValue == 404 { + print("โŒ Test endpoint not found (404). The /notif/test/\(type) endpoint may not be deployed yet.") + print(" Tip: Use 'Load Dummy Data' to test the UI, or create real notifications through app actions.") + } catch { + NetworkManager.shared.logger.error("Error creating test notification: \(error.localizedDescription)") + print("โŒ Error creating test notification: \(error)") + } + } + } + + /// Load dummy data for preview/testing + func loadDummyData() { + notifications = Notifications.dummydata + loadState = .success } } diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 3d3b2ed..03cec88 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -13,6 +13,7 @@ struct NotificationsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = NotificationsViewModel() + @State private var showTestMenu = false private let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() @@ -32,9 +33,14 @@ struct NotificationsView: View { switch viewModel.loadState { case .idle: - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .offset(y: -60) + 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) @@ -50,7 +56,7 @@ struct NotificationsView: View { .padding(.leading, 8) .padding(.top, 5) .listRowSeparator(.hidden) - ForEach(items, id: \.data.messageId) { notification in + ForEach(items) { notification in notificationView(for: notification) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) @@ -60,6 +66,9 @@ struct NotificationsView: View { } .listStyle(.plain) .listRowSeparator(.hidden) + .refreshable { + viewModel.fetchNotifications() + } case .empty: VStack (alignment: .center, spacing: 16) { Text("You're all caught up!") @@ -79,6 +88,12 @@ struct NotificationsView: View { 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) @@ -87,10 +102,44 @@ struct NotificationsView: View { .padding(.top, 5) .padding(.vertical, 1) .navigationTitle("Notifications") - // MARK: - Uncomment when confirm notification data in backend -// .onAppear { -// viewModel.fetchNotifications() -// } + .toolbar { + // Test notification button (for development) + #if DEBUG + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Section("Test Notifications") { + Button("๐Ÿ“ฌ Test Messages") { + viewModel.createTestNotification(type: "messages") + } + Button("๐Ÿ“‹ Test Requests") { + viewModel.createTestNotification(type: "requests") + } + Button("๐Ÿ”– Test Bookmarks") { + viewModel.createTestNotification(type: "bookmarks") + } + Button("๐Ÿ’ฐ Test Transactions") { + viewModel.createTestNotification(type: "transactions") + } + } + + Divider() + + Button("๐Ÿงช Load Dummy Data") { + viewModel.loadDummyData() + } + + Button("๐Ÿ”„ Refresh from Server") { + viewModel.fetchNotifications() + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + #endif + } + .onAppear { + viewModel.fetchNotifications() + } } // Creates the filter for notifications sorting @@ -114,10 +163,21 @@ 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() @@ -133,8 +193,14 @@ struct NotificationsView: View { } .padding(12) .padding(.horizontal, 12) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.markAsRead(notification: notification) + // Navigate based on notification type + handleNotificationTap(notification) + } .listRowBackground( - (notification.isRead ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) + (notification.read ? Color.white : Constants.Colors.resellPurple.opacity(0.1)) ) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button(action: { @@ -155,22 +221,63 @@ struct NotificationsView: View { } } + 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) { + // TODO: Navigate to appropriate screen based on notification type + // For now, just mark as read + switch notification.data.resolvedType.lowercased() { + case "messages": + if let chatId = notification.data.chatId { + print("Navigate to chat: \(chatId)") + // router.push(.chat(id: chatId)) + } + case "bookmarks", "transactions": + if let postId = notification.data.postId { + print("Navigate to post: \(postId)") + // router.push(.product(id: postId)) + } + default: + break + } + } + private func notifText(for notification: Notifications) -> some View { - switch notification.data.type { + // 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": - return Text(notification.userID).bold() + Text(" sent you a message") + 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) } } } From 0e354439117f331235e5ff4f1f217bbb7b432cb1 Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 25 Jan 2026 19:10:50 -0500 Subject: [PATCH 15/17] notis stuff --- Resell/API/NetworkManager.swift | 31 ++++- .../ViewModels/NotificationsViewModel.swift | 15 ++- Resell/Views/Home/NotificationsView.swift | 107 +++++++++++++++--- 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index 1c027e6..254c6fd 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -582,9 +582,36 @@ class NetworkManager: APIClient { } /// Mark a notification as read - func markNotificationAsRead(notificationId: String) async throws -> MarkReadResponse { + func markNotificationAsRead(notificationId: String) async throws -> Notifications { let url = try constructURL(endpoint: "/notif/read/\(notificationId)") - return try await postNotification(url: url, body: EmptyBody()) + + 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 + } + + // Debug: print raw response + if let jsonString = String(data: data, encoding: .utf8) { + print("๐Ÿ“ฅ Mark read response: \(jsonString.prefix(500))") + } + + // 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) } // MARK: - Test Notification Endpoints diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index 5e154ed..75d57c4 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -100,18 +100,21 @@ class NotificationsViewModel: ObservableObject { /// Mark a notification as read (calls backend) func markAsRead(notification: Notifications) { + // 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 response = try await NetworkManager.shared.markNotificationAsRead(notificationId: notification.id) + let updated = try await NetworkManager.shared.markNotificationAsRead(notificationId: notification.id) if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - notifications[index] = response.notification + notifications[index] = updated } } catch { NetworkManager.shared.logger.error("Error marking notification as read: \(error.localizedDescription)") - // Update locally as fallback - if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - notifications[index].read = true - } + // Local update already applied, so user still sees it as read } } } diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 03cec88..3334bb5 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -14,6 +14,7 @@ struct NotificationsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = NotificationsViewModel() @State private var showTestMenu = false + @State private var isNavigating = false private let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() @@ -140,6 +141,17 @@ struct NotificationsView: View { .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 @@ -232,22 +244,91 @@ struct NotificationsView: View { } private func handleNotificationTap(_ notification: Notifications) { - // TODO: Navigate to appropriate screen based on notification type - // For now, just mark as read - switch notification.data.resolvedType.lowercased() { - case "messages": - if let chatId = notification.data.chatId { - print("Navigate to chat: \(chatId)") - // router.push(.chat(id: chatId)) + 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": + // Navigate to requests tab or specific request + print("Navigate to requests") + default: + break } - case "bookmarks", "transactions": - if let postId = notification.data.postId { - print("Navigate to post: \(postId)") - // router.push(.product(id: postId)) + } + } + + @MainActor + private func navigateToChat(notification: Notifications) async { + guard let postId = notification.data.postId, + let sellerId = notification.data.sellerId, + let buyerId = notification.data.buyerId else { + print("โš ๏ธ Missing data for chat navigation: postId=\(notification.data.postId ?? "nil"), sellerId=\(notification.data.sellerId ?? "nil"), buyerId=\(notification.data.buyerId ?? "nil")") + // Fallback: if we have postId, at least navigate to the post + if notification.data.postId != nil { + await navigateToPost(notification: notification) } - default: - break + 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 { + print("โŒ Post not found") + return + } + + let chatInfo = ChatInfo(listing: post, buyer: buyerRes.user, seller: sellerRes.user) + router.push(.messages(chatInfo: chatInfo)) + } catch { + print("โŒ Error navigating to chat: \(error)") + // Fallback to post details if available + await navigateToPost(notification: notification) + } + } + + @MainActor + private func navigateToPost(notification: Notifications) async { + guard let postId = notification.data.postId else { + print("โš ๏ธ No postId in notification") + showNavigationError("This notification doesn't have a valid post reference.") + return + } + + // Check if it looks like a valid UUID (basic check) + guard postId.count > 10 && !postId.hasPrefix("test-") else { + print("โš ๏ธ Invalid postId format: \(postId)") + showNavigationError("This is a test notification with dummy data. Update your backend to use real post IDs.") + return + } + + do { + let response = try await NetworkManager.shared.getPostByID(id: postId) + guard let post = response.post else { + print("โŒ Post not found for id: \(postId)") + showNavigationError("The post for this notification no longer exists.") + return + } + router.push(.productDetails(post)) + } catch { + print("โŒ Error fetching post: \(error)") + showNavigationError("Couldn't load the post. It may have been deleted.") + } + } + + private func showNavigationError(_ message: String) { + // For now just print - you could show an alert instead + print("โš ๏ธ Navigation error: \(message)") } private func notifText(for notification: Notifications) -> some View { From b84a5e85c36b2dfb92f42d9bab9f2643f0c72473 Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 25 Jan 2026 19:16:03 -0500 Subject: [PATCH 16/17] remove ui tests --- Resell/API/NetworkManager.swift | 21 ----------- .../ViewModels/NotificationsViewModel.swift | 28 --------------- Resell/Views/Home/NotificationsView.swift | 36 ------------------- 3 files changed, 85 deletions(-) diff --git a/Resell/API/NetworkManager.swift b/Resell/API/NetworkManager.swift index 254c6fd..094929a 100644 --- a/Resell/API/NetworkManager.swift +++ b/Resell/API/NetworkManager.swift @@ -514,10 +514,6 @@ class NetworkManager: APIClient { } } - // Debug: print raw response - if let jsonString = String(data: data, encoding: .utf8) { - print("๐Ÿ“ฅ Notification response: \(jsonString.prefix(1000))") - } // Try decoding as array first (direct response) if let notifications = try? iso8601Decoder.decode([Notifications].self, from: data) { @@ -549,10 +545,6 @@ class NetworkManager: APIClient { } } - // Debug: print raw response - if let jsonString = String(data: data, encoding: .utf8) { - print("๐Ÿ“ฅ Notification POST response: \(jsonString.prefix(1000))") - } return try iso8601Decoder.decode(T.self, from: data) } @@ -596,10 +588,6 @@ class NetworkManager: APIClient { throw error } - // Debug: print raw response - if let jsonString = String(data: data, encoding: .utf8) { - print("๐Ÿ“ฅ Mark read response: \(jsonString.prefix(500))") - } // Try different response formats if let wrapped = try? iso8601Decoder.decode(MarkReadResponse.self, from: data) { @@ -614,15 +602,6 @@ class NetworkManager: APIClient { return try iso8601Decoder.decode(Notifications.self, from: data) } - // MARK: - Test Notification Endpoints - - /// Create a test notification (for development/testing) - /// - Parameter type: One of "messages", "requests", "bookmarks", "transactions" - func createTestNotification(type: String) async throws -> TestNotificationResponse { - let url = try constructURL(endpoint: "/notif/test/\(type)") - print("๐Ÿ“ค POST to: \(url.absoluteString)") - return try await postNotification(url: url, body: EmptyBody()) - } } diff --git a/Resell/ViewModels/NotificationsViewModel.swift b/Resell/ViewModels/NotificationsViewModel.swift index 75d57c4..06f1f74 100644 --- a/Resell/ViewModels/NotificationsViewModel.swift +++ b/Resell/ViewModels/NotificationsViewModel.swift @@ -133,10 +133,8 @@ class NotificationsViewModel: ObservableObject { do { self.notifications = try await NetworkManager.shared.getLast30DaysNotifications() loadState = notifications.isEmpty ? .empty : .success - print("โœ… Fetched \(notifications.count) notifications") } catch { NetworkManager.shared.logger.error("Error in NotificationsViewModel.fetchNotifications: \(error.localizedDescription)") - print("โŒ Fetch notifications error: \(error)") // Fall back to empty state instead of error - notifications might just not exist yet self.notifications = [] @@ -159,32 +157,6 @@ class NotificationsViewModel: ObservableObject { } } - // MARK: - Test Functions - - /// Create a test notification (for development only) - /// - Parameter type: One of "messages", "requests", "bookmarks", "transactions" - func createTestNotification(type: String) { - Task { - do { - let response = try await NetworkManager.shared.createTestNotification(type: type) - // Add to local list immediately - notifications.insert(response.notification, at: 0) - print("โœ… Test notification created: \(response.notification.title)") - } catch let urlError as URLError where urlError.code.rawValue == 404 { - print("โŒ Test endpoint not found (404). The /notif/test/\(type) endpoint may not be deployed yet.") - print(" Tip: Use 'Load Dummy Data' to test the UI, or create real notifications through app actions.") - } catch { - NetworkManager.shared.logger.error("Error creating test notification: \(error.localizedDescription)") - print("โŒ Error creating test notification: \(error)") - } - } - } - - /// Load dummy data for preview/testing - func loadDummyData() { - notifications = Notifications.dummydata - loadState = .success - } } diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 3334bb5..36077f7 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -13,7 +13,6 @@ struct NotificationsView: View { @EnvironmentObject var router: Router @StateObject private var viewModel = NotificationsViewModel() - @State private var showTestMenu = false @State private var isNavigating = false private let relativeFormatter: RelativeDateTimeFormatter = { @@ -103,41 +102,6 @@ struct NotificationsView: View { .padding(.top, 5) .padding(.vertical, 1) .navigationTitle("Notifications") - .toolbar { - // Test notification button (for development) - #if DEBUG - ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Section("Test Notifications") { - Button("๐Ÿ“ฌ Test Messages") { - viewModel.createTestNotification(type: "messages") - } - Button("๐Ÿ“‹ Test Requests") { - viewModel.createTestNotification(type: "requests") - } - Button("๐Ÿ”– Test Bookmarks") { - viewModel.createTestNotification(type: "bookmarks") - } - Button("๐Ÿ’ฐ Test Transactions") { - viewModel.createTestNotification(type: "transactions") - } - } - - Divider() - - Button("๐Ÿงช Load Dummy Data") { - viewModel.loadDummyData() - } - - Button("๐Ÿ”„ Refresh from Server") { - viewModel.fetchNotifications() - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - #endif - } .onAppear { viewModel.fetchNotifications() } From 8445238965f0773233b183852bf266d3c22fd4ce Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 25 Jan 2026 19:17:36 -0500 Subject: [PATCH 17/17] remove more ui tests --- Resell/Models/Notification.swift | 69 ----------------------- Resell/Views/Home/NotificationsView.swift | 21 +------ 2 files changed, 3 insertions(+), 87 deletions(-) diff --git a/Resell/Models/Notification.swift b/Resell/Models/Notification.swift index cc031b9..9faebbe 100644 --- a/Resell/Models/Notification.swift +++ b/Resell/Models/Notification.swift @@ -135,11 +135,6 @@ struct SingleNotificationResponse: Codable { let message: String? } -struct TestNotificationResponse: Codable { - let message: String - let notification: Notifications -} - struct MarkReadResponse: Codable { let message: String let notification: Notifications @@ -162,68 +157,4 @@ enum LoadState { case error } -extension Notifications { - /// Creates dummy notification data for testing/preview purposes - static func makeDummyData( - id: String = UUID().uuidString, - type: String, - title: String, - body: String, - createdAt: Date - ) -> Notifications { - Notifications( - id: id, - userId: "dummy-user", - title: title, - body: body, - data: NotificationData( - type: type, - imageUrl: nil, - postId: nil, - postTitle: nil, - chatId: nil, - sellerId: nil, - sellerUsername: nil, - sellerPhotoUrl: nil, - buyerId: nil, - buyerUsername: nil, - transactionId: nil, - price: nil, - messageId: id - ), - read: false, - createdAt: createdAt, - updatedAt: createdAt - ) - } - - static let dummydata: [Notifications] = { - let now = Date() - let cal = Calendar.current - - func hoursAgo(_ h: Int) -> Date { - now.addingTimeInterval(TimeInterval(-h * 3600)) - } - func daysAgo(_ d: Int) -> Date { - cal.date(byAdding: .day, value: -d, to: now)! - } - - return [ - makeDummyData(id: "msg-0001", type: "messages", title: "New Message", body: "You have received a new message from Mateo", createdAt: hoursAgo(1)), - makeDummyData(id: "req-0001", type: "requests", title: "Request Received", body: "You have a new request from Angelina", createdAt: hoursAgo(5)), - makeDummyData(id: "bm-0001", type: "bookmarks", title: "Bookmarked Item", body: "Your bookmarked item is back in stock", createdAt: hoursAgo(12)), - makeDummyData(id: "msg-0002", type: "messages", title: "New Message", body: "Sam: Is this still available?", createdAt: daysAgo(1)), - makeDummyData(id: "req-0002", type: "requests", title: "Request Updated", body: "Zoe updated her request", createdAt: daysAgo(2)), - makeDummyData(id: "bm-0002", type: "bookmarks", title: "Discount Alert", body: "An item you bookmarked was discounted", createdAt: daysAgo(3)), - makeDummyData(id: "msg-0003", type: "messages", title: "New Message", body: "Ivy sent you a follow-up", createdAt: daysAgo(6)), - makeDummyData(id: "req-0003", type: "requests", title: "Request Accepted", body: "Ken accepted your offer", createdAt: daysAgo(7)), - makeDummyData(id: "bm-0003", type: "bookmarks", title: "Price Drop", body: "Bookmarked item dropped in price", createdAt: daysAgo(10)), - makeDummyData(id: "msg-0004", type: "messages", title: "New Message", body: "Omar sent a question about size", createdAt: daysAgo(20)), - makeDummyData(id: "req-0004", type: "requests", title: "Request Withdrawn", body: "Pia withdrew a request", createdAt: daysAgo(28)), - makeDummyData(id: "msg-0005", type: "messages", title: "Old Message", body: "Quinn asked about shipping", createdAt: daysAgo(31)), - makeDummyData(id: "req-0005", type: "requests", title: "Past Request", body: "Ryan's request expired", createdAt: daysAgo(45)), - makeDummyData(id: "bm-0004", type: "bookmarks", title: "Old Bookmark", body: "Sara bookmarked a while ago", createdAt: daysAgo(60)), - ] - }() -} diff --git a/Resell/Views/Home/NotificationsView.swift b/Resell/Views/Home/NotificationsView.swift index 36077f7..b06f424 100644 --- a/Resell/Views/Home/NotificationsView.swift +++ b/Resell/Views/Home/NotificationsView.swift @@ -218,8 +218,8 @@ struct NotificationsView: View { case "bookmarks", "transactions": await navigateToPost(notification: notification) case "requests": - // Navigate to requests tab or specific request - print("Navigate to requests") + // TODO: Navigate to requests tab when implemented + break default: break } @@ -231,7 +231,6 @@ struct NotificationsView: View { guard let postId = notification.data.postId, let sellerId = notification.data.sellerId, let buyerId = notification.data.buyerId else { - print("โš ๏ธ Missing data for chat navigation: postId=\(notification.data.postId ?? "nil"), sellerId=\(notification.data.sellerId ?? "nil"), buyerId=\(notification.data.buyerId ?? "nil")") // Fallback: if we have postId, at least navigate to the post if notification.data.postId != nil { await navigateToPost(notification: notification) @@ -248,14 +247,12 @@ struct NotificationsView: View { let (postRes, buyerRes, sellerRes) = try await (postResponse, buyerResponse, sellerResponse) guard let post = postRes.post else { - print("โŒ Post not found") return } let chatInfo = ChatInfo(listing: post, buyer: buyerRes.user, seller: sellerRes.user) router.push(.messages(chatInfo: chatInfo)) } catch { - print("โŒ Error navigating to chat: \(error)") // Fallback to post details if available await navigateToPost(notification: notification) } @@ -264,37 +261,25 @@ struct NotificationsView: View { @MainActor private func navigateToPost(notification: Notifications) async { guard let postId = notification.data.postId else { - print("โš ๏ธ No postId in notification") - showNavigationError("This notification doesn't have a valid post reference.") return } // Check if it looks like a valid UUID (basic check) guard postId.count > 10 && !postId.hasPrefix("test-") else { - print("โš ๏ธ Invalid postId format: \(postId)") - showNavigationError("This is a test notification with dummy data. Update your backend to use real post IDs.") return } do { let response = try await NetworkManager.shared.getPostByID(id: postId) guard let post = response.post else { - print("โŒ Post not found for id: \(postId)") - showNavigationError("The post for this notification no longer exists.") return } router.push(.productDetails(post)) } catch { - print("โŒ Error fetching post: \(error)") - showNavigationError("Couldn't load the post. It may have been deleted.") + // Silently fail - post may have been deleted } } - private func showNavigationError(_ message: String) { - // For now just print - you could show an alert instead - print("โš ๏ธ Navigation error: \(message)") - } - private func notifText(for notification: Notifications) -> some View { // 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'"