diff --git a/Lendair/README.md b/Lendair/README.md new file mode 100644 index 000000000..4c4e48c9a --- /dev/null +++ b/Lendair/README.md @@ -0,0 +1,87 @@ +# Lendair iOS Notifications + +## Overview +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 + +### 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 + +#### 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 + +#### 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 + +## 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 + +## Integration Points + +### 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(...), +}) +``` + +### 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 + +## Usage + +```swift +// In your MainTabView or navigation stack +NavigationStack { + NotificationsView() +} +``` + +## 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 +2. **Notification Preferences**: Allow users to customize notification types +3. **Deep Linking**: Navigate to relevant screens when tapping notifications +4. **Offline Support**: Cache notifications locally with Core Data +5. **Analytics**: Track notification engagement metrics diff --git a/Lendair/ViewModels/NotificationsViewModel.swift b/Lendair/ViewModels/NotificationsViewModel.swift new file mode 100644 index 000000000..05c00c30a --- /dev/null +++ b/Lendair/ViewModels/NotificationsViewModel.swift @@ -0,0 +1,137 @@ +import Foundation +import SwiftUI + +@MainActor +class NotificationsViewModel: ObservableObject { + @Published var notifications: [NotificationItem] = [] + @Published var isLoading: Bool = false + @Published var lastRefreshDate: Date? + + private let notificationsService: NotificationsService + + init(notificationsService: NotificationsService = NotificationsService()) { + self.notificationsService = notificationsService + } + + func fetchNotifications() async { + isLoading = true + defer { + isLoading = false + lastRefreshDate = Date() + } + + do { + let fetchedNotifications = try await notificationsService.list() + notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt } + } 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 + } 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 + } + } 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/Lendair/Views/NotificationRowView.swift b/Lendair/Views/NotificationRowView.swift new file mode 100644 index 000000000..9d1ab4d8b --- /dev/null +++ b/Lendair/Views/NotificationRowView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct NotificationRowView: View { + let notification: NotificationItem + + var body: some View { + HStack(spacing: 12) { + // Notification icon + Image(systemName: notification.type.icon) + .font(.system(size: 24)) + .foregroundColor(notification.type.color) + .accessibilityLabel(notification.type.rawValue) + + // Notification content + VStack(alignment: .leading, spacing: 4) { + Text(notification.title) + .font(.headline) + .foregroundColor(.primary) + + Text(notification.message) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer() + + // Timestamp and read indicator + VStack(alignment: .trailing, spacing: 4) { + if !notification.isRead { + Image(systemName: "circle.fill") + .font(.system(size: 8)) + .foregroundColor(.blue) + } + + Text(formatTimestamp(notification.createdAt)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + + private func formatTimestamp(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +#Preview { + List { + NotificationRowView( + notification: NotificationItem( + id: "1", + type: .loanApproved, + title: "Loan Approved", + message: "Your loan application for $500 has been approved by Sarah Johnson.", + createdAt: Date().addingTimeInterval(-3600), + isRead: false + ) + ) + + NotificationRowView( + notification: NotificationItem( + id: "2", + type: .paymentDue, + title: "Payment Due Soon", + message: "Your payment of $150 is due in 3 days.", + createdAt: Date().addingTimeInterval(-86400 * 2), + isRead: true + ) + ) + + NotificationRowView( + notification: NotificationItem( + id: "3", + type: .paymentReceived, + title: "Payment Received", + message: "You received a payment of $75 from Michael Chen.", + createdAt: Date().addingTimeInterval(-86400 * 5), + isRead: false + ) + ) + } + .listStyle(.insetGrouped) + .previewDisplayName("Notification Row Preview") +} diff --git a/Lendair/Views/NotificationsView.swift b/Lendair/Views/NotificationsView.swift new file mode 100644 index 000000000..696288492 --- /dev/null +++ b/Lendair/Views/NotificationsView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct NotificationsView: View { + @StateObject private var viewModel = NotificationsViewModel() + @State private var showingRefreshIndicator = false + + var body: some View { + NavigationView { + Group { + if viewModel.notifications.isEmpty && !viewModel.isLoading { + emptyStateView + } else { + notificationListView + } + } + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !viewModel.notifications.isEmpty { + ToolbarItem(placement: .navigationBarTrailing) { + if viewModel.unreadCount > 0 { + Button { + Task { + await viewModel.markAllAsRead() + } + } label: { + Text("Mark All Read") + .font(.caption) + } + .foregroundColor(.blue) + } + } + } + } + } + .onAppear { + Task { + await viewModel.fetchNotifications() + } + } + } + + @ViewBuilder + private var notificationListView: some View { + List { + ForEach(viewModel.notifications) { notification in + NotificationRowView(notification: notification) + .onTapGesture { + Task { + if !notification.isRead { + await viewModel.markAsRead(id: notification.id) + } + } + } + } + .onDelete(perform: deleteNotifications) + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.refresh() + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "bell.slash") + .font(.system(size: 64)) + .foregroundColor(.secondary) + + Text("No Notifications") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("You're all caught up!\nWhen you have notifications, they'll appear here.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .padding(.vertical, 60) + } + + private func deleteNotifications(at offsets: IndexSet) async { + // TODO: Implement notification deletion logic + // This would typically call a delete API endpoint + for index in offsets { + let notification = viewModel.notifications[index] + // await notificationsService.delete(id: notification.id) + } + } +} + +#Preview { + NotificationsView() +} + +#Preview("With Data") { + let previewView = NotificationsView() + + // Inject mock data for preview + return previewView +}