Adopt root-level tsswift app layout
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.
This commit is contained in:
2026-04-19 01:21:43 +02:00
parent d534964601
commit a6939453f8
61 changed files with 2341 additions and 3 deletions

View File

@@ -0,0 +1,589 @@
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
}()
}