diff --git a/Lendair/Models/Notification.swift b/Lendair/Models/Notification.swift new file mode 100644 index 000000000..b89aa707d --- /dev/null +++ b/Lendair/Models/Notification.swift @@ -0,0 +1,92 @@ +import Foundation +import SwiftUI + +// MARK: - Notification Item + +struct NotificationItem: Identifiable, Equatable, Codable { + let id: String + let type: NotificationType + let title: String + let message: String + let createdAt: Date + var isRead: Bool + + enum CodingKeys: String, CodingKey { + case id, type, title, message, createdAt, isRead + } + + init(id: String, type: NotificationType, title: String, message: String, createdAt: Date, isRead: Bool) { + self.id = id + self.type = type + self.title = title + self.message = message + self.createdAt = createdAt + self.isRead = isRead + } + + static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { + lhs.id == rhs.id && lhs.isRead == rhs.isRead + } +} + +// MARK: - Notification Type + +enum NotificationType: String, CaseIterable, Codable { + case loanApproved = "LOAN_APPROVED" + case loanRejected = "LOAN_REJECTED" + case paymentReceived = "PAYMENT_RECEIVED" + case paymentDue = "PAYMENT_DUE" + case newLender = "NEW_LENDER" + case systemUpdate = "SYSTEM_UPDATE" + + var icon: String { + switch self { + case .loanApproved: return "checkmark.circle.fill" + case .loanRejected: return "xmark.circle.fill" + case .paymentReceived: return "arrow.down.circle.fill" + case .paymentDue: return "exclamationmark.circle.fill" + case .newLender: return "person.circle.fill" + case .systemUpdate: return "info.circle.fill" + } + } + + var color: Color { + switch self { + case .loanApproved: return .green + case .loanRejected: return .red + case .paymentReceived: return .green + case .paymentDue: return .orange + case .newLender: return .blue + case .systemUpdate: return .gray + } + } +} + +// MARK: - List Parameters + +struct NotificationListParams: Encodable { + var limit: Int + var offset: Int + + init(limit: Int = 20, offset: Int = 0) { + self.limit = limit + self.offset = offset + } +} + +// MARK: - API Response Types + +struct NotificationListResponse: Decodable { + let notifications: [NotificationItem] + let hasMore: Bool +} + +struct NotificationMarkAsReadResponse: Decodable { + let success: Bool + let notificationId: String +} + +struct NotificationMarkAllReadResponse: Decodable { + let success: Bool + let markedCount: Int +} diff --git a/Lendair/README.md b/Lendair/README.md index 4c4e48c9a..ea25467b3 100644 --- a/Lendair/README.md +++ b/Lendair/README.md @@ -6,59 +6,90 @@ SwiftUI implementation of the notifications feature for the Lendair iOS app. ## Architecture ### MVVM Pattern -- **View**: `NotificationsView` - Main container view -- **ViewModel**: `NotificationsViewModel` - Manages notification state and business logic -- **Service**: `NotificationsService` - Data layer for API communication +- **View**: `Views/` - SwiftUI views for notification display +- **ViewModel**: `ViewModels/` - State management and business logic +- **Service**: `Services/` - Data layer with API communication +- **Model**: `Models/` - Data structures and type definitions -### Components +### File Structure +``` +Lendair/ +├── Models/ +│ └── Notification.swift # NotificationItem, NotificationType, API response types +├── Services/ +│ └── NotificationService.swift # NotificationsServiceProtocol + implementation +├── ViewModels/ +│ └── NotificationsViewModel.swift # State management, mark-as-read actions +├── Views/ +│ ├── NotificationsView.swift # Main notifications list screen +│ └── NotificationRowView.swift # Individual notification row +└── README.md +``` -#### NotificationsView (`Views/NotificationsView.swift`) +## Components + +### NotificationsView (`Views/NotificationsView.swift`) - Main navigation container for the notifications screen -- Implements pull-to-refresh functionality -- Handles empty state display -- Provides "Mark All Read" action in toolbar -- Integrates with navigation stack +- Pull-to-refresh via `.refreshable` +- Empty state when no notifications +- "Mark All Read" toolbar button when unread count > 0 +- Tap-to-mark-as-read on individual rows +- Swipe-to-delete (TODO: backend integration) -#### NotificationRowView (`Views/NotificationRowView.swift`) +### NotificationRowView (`Views/NotificationRowView.swift`) - Individual notification list item -- Displays notification icon, title, message, and timestamp -- Shows read/unread indicator -- Supports tap-to-mark-as-read interaction +- Type-specific SF Symbol icon with color coding +- Read/unread indicator (blue dot) +- Relative timestamp display -#### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`) -- Observable object managing notification state -- Fetches notifications from service layer -- Handles mark-as-read and mark-all-as-read operations -- Calculates unread count for badge display -- Implements refresh logic +### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`) +- `@Published notifications` — sorted by createdAt descending +- `@Published isLoading` — loading state for UI feedback +- `@Published error` — typed error state (NotificationError) +- `fetchNotifications()` — loads from service +- `markAsRead(id:)` — marks single notification, updates local state +- `markAllAsRead()` — marks all unread, updates local state +- `unreadCount` — computed property for badge display + +### NotificationsService (`Services/NotificationService.swift`) +- Protocol: `NotificationsServiceProtocol` (Sendable, testable) +- `list(params:)` — GET `/api/notifications?limit=&offset=` +- `markAsRead(id:)` — PATCH `/api/notifications/:id/read` +- `markAllAsRead()` — PATCH `/api/notifications/read-all` +- Error handling: `NotificationError` enum with localized descriptions +- Configurable: baseURL, URLSession, authToken + +### Models (`Models/Notification.swift`) +- `NotificationItem` — Identifiable, Equatable, Codable +- `NotificationType` — 6 cases with icon/color mappings +- `NotificationListParams` — pagination parameters +- `NotificationListResponse`, `NotificationMarkAsReadResponse`, `NotificationMarkAllReadResponse` — API response types ## Notification Types -The app supports the following notification types: -- `LOAN_APPROVED` - Green checkmark icon -- `LOAN_REJECTED` - Red X icon -- `PAYMENT_RECEIVED` - Green down arrow icon -- `PAYMENT_DUE` - Orange exclamation icon -- `NEW_LENDER` - Blue person icon -- `SYSTEM_UPDATE` - Gray info icon +| Type | Icon | Color | +|------|------|-------| +| `LOAN_APPROVED` | checkmark.circle.fill | Green | +| `LOAN_REJECTED` | xmark.circle.fill | Red | +| `PAYMENT_RECEIVED` | arrow.down.circle.fill | Green | +| `PAYMENT_DUE` | exclamationmark.circle.fill | Orange | +| `NEW_LENDER` | person.circle.fill | Blue | +| `SYSTEM_UPDATE` | info.circle.fill | Gray | -## Integration Points +## API Endpoints -### tRPC Router (TODO) -The service layer is designed to connect to the tRPC notifications router: -```typescript -// web/src/server/api/routers/notifications.ts -notifications: router({ - list: protectedQuery(...), - markAsRead: protectedMutation(...), - markAllAsRead: protectedMutation(...), -}) -``` +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/notifications?limit=&offset=` | List notifications | +| PATCH | `/api/notifications/:id/read` | Mark single as read | +| PATCH | `/api/notifications/read-all` | Mark all as read | -### API Endpoints (TODO) -- `GET /api/notifications` - List notifications -- `PATCH /api/notifications/:id/read` - Mark single as read -- `PATCH /api/notifications/read-all` - Mark all as read +## Testing + +Tests are in `LendairTests/NotificationServiceTests.swift`: +- 12 ViewModel tests (fetch, mark-as-read, mark-all-read, unread count, refresh, error handling) +- 6 Model tests (icons, colors, equality, raw values, params) +- Uses `MockNotificationsService` conforming to `NotificationsServiceProtocol` ## Usage @@ -69,15 +100,6 @@ NavigationStack { } ``` -## Testing - -Run the preview in Xcode to see the notification row designs: -```swift -#Preview { - NotificationRowView(notification: sampleNotification) -} -``` - ## Future Enhancements 1. **Push Notifications**: Integrate with UNUserNotificationCenter diff --git a/Lendair/Services/NotificationService.swift b/Lendair/Services/NotificationService.swift new file mode 100644 index 000000000..58c642204 --- /dev/null +++ b/Lendair/Services/NotificationService.swift @@ -0,0 +1,134 @@ +import Foundation + +// MARK: - Service Protocol + +protocol NotificationsServiceProtocol: Sendable { + func list(params: NotificationListParams) async throws -> [NotificationItem] + func markAsRead(id: String) async throws + func markAllAsRead() async throws +} + +// MARK: - Default Service + +class NotificationsService: NotificationsServiceProtocol { + private let baseURL: URL + private let session: URLSession + private let authToken: String? + + init( + baseURL: URL = URL(string: "http://localhost:3000")!, + session: URLSession = .shared, + authToken: String? = nil + ) { + self.baseURL = baseURL + self.session = session + self.authToken = authToken + } + + func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { + var components = URLComponents(url: baseURL.appendingPathComponent("/api/notifications"), resolvingAgainstBaseURL: true)! + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "limit", value: String(params.limit)), + URLQueryItem(name: "offset", value: String(params.offset)) + ] + components.queryItems = queryItems + + let request = try buildRequest(url: components.url!) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + let decoded = try JSONDecoder().decode(NotificationListResponse.self, from: data) + return decoded.notifications + } + + func markAsRead(id: String) async throws { + let url = baseURL.appendingPathComponent("/api/notifications/\(id)/read") + let request = try buildRequest(url: url, method: .patch) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + _ = try JSONDecoder().decode(NotificationMarkAsReadResponse.self, from: data) + } + + func markAllAsRead() async throws { + let url = baseURL.appendingPathComponent("/api/notifications/read-all") + let request = try buildRequest(url: url, method: .patch) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + _ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data) + } + + // MARK: - Helpers + + private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body = body { + request.httpBody = body + } + + return request + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw NotificationError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + switch httpResponse.statusCode { + case 401: throw NotificationError.unauthorized + case 403: throw NotificationError.forbidden + case 404: throw NotificationError.notFound + case 429: throw NotificationError.rateLimited + case 500...599: throw NotificationError.serverError(httpResponse.statusCode) + default: throw NotificationError.httpError(httpResponse.statusCode) + } + } + } +} + +// MARK: - Error Types + +enum NotificationError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case rateLimited + case serverError(Int) + case httpError(Int) + case decodingError(Error) + + var errorDescription: String { + switch self { + case .invalidResponse: return "Invalid server response" + case .unauthorized: return "Unauthorized — please log in again" + case .forbidden: return "Forbidden — check permissions" + case .notFound: return "Notification not found" + case .rateLimited: return "Too many requests — try again shortly" + case .serverError(let code): return "Server error (\(code))" + case .httpError(let code): return "HTTP error (\(code))" + case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" + } + } +} + +// MARK: - HTTP Method + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case patch = "PATCH" + case delete = "DELETE" +} diff --git a/Lendair/ViewModels/NotificationsViewModel.swift b/Lendair/ViewModels/NotificationsViewModel.swift index 05c00c30a..35248b2c9 100644 --- a/Lendair/ViewModels/NotificationsViewModel.swift +++ b/Lendair/ViewModels/NotificationsViewModel.swift @@ -6,132 +6,64 @@ class NotificationsViewModel: ObservableObject { @Published var notifications: [NotificationItem] = [] @Published var isLoading: Bool = false @Published var lastRefreshDate: Date? - - private let notificationsService: NotificationsService - - init(notificationsService: NotificationsService = NotificationsService()) { + @Published var error: NotificationError? + + private let notificationsService: NotificationsServiceProtocol + + init(notificationsService: NotificationsServiceProtocol = NotificationsService()) { self.notificationsService = notificationsService } - + func fetchNotifications() async { isLoading = true + error = nil defer { isLoading = false lastRefreshDate = Date() } - + do { let fetchedNotifications = try await notificationsService.list() notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt } + } catch let error as NotificationError { + self.error = error } catch { print("Failed to fetch notifications: \(error)") } } - + func refresh() async { await fetchNotifications() } - + func markAsRead(id: String) async { guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } - + do { try await notificationsService.markAsRead(id: id) notifications[index].isRead = true + objectWillChange.send() } catch { print("Failed to mark notification as read: \(error)") } } - + func markAllAsRead() async { let unreadIds = notifications.filter { !$0.isRead }.map { $0.id } guard !unreadIds.isEmpty else { return } - + do { try await notificationsService.markAllAsRead() for index in notifications.indices { notifications[index].isRead = true } + objectWillChange.send() } catch { print("Failed to mark all as read: \(error)") } } - + var unreadCount: Int { notifications.filter { !$0.isRead }.count } } - -struct NotificationItem: Identifiable, Equatable { - let id: String - let type: NotificationType - let title: String - let message: String - let createdAt: Date - var isRead: Bool - - static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { - lhs.id == rhs.id && lhs.isRead == rhs.isRead - } -} - -enum NotificationType: String, CaseIterable { - case loanApproved = "LOAN_APPROVED" - case loanRejected = "LOAN_REJECTED" - case paymentReceived = "PAYMENT_RECEIVED" - case paymentDue = "PAYMENT_DUE" - case newLender = "NEW_LENDER" - case systemUpdate = "SYSTEM_UPDATE" - - var icon: String { - switch self { - case .loanApproved: - return "checkmark.circle.fill" - case .loanRejected: - return "xmark.circle.fill" - case .paymentReceived: - return "arrow.down.circle.fill" - case .paymentDue: - return "exclamationmark.circle.fill" - case .newLender: - return "person.circle.fill" - case .systemUpdate: - return "info.circle.fill" - } - } - - var color: Color { - switch self { - case .loanApproved: - return .green - case .loanRejected: - return .red - case .paymentReceived: - return .green - case .paymentDue: - return .orange - case .newLender: - return .blue - case .systemUpdate: - return .gray - } - } -} - -class NotificationsService { - func list() async throws -> [NotificationItem] { - // TODO: Connect to tRPC notifications router - // let result = await APIClient.shared.query("notifications.list") - return [] - } - - func markAsRead(id: String) async throws { - // TODO: Connect to tRPC notifications router - // try await APIClient.shared.mutation("notifications.markAsRead", { id }) - } - - func markAllAsRead() async throws { - // TODO: Connect to tRPC notifications router - // try await APIClient.shared.mutation("notifications.markAllAsRead", {}) - } -} diff --git a/LendairTests/NotificationServiceTests.swift b/LendairTests/NotificationServiceTests.swift new file mode 100644 index 000000000..5f8964399 --- /dev/null +++ b/LendairTests/NotificationServiceTests.swift @@ -0,0 +1,267 @@ +import XCTest +import SwiftUI +@testable import Lendair + +// MARK: - Mock Service + +final class MockNotificationsService: NotificationsServiceProtocol { + var notifications: [NotificationItem] = [] + var markedReadIds: [String] = [] + var markAllCalled = false + var listCallCount = 0 + var listError: Error? + + func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { + listCallCount += 1 + if let error = listError { + throw error + } + return notifications + } + + func markAsRead(id: String) async throws { + markedReadIds.append(id) + } + + func markAllAsRead() async throws { + markAllCalled = true + } +} + +// MARK: - Helper: Sample Notifications + +extension NotificationItem { + static func sample( + id: String = "test-1", + type: NotificationType = .loanApproved, + title: String = "Test", + message: String = "Test message", + isRead: Bool = false + ) -> NotificationItem { + NotificationItem( + id: id, + type: type, + title: title, + message: message, + createdAt: Date(), + isRead: isRead + ) + } +} + +// MARK: - NotificationServiceTests + +final class NotificationServiceTests: XCTestCase { + // MARK: - Fetch Notifications + + @MainActor + func testFetchNotificationsLoadsData() async { + let mock = MockNotificationsService() + mock.notifications = [.sample(id: "1"), .sample(id: "2")] + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.fetchNotifications() + + XCTAssertEqual(viewModel.notifications.count, 2) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(mock.listCallCount, 1) + } + + @MainActor + func testFetchNotificationsSortsByCreatedAtDescending() async { + let mock = MockNotificationsService() + let older = NotificationItem.sample(id: "1", createdAt: Date().addingTimeInterval(-3600)) + let newer = NotificationItem.sample(id: "2", createdAt: Date()) + mock.notifications = [newer, older] + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.fetchNotifications() + + XCTAssertEqual(viewModel.notifications.first?.id, "2") + XCTAssertEqual(viewModel.notifications.last?.id, "1") + } + + @MainActor + func testFetchNotificationsSetsLoadingState() async { + let mock = MockNotificationsService() + let viewModel = NotificationsViewModel(notificationsService: mock) + + await viewModel.fetchNotifications() + XCTAssertFalse(viewModel.isLoading) + XCTAssertNotNil(viewModel.lastRefreshDate) + } + + @MainActor + func testFetchNotificationsHandlesError() async { + let mock = MockNotificationsService() + mock.listError = NotificationError.unauthorized + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.fetchNotifications() + + XCTAssertTrue(viewModel.notifications.isEmpty) + XCTAssertFalse(viewModel.isLoading) + XCTAssertEqual(viewModel.error, .unauthorized) + } + + // MARK: - Mark As Read + + @MainActor + func testMarkAsReadUpdatesLocalState() async { + let mock = MockNotificationsService() + let unread = NotificationItem.sample(id: "1", isRead: false) + mock.notifications = [unread] + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [unread] + + await viewModel.markAsRead(id: "1") + + XCTAssertTrue(viewModel.notifications.first?.isRead == true) + XCTAssertEqual(mock.markedReadIds, ["1"]) + } + + @MainActor + func testMarkAsReadIgnoresUnknownId() async { + let mock = MockNotificationsService() + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [.sample(id: "1")] + + await viewModel.markAsRead(id: "999") + + XCTAssertTrue(mock.markedReadIds.isEmpty) + } + + @MainActor + func testMarkAsReadReducesUnreadCount() async { + let mock = MockNotificationsService() + let read = NotificationItem.sample(id: "1", isRead: true) + let unread = NotificationItem.sample(id: "2", isRead: false) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [read, unread] + + XCTAssertEqual(viewModel.unreadCount, 1) + + await viewModel.markAsRead(id: "2") + XCTAssertEqual(viewModel.unreadCount, 0) + } + + // MARK: - Mark All As Read + + @MainActor + func testMarkAllAsReadUpdatesAllNotifications() async { + let mock = MockNotificationsService() + let unread1 = NotificationItem.sample(id: "1", isRead: false) + let unread2 = NotificationItem.sample(id: "2", isRead: false) + let read = NotificationItem.sample(id: "3", isRead: true) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [unread1, unread2, read] + + await viewModel.markAllAsRead() + + XCTAssertTrue(viewModel.notifications.allSatisfy { $0.isRead }) + XCTAssertTrue(mock.markAllCalled) + XCTAssertEqual(viewModel.unreadCount, 0) + } + + @MainActor + func testMarkAllAsReadNoOpWhenAllRead() async { + let mock = MockNotificationsService() + let read1 = NotificationItem.sample(id: "1", isRead: true) + let read2 = NotificationItem.sample(id: "2", isRead: true) + + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [read1, read2] + + await viewModel.markAllAsRead() + + XCTAssertFalse(mock.markAllCalled) + } + + // MARK: - Unread Count + + @MainActor + func testUnreadCountCalculatesCorrectly() async { + let mock = MockNotificationsService() + let viewModel = NotificationsViewModel(notificationsService: mock) + viewModel.notifications = [ + NotificationItem.sample(id: "1", isRead: false), + NotificationItem.sample(id: "2", isRead: true), + NotificationItem.sample(id: "3", isRead: false), + ] + + XCTAssertEqual(viewModel.unreadCount, 2) + } + + @MainActor + func testUnreadCountIsEmptyWhenNoNotifications() async { + let mock = MockNotificationsService() + let viewModel = NotificationsViewModel(notificationsService: mock) + + XCTAssertEqual(viewModel.unreadCount, 0) + } + + // MARK: - Refresh + + @MainActor + func testRefreshReloadsData() async { + let mock = MockNotificationsService() + mock.notifications = [.sample(id: "1")] + + let viewModel = NotificationsViewModel(notificationsService: mock) + await viewModel.refresh() + + XCTAssertEqual(mock.listCallCount, 1) + XCTAssertEqual(viewModel.notifications.count, 1) + } +} + +// MARK: - NotificationModelTests + +final class NotificationModelTests: XCTestCase { + func testNotificationTypeIcons() { + XCTAssertEqual(NotificationType.loanApproved.icon, "checkmark.circle.fill") + XCTAssertEqual(NotificationType.loanRejected.icon, "xmark.circle.fill") + XCTAssertEqual(NotificationType.paymentReceived.icon, "arrow.down.circle.fill") + XCTAssertEqual(NotificationType.paymentDue.icon, "exclamationmark.circle.fill") + XCTAssertEqual(NotificationType.newLender.icon, "person.circle.fill") + XCTAssertEqual(NotificationType.systemUpdate.icon, "info.circle.fill") + } + + func testNotificationTypeColors() { + XCTAssertEqual(NotificationType.loanApproved.color, .green) + XCTAssertEqual(NotificationType.loanRejected.color, .red) + XCTAssertEqual(NotificationType.paymentReceived.color, .green) + XCTAssertEqual(NotificationType.paymentDue.color, .orange) + XCTAssertEqual(NotificationType.newLender.color, .blue) + XCTAssertEqual(NotificationType.systemUpdate.color, .gray) + } + + func testNotificationItemEquality() { + let a = NotificationItem.sample(id: "1", isRead: false) + let b = NotificationItem.sample(id: "1", isRead: false) + let c = NotificationItem.sample(id: "1", isRead: true) + + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func testNotificationTypeRawValue() { + XCTAssertEqual(NotificationType.loanApproved.rawValue, "LOAN_APPROVED") + XCTAssertEqual(NotificationType.paymentDue.rawValue, "PAYMENT_DUE") + } + + func testNotificationListParamsDefaults() { + let params = NotificationListParams() + XCTAssertEqual(params.limit, 20) + XCTAssertEqual(params.offset, 0) + } + + func testNotificationListParamsCustom() { + let params = NotificationListParams(limit: 50, offset: 100) + XCTAssertEqual(params.limit, 50) + XCTAssertEqual(params.offset, 100) + } +}