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.
590 lines
21 KiB
Swift
590 lines
21 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
private let watchAccent = AppTheme.accent
|
|
private let watchGold = AppTheme.warmAccent
|
|
|
|
struct WatchRootView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if model.session == nil {
|
|
WatchPairingView(model: model)
|
|
} else {
|
|
WatchDashboardView(model: model)
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.tint(watchAccent)
|
|
}
|
|
}
|
|
|
|
private struct WatchPairingView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AppBadge(title: "Preview passport", tone: watchAccent)
|
|
|
|
Text("Prove identity from your wrist")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
HStack(spacing: 8) {
|
|
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
|
|
AppStatusTag(title: "Proof focus", tone: watchGold)
|
|
}
|
|
}
|
|
.watchCard()
|
|
|
|
if model.isBootstrapping {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.tint(watchAccent)
|
|
Text("Preparing preview passport...")
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.watchCard()
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await model.signInWithSuggestedPayload()
|
|
}
|
|
} label: {
|
|
if model.isAuthenticating {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Label("Link Preview Passport", systemImage: "applewatch")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(watchAccent)
|
|
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("What this watch does")
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
|
|
WatchSetupFeatureRow(
|
|
systemImage: "checkmark.shield",
|
|
title: "Review identity checks",
|
|
subtitle: "See pending proof prompts quickly."
|
|
)
|
|
|
|
WatchSetupFeatureRow(
|
|
systemImage: "bell.badge",
|
|
title: "Surface important alerts",
|
|
subtitle: "Keep passport activity visible at a glance."
|
|
)
|
|
|
|
WatchSetupFeatureRow(
|
|
systemImage: "iphone.radiowaves.left.and.right",
|
|
title: "Stay in sync with the phone preview",
|
|
subtitle: "Use the same mocked passport context."
|
|
)
|
|
}
|
|
.watchCard()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 6)
|
|
.padding(.bottom, 20)
|
|
}
|
|
.background(Color.black.ignoresSafeArea())
|
|
.navigationTitle("Link Watch")
|
|
}
|
|
}
|
|
|
|
private struct WatchSetupFeatureRow: View {
|
|
let systemImage: String
|
|
let title: String
|
|
let subtitle: String
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: systemImage)
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(watchAccent)
|
|
.frame(width: 18, height: 18)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func watchCard() -> some View {
|
|
padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct WatchDashboardView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
WatchPassportCard(model: model)
|
|
.watchCard()
|
|
|
|
WatchSectionHeader(
|
|
title: "Pending",
|
|
detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)"
|
|
)
|
|
|
|
if model.pendingRequests.isEmpty {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("No checks waiting.")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("New identity checks will appear here when a site or device asks you to prove it is really you.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
|
|
Button("Seed Identity Check") {
|
|
Task {
|
|
await model.simulateIncomingRequest()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(watchAccent)
|
|
}
|
|
.watchCard()
|
|
} else {
|
|
ForEach(model.pendingRequests) { request in
|
|
NavigationLink {
|
|
WatchRequestDetailView(model: model, requestID: request.id)
|
|
} label: {
|
|
WatchRequestRow(request: request)
|
|
.watchCard()
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
WatchSectionHeader(title: "Activity")
|
|
|
|
if model.notifications.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("No recent alerts.")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Passport activity and security events will show up here.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
}
|
|
.watchCard()
|
|
} else {
|
|
ForEach(model.notifications.prefix(3)) { notification in
|
|
NavigationLink {
|
|
WatchNotificationDetailView(model: model, notificationID: notification.id)
|
|
} label: {
|
|
WatchNotificationRow(notification: notification)
|
|
.watchCard()
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
WatchSectionHeader(title: "Actions")
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Button("Refresh") {
|
|
Task {
|
|
await model.refreshDashboard()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(watchAccent)
|
|
.disabled(model.isRefreshing)
|
|
|
|
Button("Send Test Alert") {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
|
Button("Enable Alerts") {
|
|
Task {
|
|
await model.requestNotificationAccess()
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
Button("Sign Out", role: .destructive) {
|
|
model.signOut()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.watchCard()
|
|
|
|
if let profile = model.profile {
|
|
WatchSectionHeader(title: "Identity")
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(profile.handle)
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(profile.organization)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
|
|
Text("Notifications: \(model.notificationPermission.title)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
}
|
|
.watchCard()
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 20)
|
|
}
|
|
.background(Color.black.ignoresSafeArea())
|
|
.navigationTitle("Passport")
|
|
.refreshable {
|
|
await model.refreshDashboard()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchSectionHeader: View {
|
|
let title: String
|
|
var detail: String? = nil
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(title)
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
|
|
if let detail, !detail.isEmpty {
|
|
Text(detail)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
}
|
|
}
|
|
.padding(.horizontal, 2)
|
|
}
|
|
}
|
|
|
|
private struct WatchPassportCard: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AppBadge(title: "Passport active", tone: watchAccent)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(model.profile?.name ?? "Preview Session")
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
Text(model.pairedDeviceSummary)
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
if let session = model.session {
|
|
Text("Via \(session.pairingTransport.title)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent)
|
|
WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold)
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
private struct WatchMetricPill: View {
|
|
let title: String
|
|
let value: String
|
|
let accent: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(value)
|
|
.font(.headline.monospacedDigit())
|
|
.foregroundStyle(.white)
|
|
Text(title)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct WatchRequestRow: View {
|
|
let request: ApprovalRequest
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Text(request.title)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer(minLength: 6)
|
|
|
|
Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
|
|
.foregroundStyle(request.risk == .elevated ? .orange : watchAccent)
|
|
}
|
|
|
|
Text(request.source)
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
HStack(spacing: 8) {
|
|
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? watchAccent : .orange)
|
|
AppStatusTag(title: request.status.title, tone: request.status == .pending ? .orange : watchAccent)
|
|
}
|
|
|
|
Text(request.createdAt.watchRelativeString)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchNotificationRow: View {
|
|
let notification: AppNotification
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer(minLength: 6)
|
|
|
|
if notification.isUnread {
|
|
Circle()
|
|
.fill(watchAccent)
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
}
|
|
|
|
Text(notification.message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
.lineLimit(2)
|
|
|
|
Text(notification.sentAt.watchRelativeString)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchRequestDetailView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let requestID: ApprovalRequest.ID
|
|
|
|
private var request: ApprovalRequest? {
|
|
model.requests.first(where: { $0.id == requestID })
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let request {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
detailHeader(
|
|
title: request.title,
|
|
subtitle: request.source,
|
|
badge: request.status.title
|
|
)
|
|
|
|
Text(request.subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Trust Summary")
|
|
.font(.headline)
|
|
Text(request.trustHeadline)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(request.trustDetail)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Text(request.risk.guidance)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(10)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
|
|
if !request.scopes.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Scopes")
|
|
.font(.headline)
|
|
|
|
ForEach(request.scopes, id: \.self) { scope in
|
|
Label(scope, systemImage: "checkmark.seal.fill")
|
|
.font(.footnote)
|
|
}
|
|
}
|
|
}
|
|
|
|
if request.status == .pending {
|
|
if model.activeRequestID == request.id {
|
|
ProgressView("Updating proof...")
|
|
} else {
|
|
Button("Verify") {
|
|
Task {
|
|
await model.approve(request)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button("Decline", role: .destructive) {
|
|
Task {
|
|
await model.reject(request)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.bottom, 20)
|
|
}
|
|
} else {
|
|
Text("This request is no longer available.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.navigationTitle("Identity Check")
|
|
}
|
|
|
|
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Text(subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(badge)
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(watchAccent.opacity(0.14), in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchNotificationDetailView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let notificationID: AppNotification.ID
|
|
|
|
private var notification: AppNotification? {
|
|
model.notifications.first(where: { $0.id == notificationID })
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let notification {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
Text(notification.kind.title)
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(watchAccent)
|
|
Text(notification.sentAt.watchRelativeString)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text(notification.message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Alert posture")
|
|
.font(.headline)
|
|
Text(model.notificationPermission.summary)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(10)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
|
|
if notification.isUnread {
|
|
Button("Mark Read") {
|
|
Task {
|
|
await model.markNotificationRead(notification)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.bottom, 20)
|
|
}
|
|
} else {
|
|
Text("This activity item has already been cleared.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.navigationTitle("Activity")
|
|
}
|
|
}
|
|
|
|
private extension Date {
|
|
var watchRelativeString: String {
|
|
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
|
|
}
|
|
}
|
|
|
|
private enum WatchFormatters {
|
|
static let relative: RelativeDateTimeFormatter = {
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .abbreviated
|
|
return formatter
|
|
}()
|
|
}
|