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:
87
Lendair/README.md
Normal file
87
Lendair/README.md
Normal 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
|
||||||
137
Lendair/ViewModels/NotificationsViewModel.swift
Normal file
137
Lendair/ViewModels/NotificationsViewModel.swift
Normal 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", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Lendair/Views/NotificationRowView.swift
Normal file
89
Lendair/Views/NotificationRowView.swift
Normal 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")
|
||||||
|
}
|
||||||
103
Lendair/Views/NotificationsView.swift
Normal file
103
Lendair/Views/NotificationsView.swift
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user