331 lines
9.6 KiB
Swift
331 lines
9.6 KiB
Swift
import SwiftUI
|
|
|
|
struct RequestList: View {
|
|
let requests: [ApprovalRequest]
|
|
let compactLayout: Bool
|
|
let activeRequestID: ApprovalRequest.ID?
|
|
let onApprove: ((ApprovalRequest) -> Void)?
|
|
let onReject: ((ApprovalRequest) -> Void)?
|
|
let onOpenRequest: (ApprovalRequest) -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 14) {
|
|
ForEach(requests) { request in
|
|
RequestCard(
|
|
request: request,
|
|
compactLayout: compactLayout,
|
|
isBusy: activeRequestID == request.id,
|
|
onApprove: onApprove == nil ? nil : { onApprove?(request) },
|
|
onReject: onReject == nil ? nil : { onReject?(request) },
|
|
onOpenRequest: { onOpenRequest(request) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestCard: View {
|
|
let request: ApprovalRequest
|
|
let compactLayout: Bool
|
|
let isBusy: Bool
|
|
let onApprove: (() -> Void)?
|
|
let onReject: (() -> Void)?
|
|
let onOpenRequest: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: request.kind.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(requestAccent)
|
|
.frame(width: 28, height: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.title)
|
|
.font(.headline)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Text(request.source)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
AppStatusTag(title: request.status.title, tone: statusTone)
|
|
}
|
|
|
|
Text(request.subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
|
|
HStack(spacing: 8) {
|
|
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
|
Text(request.scopeSummary)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 0)
|
|
Text(request.createdAt, style: .relative)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if !request.scopes.isEmpty {
|
|
Text("Proof details: \(request.scopes.joined(separator: ", "))")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
controls
|
|
}
|
|
.padding(compactLayout ? 18 : 20)
|
|
.appSurface(radius: 24)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var controls: some View {
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
reviewButton
|
|
decisionButtons
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
reviewButton
|
|
Spacer(minLength: 0)
|
|
decisionButtons
|
|
}
|
|
}
|
|
}
|
|
|
|
private var reviewButton: some View {
|
|
Button {
|
|
onOpenRequest()
|
|
} label: {
|
|
Label("Review proof", systemImage: "arrow.up.forward.app")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var decisionButtons: some View {
|
|
if request.status == .pending, let onApprove, let onReject {
|
|
Button {
|
|
onApprove()
|
|
} label: {
|
|
if isBusy {
|
|
ProgressView()
|
|
} else {
|
|
Label("Verify", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isBusy)
|
|
|
|
Button(role: .destructive) {
|
|
onReject()
|
|
} label: {
|
|
Label("Decline", systemImage: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isBusy)
|
|
}
|
|
}
|
|
|
|
private var statusTone: Color {
|
|
switch request.status {
|
|
case .pending:
|
|
.orange
|
|
case .approved:
|
|
.green
|
|
case .rejected:
|
|
.red
|
|
}
|
|
}
|
|
|
|
private var requestAccent: Color {
|
|
switch request.status {
|
|
case .approved:
|
|
.green
|
|
case .rejected:
|
|
.red
|
|
case .pending:
|
|
request.risk == .routine ? dashboardAccent : .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NotificationList: View {
|
|
let notifications: [AppNotification]
|
|
let compactLayout: Bool
|
|
let onMarkRead: (AppNotification) -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 14) {
|
|
ForEach(notifications) { notification in
|
|
NotificationCard(
|
|
notification: notification,
|
|
compactLayout: compactLayout,
|
|
onMarkRead: { onMarkRead(notification) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NotificationCard: View {
|
|
let notification: AppNotification
|
|
let compactLayout: Bool
|
|
let onMarkRead: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: notification.kind.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(accentColor)
|
|
.frame(width: 28, height: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 8) {
|
|
AppStatusTag(title: notification.kind.title, tone: accentColor)
|
|
if notification.isUnread {
|
|
AppStatusTag(title: "Unread", tone: .orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
Text(notification.message)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
timestamp
|
|
if notification.isUnread {
|
|
markReadButton
|
|
}
|
|
}
|
|
} else {
|
|
HStack {
|
|
timestamp
|
|
Spacer(minLength: 0)
|
|
if notification.isUnread {
|
|
markReadButton
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 18 : 20)
|
|
.appSurface(radius: 24)
|
|
}
|
|
|
|
private var timestamp: some View {
|
|
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
private var markReadButton: some View {
|
|
Button {
|
|
onMarkRead()
|
|
} label: {
|
|
Label("Mark read", systemImage: "checkmark")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
switch notification.kind {
|
|
case .approval:
|
|
.green
|
|
case .security:
|
|
.orange
|
|
case .system:
|
|
.blue
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NotificationBellButton: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Button {
|
|
model.isNotificationCenterPresented = true
|
|
} label: {
|
|
Image(systemName: imageName)
|
|
.font(.headline)
|
|
.foregroundStyle(iconTone)
|
|
.frame(width: 28, height: 28, alignment: .center)
|
|
.background(alignment: .center) {
|
|
#if os(iOS)
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
.accessibilityLabel("Notifications")
|
|
}
|
|
|
|
private var imageName: String {
|
|
#if os(iOS)
|
|
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
|
|
#else
|
|
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
|
|
#endif
|
|
}
|
|
|
|
private var iconTone: some ShapeStyle {
|
|
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
|
|
}
|
|
}
|
|
|
|
struct NotificationCenterSheet: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
AppScrollScreen(
|
|
compactLayout: compactLayout,
|
|
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
|
) {
|
|
NotificationsPanel(model: model, compactLayout: compactLayout)
|
|
}
|
|
.navigationTitle("Notifications")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
|
|
#endif
|
|
}
|
|
|
|
private var compactLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|