Some checks failed
CI / test (push) Has been cancelled
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
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
|
|
}
|
|
}
|