import SwiftUI struct WatchRootView: View { @ObservedObject var model: AppViewModel @State private var showsQueue = false var body: some View { NavigationStack { Group { if model.session == nil { WatchPairingView(model: model) } else { if showsQueue { WatchQueueView(model: model) } else { WatchHomeView(model: model) } } } .background(Color.idpGroupedBackground.ignoresSafeArea()) } .tint(IdP.tint) .onOpenURL { url in if (url.host ?? url.lastPathComponent).lowercased() == "inbox" { showsQueue = true } } } } private struct WatchPairingView: View { @ObservedObject var model: AppViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Link your watch") .font(.headline) .foregroundStyle(.white) Text("Use the shared demo passport so approvals stay visible on your wrist.") .font(.footnote) .foregroundStyle(.white.opacity(0.72)) Button("Use demo payload") { Task { await model.signInWithSuggestedPayload() } } .buttonStyle(PrimaryActionStyle()) } .approvalCard(highlighted: true) .padding(10) .navigationTitle("idp.global") } } private struct WatchHomeView: View { @ObservedObject var model: AppViewModel var body: some View { Group { if let request = model.pendingRequests.first { WatchApprovalView(model: model, requestID: request.id) } else { WatchQueueView(model: model) } } } } struct WatchApprovalView: 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) { MonogramAvatar(title: request.watchAppDisplayName, size: 42) Text("Sign in as \(model.profile?.handle ?? "@you")?") .font(.headline) .foregroundStyle(.white) Text(request.watchLocationSummary) .font(.footnote) .foregroundStyle(.white.opacity(0.72)) HStack(spacing: 8) { Button { Task { Haptics.warning() await model.reject(request) } } label: { Image(systemName: "xmark") .frame(maxWidth: .infinity) } .buttonStyle(SecondaryActionStyle()) .frame(maxWidth: .infinity) WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { await model.approve(request) } .frame(maxWidth: .infinity) } } .approvalCard(highlighted: true) .padding(10) } .navigationTitle("Approve") .toolbar { ToolbarItem(placement: .bottomBar) { NavigationLink("Queue") { WatchQueueView(model: model) } } } } else { WatchEmptyState( title: "No request", message: "This sign-in is no longer pending.", systemImage: "checkmark.circle" ) } } } } private struct WatchQueueView: View { @ObservedObject var model: AppViewModel var body: some View { List { if model.requests.isEmpty { WatchEmptyState( title: "All clear", message: "New sign-in requests will appear on your watch here.", systemImage: "shield" ) } else { ForEach(model.requests) { request in NavigationLink { WatchRequestDetailView(model: model, requestID: request.id) } label: { WatchQueueRow(request: request) } } } } .navigationTitle("Queue") } } private struct WatchQueueRow: View { let request: ApprovalRequest var body: some View { HStack(spacing: 8) { MonogramAvatar(title: request.watchAppDisplayName, size: 22) VStack(alignment: .leading, spacing: 2) { Text(request.watchAppDisplayName) .font(.footnote.weight(.semibold)) .foregroundStyle(.white) Text(request.createdAt, style: .time) .font(.caption2) .foregroundStyle(.white.opacity(0.68)) } } .padding(.vertical, 2) } } 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) { RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") Text(request.watchTrustExplanation) .font(.footnote) .foregroundStyle(.white.opacity(0.72)) if request.status == .pending { WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { await model.approve(request) } Button("Deny") { Task { Haptics.warning() await model.reject(request) } } .buttonStyle(SecondaryActionStyle()) } } .padding(10) } } else { WatchEmptyState( title: "No request", message: "This sign-in is no longer pending.", systemImage: "shield" ) } } .navigationTitle("Details") } } private struct WatchHoldToApproveButton: View { var isBusy = false let action: () async -> Void @State private var progress: CGFloat = 0 var body: some View { ZStack { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .fill(isBusy ? Color.white.opacity(0.18) : IdP.tint) RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .stroke(Color.white.opacity(0.16), lineWidth: 1) Text(isBusy ? "Working…" : "Approve") .font(.headline) .foregroundStyle(.white) .padding(.vertical, 12) RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(2) } .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 18, pressing: updateProgress) { guard !isBusy else { return } Task { Haptics.success() await action() progress = 0 } } .watchPrimaryActionGesture() .accessibilityAddTraits(.isButton) .accessibilityHint("Press and hold to approve the sign-in request.") } private func updateProgress(_ isPressing: Bool) { guard !isBusy else { return } withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) { progress = isPressing ? 1 : 0 } } } private extension View { @ViewBuilder func watchPrimaryActionGesture() -> some View { if #available(watchOS 11.0, *) { self.handGestureShortcut(.primaryAction) } else { self } } } private extension ApprovalRequest { var watchAppDisplayName: String { source.replacingOccurrences(of: "auth.", with: "") } var watchTrustExplanation: String { risk == .elevated ? "This request needs a higher-assurance proof before it can continue." : "This request matches a familiar device and sign-in pattern." } var watchLocationSummary: String { "Berlin, DE" } } private struct WatchEmptyState: View { let title: String let message: String let systemImage: String var body: some View { ContentUnavailableView { Label(title, systemImage: systemImage) } description: { Text(message) } } } #Preview("Watch Approval Light") { WatchApprovalPreviewHost() } #Preview("Watch Approval Dark") { WatchApprovalPreviewHost() .preferredColorScheme(.dark) } @MainActor private struct WatchApprovalPreviewHost: View { @State private var model = WatchPreviewFixtures.model() var body: some View { WatchApprovalView(model: model, requestID: WatchPreviewFixtures.requests[0].id) } } private enum WatchPreviewFixtures { static let profile = MemberProfile( name: "Jurgen Meyer", handle: "@jurgen", organization: "idp.global", deviceCount: 3, recoverySummary: "Recovery kit healthy." ) static let session = AuthSession( deviceName: "Apple Watch", originHost: "github.com", pairedAt: .now.addingTimeInterval(-60 * 45), tokenPreview: "berlin", pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=Apple%20Watch", pairingTransport: .preview ) static let requests: [ApprovalRequest] = [ ApprovalRequest( title: "GitHub sign-in", subtitle: "A sign-in request is waiting on your iPhone.", source: "github.com", createdAt: .now.addingTimeInterval(-60 * 2), kind: .signIn, risk: .routine, scopes: ["profile", "email"], status: .pending ) ] @MainActor static func model() -> AppViewModel { let model = AppViewModel( service: MockIDPService.shared, notificationCoordinator: WatchPreviewCoordinator(), appStateStore: WatchPreviewStore(), launchArguments: [] ) model.session = session model.profile = profile model.requests = requests model.notifications = [] model.notificationPermission = .allowed return model } } private struct WatchPreviewCoordinator: NotificationCoordinating { func authorizationStatus() async -> NotificationPermissionState { .allowed } func requestAuthorization() async throws -> NotificationPermissionState { .allowed } func scheduleTestNotification(title: String, body: String) async throws {} } private struct WatchPreviewStore: AppStateStoring { func load() -> PersistedAppState? { nil } func save(_ state: PersistedAppState) {} func clear() {} }