Files
swiftapp/swift/WatchApp/Features/WatchRootView.swift
T
jkunz 271d9657bf
CI / test (push) Has been cancelled
Refine inbox and watch approval presentation
Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
2026-04-19 21:50:03 +02:00

509 lines
16 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.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() {}
}