Some checks failed
CI / test (push) Has been cancelled
Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
509 lines
16 KiB
Swift
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() {}
|
|
}
|