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