Refocus app around identity proof flows
This commit is contained in:
33
WatchApp/App/IDPGlobalWatchApp.swift
Normal file
33
WatchApp/App/IDPGlobalWatchApp.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalWatchApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView(model: model)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
479
WatchApp/Features/WatchRootView.swift
Normal file
479
WatchApp/Features/WatchRootView.swift
Normal file
@@ -0,0 +1,479 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let watchAccent = AppTheme.accent
|
||||
private let watchGold = AppTheme.warmAccent
|
||||
|
||||
struct WatchRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
WatchPairingView(model: model)
|
||||
} else {
|
||||
WatchDashboardView(model: model)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.tint(watchAccent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPairingView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AppPanel(compactLayout: true, radius: 22) {
|
||||
AppBadge(title: "Preview passport", tone: watchAccent)
|
||||
|
||||
Text("Prove identity from your wrist")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("This preview connects directly to the mock service today.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
|
||||
AppStatusTag(title: "Preview sync", tone: watchGold)
|
||||
}
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview passport...")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Use Preview Passport", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
|
||||
|
||||
AppPanel(compactLayout: true, radius: 18) {
|
||||
Text("What works today")
|
||||
.font(.headline)
|
||||
|
||||
Text("The watch shows pending identity checks, recent alerts, and quick actions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Set Up Watch")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchInfoPill: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let tone: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tone.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchDashboardView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
WatchPassportCard(model: model)
|
||||
}
|
||||
|
||||
Section("Pending") {
|
||||
if model.pendingRequests.isEmpty {
|
||||
Text("No checks waiting.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button("Seed Identity Check") {
|
||||
Task {
|
||||
await model.simulateIncomingRequest()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(model.pendingRequests) { request in
|
||||
NavigationLink {
|
||||
WatchRequestDetailView(model: model, requestID: request.id)
|
||||
} label: {
|
||||
WatchRequestRow(request: request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Recent Activity") {
|
||||
if model.notifications.isEmpty {
|
||||
Text("No recent alerts.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(model.notifications.prefix(3)) { notification in
|
||||
NavigationLink {
|
||||
WatchNotificationDetailView(model: model, notificationID: notification.id)
|
||||
} label: {
|
||||
WatchNotificationRow(notification: notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Actions") {
|
||||
Button("Refresh") {
|
||||
Task {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
.disabled(model.isRefreshing)
|
||||
|
||||
Button("Send Test Alert") {
|
||||
Task {
|
||||
await model.sendTestNotification()
|
||||
}
|
||||
}
|
||||
|
||||
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
||||
Button("Enable Alerts") {
|
||||
Task {
|
||||
await model.requestNotificationAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
if let profile = model.profile {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
Text(profile.organization)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Notifications")
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button("Sign Out", role: .destructive) {
|
||||
model.signOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Passport")
|
||||
.refreshable {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPassportCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.profile?.name ?? "Preview Session")
|
||||
.font(.headline)
|
||||
Text(model.pairedDeviceSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let session = model.session {
|
||||
Text("Via \(session.pairingTransport.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.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)
|
||||
|
||||
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(.secondary)
|
||||
|
||||
Text(request.createdAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if notification.isUnread {
|
||||
Circle()
|
||||
.fill(watchAccent)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.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) {
|
||||
detailHeader(
|
||||
title: request.title,
|
||||
subtitle: request.source,
|
||||
badge: request.status.title
|
||||
)
|
||||
|
||||
Text(request.subtitle)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView("Updating proof...")
|
||||
} else {
|
||||
Button("Verify") {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Decline", role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This request is no longer available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let notificationID: AppNotification.ID
|
||||
|
||||
private var notification: AppNotification? {
|
||||
model.notifications.first(where: { $0.id == notificationID })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var watchRelativeString: String {
|
||||
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchFormatters {
|
||||
static let relative: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user