Files
swiftapp/swift/WatchApp/Features/WatchRootView.swift
Jürgen Kunz 61a0cc1f7d
Some checks failed
CI / test (push) Has been cancelled
Overhaul native approval UX and add widget surfaces
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
2026-04-19 16:29:13 +02:00

392 lines
12 KiB
Swift

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