Ship full-featured mobile clients with auth, payments, QRs, push notifications, and iOS polish including Figtree typography, app icons, and QR previews. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class NotificationsViewModel: ObservableObject {
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var unreadCount = 0
|
||||
@Published var loading = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var error: String?
|
||||
|
||||
func load() async {
|
||||
loading = notifications.isEmpty
|
||||
error = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
let response = try await APIClient.shared.notifications()
|
||||
notifications = response.data
|
||||
unreadCount = response.unreadCount ?? 0
|
||||
} catch {
|
||||
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
await load()
|
||||
}
|
||||
|
||||
func markAllRead() async {
|
||||
try? await APIClient.shared.markAllNotificationsRead()
|
||||
await load()
|
||||
}
|
||||
|
||||
func markRead(_ notification: AppNotification) async {
|
||||
guard notification.readAt == nil else { return }
|
||||
try? await APIClient.shared.markNotificationRead(id: notification.id)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsSheet: View {
|
||||
let onDismiss: () -> Void
|
||||
let onUnreadChanged: (Int) -> Void
|
||||
|
||||
@StateObject private var vm = NotificationsViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if vm.loading {
|
||||
LoadingView()
|
||||
} else if let error = vm.error {
|
||||
ErrorView(message: error) { Task { await vm.load() } }
|
||||
} else if vm.notifications.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bell.slash")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(LadillColors.muted)
|
||||
Text("You're all caught up")
|
||||
.font(.ladill(size: 16, weight: .semibold))
|
||||
Text("Payment alerts and account updates will show up here.")
|
||||
.font(.ladill(size: 14))
|
||||
.foregroundStyle(LadillColors.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(32)
|
||||
} else {
|
||||
List(vm.notifications) { notification in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(notification.title)
|
||||
.font(.ladill(size: 15, weight: notification.isRead ? .regular : .semibold))
|
||||
Text(notification.message)
|
||||
.font(.ladill(size: 13))
|
||||
.foregroundStyle(LadillColors.muted)
|
||||
if let date = notification.createdAt {
|
||||
Text(Formatters.date(iso: date))
|
||||
.font(.ladill(size: 11))
|
||||
.foregroundStyle(LadillColors.subtle)
|
||||
}
|
||||
}
|
||||
.opacity(notification.isRead ? 0.7 : 1)
|
||||
.onTapGesture { Task { await vm.markRead(notification) } }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(LadillColors.page)
|
||||
.navigationTitle("Notifications")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if vm.unreadCount > 0 {
|
||||
Button("Mark all read") { Task { await vm.markAllRead() } }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
onUnreadChanged(vm.unreadCount)
|
||||
dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await vm.refresh() }
|
||||
.task { await vm.load() }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user