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.black.ignoresSafeArea()) } .tint(IdP.tint) .preferredColorScheme(.dark) .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: 10) { WatchBadge(title: "PAIR · STEP 1", tone: .accent) Text("Link your watch") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) Text("Use the shared demo passport so approvals stay visible on your wrist.") .font(.footnote) .foregroundStyle(Color.idpMutedForeground) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) Button("Use demo payload") { Task { await model.signInWithSuggestedPayload() } } .buttonStyle(PrimaryActionStyle()) } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .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 }) } @ViewBuilder private func signInPrompt(handle: String) -> some View { var attributed = AttributedString("Sign in as \(handle)?") attributed.font = .system(size: 15, weight: .semibold) attributed.foregroundColor = .white if let range = attributed.range(of: handle) { attributed[range].foregroundColor = IdP.tint } return Text(attributed) .lineLimit(2) .minimumScaleFactor(0.8) } var body: some View { Group { if let request { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { MonogramAvatar( title: request.watchAppDisplayName, size: 18, tint: BrandTint.color(for: request.watchAppDisplayName) ) Text(request.watchAppDisplayName) .font(.system(size: 11, weight: .medium)) .foregroundStyle(Color.idpMutedForeground) .lineLimit(1) } signInPrompt(handle: model.profile?.handle ?? "@you") HStack(spacing: 3) { Image(systemName: "location.fill") .font(.system(size: 8)) Text("\(request.watchLocationSummary) · now") } .font(.system(size: 10)) .foregroundStyle(Color.idpMutedForeground) Spacer(minLength: 4) GeometryReader { geo in HStack(spacing: 5) { Button { Task { Haptics.warning() await model.reject(request) } } label: { Image(systemName: "xmark") .font(.footnote.weight(.semibold)) } .buttonStyle(SecondaryActionStyle()) .frame(width: (geo.size.width - 5) / 3) WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { await model.approve(request) } } } .frame(height: 36) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.top, 4) .padding(.bottom, 6) .navigationBarTitleDisplayMode(.inline) .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 { ScrollView { VStack(alignment: .leading, spacing: 6) { Text("INBOX · \(model.requests.count)") .font(.system(size: 10, weight: .bold)) .tracking(0.6) .foregroundStyle(IdP.tint) .padding(.horizontal, 4) .padding(.top, 2) if model.requests.isEmpty { WatchEmptyState( title: "All clear", message: "New sign-in requests appear here.", systemImage: "shield" ) } else { ForEach(model.requests) { request in NavigationLink { WatchRequestDetailView(model: model, requestID: request.id) } label: { WatchQueueRow(request: request) } .buttonStyle(.plain) } } } .padding(.horizontal, 6) .padding(.bottom, 8) } .scrollIndicators(.hidden) .navigationTitle("Queue") } } private struct WatchQueueRow: View { let request: ApprovalRequest var body: some View { HStack(spacing: 6) { MonogramAvatar( title: request.watchAppDisplayName, size: 20, tint: BrandTint.color(for: request.watchAppDisplayName) ) VStack(alignment: .leading, spacing: 0) { Text(request.watchAppDisplayName) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) .lineLimit(1) Text(request.kind.title) .font(.system(size: 9)) .foregroundStyle(Color.idpMutedForeground) .lineLimit(1) } Spacer(minLength: 4) Text(relativeTime) .font(.system(size: 9)) .foregroundStyle(Color.idpMutedForeground) } .padding(6) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) .stroke(Color.white.opacity(0.12), lineWidth: 1) ) } private var relativeTime: String { let seconds = Int(Date.now.timeIntervalSince(request.createdAt)) if seconds < 60 { return "now" } if seconds < 3600 { return "\(seconds / 60)m" } if seconds < 86_400 { return "\(seconds / 3600)h" } return "\(seconds / 86_400)d" } } 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: 10) { RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") Text(request.watchTrustExplanation) .font(.footnote) .foregroundStyle(Color.idpMutedForeground) .fixedSize(horizontal: false, vertical: true) 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(8) } } 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) : Color.idpPrimary) if isBusy { Text("Working…") .font(.footnote.weight(.semibold)) .foregroundStyle(.white) } else { HStack(spacing: 4) { Image(systemName: "checkmark") Text("Approve") } .font(.footnote.weight(.semibold)) .foregroundStyle(Color.idpPrimaryForeground) } RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) .stroke(IdP.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(1.5) } .frame(height: 36) .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" } } private struct WatchEmptyState: View { let title: String let message: String let systemImage: String var body: some View { VStack(alignment: .leading, spacing: 6) { Image(systemName: systemImage) .font(.title3) .foregroundStyle(Color.idpMutedForeground) Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) Text(message) .font(.footnote) .foregroundStyle(Color.idpMutedForeground) .fixedSize(horizontal: false, vertical: true) } .padding(8) } } #Preview("Watch Approval") { WatchApprovalPreviewHost() .preferredColorScheme(.dark) } #Preview("Watch Queue") { NavigationStack { WatchQueuePreviewHost() } .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) } } @MainActor private struct WatchQueuePreviewHost: View { @State private var model = WatchPreviewFixtures.model() var body: some View { WatchQueueView(model: model) } } 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 ), ApprovalRequest( title: "Lufthansa sign-in", subtitle: "Verify identity", source: "lufthansa.com", createdAt: .now.addingTimeInterval(-60 * 4), kind: .accessGrant, risk: .routine, scopes: ["profile"], status: .pending ), ApprovalRequest( title: "Hetzner", subtitle: "Console", source: "hetzner.cloud", createdAt: .now.addingTimeInterval(-60 * 8), kind: .elevatedAction, risk: .elevated, scopes: ["device"], 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() {} }