Files
swiftapp/swift/WatchApp/Features/WatchRootView.swift
Jürgen Kunz a6939453f8
Some checks failed
CI / test (push) Has been cancelled
Adopt root-level tsswift app layout
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.
2026-04-19 01:21:43 +02:00

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
}()
}