Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let watchAccent = AppTheme.accent
|
||||
private let watchGold = AppTheme.warmAccent
|
||||
|
||||
struct WatchRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@State private var showsQueue = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -13,12 +10,21 @@ struct WatchRootView: View {
|
||||
if model.session == nil {
|
||||
WatchPairingView(model: model)
|
||||
} else {
|
||||
WatchDashboardView(model: model)
|
||||
if showsQueue {
|
||||
WatchQueueView(model: model)
|
||||
} else {
|
||||
WatchHomeView(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color.idpGroupedBackground.ignoresSafeArea())
|
||||
}
|
||||
.tint(IdP.tint)
|
||||
.onOpenURL { url in
|
||||
if (url.host ?? url.lastPathComponent).lowercased() == "inbox" {
|
||||
showsQueue = true
|
||||
}
|
||||
}
|
||||
.tint(watchAccent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,395 +32,148 @@ private struct WatchPairingView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AppBadge(title: "Preview passport", tone: watchAccent)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Link your watch")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Prove identity from your wrist")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Use the shared demo passport so approvals stay visible on your wrist.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
|
||||
AppStatusTag(title: "Proof focus", tone: watchGold)
|
||||
}
|
||||
Button("Use demo payload") {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
.watchCard()
|
||||
|
||||
if model.isBootstrapping {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(watchAccent)
|
||||
Text("Preparing preview passport...")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link Preview Passport", systemImage: "applewatch")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(watchAccent)
|
||||
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("What this watch does")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "checkmark.shield",
|
||||
title: "Review identity checks",
|
||||
subtitle: "See pending proof prompts quickly."
|
||||
)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "bell.badge",
|
||||
title: "Surface important alerts",
|
||||
subtitle: "Keep passport activity visible at a glance."
|
||||
)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "iphone.radiowaves.left.and.right",
|
||||
title: "Stay in sync with the phone preview",
|
||||
subtitle: "Use the same mocked passport context."
|
||||
)
|
||||
}
|
||||
.watchCard()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 20)
|
||||
.buttonStyle(PrimaryActionStyle())
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.navigationTitle("Link Watch")
|
||||
.approvalCard(highlighted: true)
|
||||
.padding(10)
|
||||
.navigationTitle("idp.global")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchSetupFeatureRow: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
private struct WatchHomeView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(watchAccent)
|
||||
.frame(width: 18, height: 18)
|
||||
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(title)
|
||||
Text(request.watchAppDisplayName)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
Text(request.createdAt, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func watchCard() -> some View {
|
||||
padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchDashboardView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
WatchPassportCard(model: model)
|
||||
.watchCard()
|
||||
|
||||
WatchSectionHeader(
|
||||
title: "Pending",
|
||||
detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)"
|
||||
)
|
||||
|
||||
if model.pendingRequests.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("No checks waiting.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("New identity checks will appear here when a site or device asks you to prove it is really you.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
|
||||
Button("Seed Identity Check") {
|
||||
Task {
|
||||
await model.simulateIncomingRequest()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(watchAccent)
|
||||
}
|
||||
.watchCard()
|
||||
} else {
|
||||
ForEach(model.pendingRequests) { request in
|
||||
NavigationLink {
|
||||
WatchRequestDetailView(model: model, requestID: request.id)
|
||||
} label: {
|
||||
WatchRequestRow(request: request)
|
||||
.watchCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
WatchSectionHeader(title: "Activity")
|
||||
|
||||
if model.notifications.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No recent alerts.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Passport activity and security events will show up here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.watchCard()
|
||||
} else {
|
||||
ForEach(model.notifications.prefix(3)) { notification in
|
||||
NavigationLink {
|
||||
WatchNotificationDetailView(model: model, notificationID: notification.id)
|
||||
} label: {
|
||||
WatchNotificationRow(notification: notification)
|
||||
.watchCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
WatchSectionHeader(title: "Actions")
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button("Refresh") {
|
||||
Task {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(watchAccent)
|
||||
.disabled(model.isRefreshing)
|
||||
|
||||
Button("Send Test Alert") {
|
||||
Task {
|
||||
await model.sendTestNotification()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
||||
Button("Enable Alerts") {
|
||||
Task {
|
||||
await model.requestNotificationAccess()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Button("Sign Out", role: .destructive) {
|
||||
model.signOut()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.watchCard()
|
||||
|
||||
if let profile = model.profile {
|
||||
WatchSectionHeader(title: "Identity")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(profile.handle)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(profile.organization)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
|
||||
Text("Notifications: \(model.notificationPermission.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.watchCard()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.navigationTitle("Passport")
|
||||
.refreshable {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchSectionHeader: View {
|
||||
let title: String
|
||||
var detail: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let detail, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPassportCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AppBadge(title: "Passport active", tone: watchAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.profile?.name ?? "Preview Session")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Text(model.pairedDeviceSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
if let session = model.session {
|
||||
Text("Via \(session.pairingTransport.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent)
|
||||
WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchMetricPill: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let accent: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value)
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRequestRow: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
|
||||
.foregroundStyle(request.risk == .elevated ? .orange : watchAccent)
|
||||
}
|
||||
|
||||
Text(request.source)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? watchAccent : .orange)
|
||||
AppStatusTag(title: request.status.title, tone: request.status == .pending ? .orange : watchAccent)
|
||||
}
|
||||
|
||||
Text(request.createdAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationRow: View {
|
||||
let notification: AppNotification
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if notification.isUnread {
|
||||
Circle()
|
||||
.fill(watchAccent)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,159 +190,202 @@ private struct WatchRequestDetailView: View {
|
||||
if let request {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
detailHeader(
|
||||
title: request.title,
|
||||
subtitle: request.source,
|
||||
badge: request.status.title
|
||||
)
|
||||
RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you")
|
||||
|
||||
Text(request.subtitle)
|
||||
Text(request.watchTrustExplanation)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Trust Summary")
|
||||
.font(.headline)
|
||||
Text(request.trustHeadline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(request.trustDetail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(request.risk.guidance)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Scopes")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(request.scopes, id: \.self) { scope in
|
||||
Label(scope, systemImage: "checkmark.seal.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
if request.status == .pending {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView("Updating proof...")
|
||||
} else {
|
||||
Button("Verify") {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) {
|
||||
await model.approve(request)
|
||||
}
|
||||
|
||||
Button("Decline", role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
}
|
||||
Button("Deny") {
|
||||
Task {
|
||||
Haptics.warning()
|
||||
await model.reject(request)
|
||||
}
|
||||
}
|
||||
.buttonStyle(SecondaryActionStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
.padding(10)
|
||||
}
|
||||
} else {
|
||||
Text("This request is no longer available.")
|
||||
.foregroundStyle(.secondary)
|
||||
WatchEmptyState(
|
||||
title: "No request",
|
||||
message: "This sign-in is no longer pending.",
|
||||
systemImage: "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Identity Check")
|
||||
}
|
||||
|
||||
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(badge)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(watchAccent.opacity(0.14), in: Capsule())
|
||||
}
|
||||
.navigationTitle("Details")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let notificationID: AppNotification.ID
|
||||
private struct WatchHoldToApproveButton: View {
|
||||
var isBusy = false
|
||||
let action: () async -> Void
|
||||
|
||||
private var notification: AppNotification? {
|
||||
model.notifications.first(where: { $0.id == notificationID })
|
||||
}
|
||||
@State private var progress: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let notification {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
Text(notification.kind.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(watchAccent)
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
|
||||
.fill(isBusy ? Color.white.opacity(0.18) : IdP.tint)
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Alert posture")
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
Text(isBusy ? "Working…" : "Approve")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if notification.isUnread {
|
||||
Button("Mark Read") {
|
||||
Task {
|
||||
await model.markNotificationRead(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This activity item has already been cleared.")
|
||||
.foregroundStyle(.secondary)
|
||||
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
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.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 Date {
|
||||
var watchRelativeString: String {
|
||||
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func watchPrimaryActionGesture() -> some View {
|
||||
if #available(watchOS 11.0, *) {
|
||||
self.handGestureShortcut(.primaryAction)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchFormatters {
|
||||
static let relative: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter
|
||||
}()
|
||||
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() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user