feat: Implement NotificationsView component for Lendair iOS

- Create NotificationsView.swift with SwiftUI List and pull-to-refresh
- Create NotificationRowView.swift for individual notification items
- Create NotificationsViewModel.swift with MVVM pattern
- Implement empty state view for no notifications
- Add mark-as-read and mark-all-as-read functionality
- Support notification types: loan approved/rejected, payment received/due, new lender, system updates
- Add toolbar action for marking all notifications as read
- Include README.md with architecture documentation and integration guide

Next: Connect tRPC notifications router for data fetching
This commit is contained in:
2026-05-03 12:11:00 -04:00
parent 428ab17539
commit 4f1ff9dbb0
4 changed files with 416 additions and 0 deletions

87
Lendair/README.md Normal file
View File

@@ -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

View File

@@ -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", {})
}
}

View File

@@ -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")
}

View File

@@ -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
}