Polish watch companion layouts

This commit is contained in:
2026-04-18 06:11:07 +02:00
parent ea6b45388f
commit b5cf3d9e01
+171 -61
View File
@@ -28,25 +28,34 @@ private struct WatchPairingView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
AppPanel(compactLayout: true, radius: 22) { VStack(alignment: .leading, spacing: 10) {
AppBadge(title: "Preview passport", tone: watchAccent) AppBadge(title: "Preview passport", tone: watchAccent)
Text("Prove identity from your wrist") Text("Prove identity from your wrist")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(.white)
Text("This preview connects directly to the mock service today.") Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) { HStack(spacing: 8) {
AppStatusTag(title: "Wrist-ready", tone: watchAccent) AppStatusTag(title: "Wrist-ready", tone: watchAccent)
AppStatusTag(title: "Preview sync", tone: watchGold) AppStatusTag(title: "Proof focus", tone: watchGold)
} }
} }
.watchCard()
if model.isBootstrapping { if model.isBootstrapping {
ProgressView("Preparing preview passport...") HStack(spacing: 8) {
ProgressView()
.tint(watchAccent)
Text("Preparing preview passport...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
}
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.watchCard()
} }
Button { Button {
@@ -58,47 +67,82 @@ private struct WatchPairingView: View {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
Label("Use Preview Passport", systemImage: "qrcode") Label("Link Preview Passport", systemImage: "applewatch")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(watchAccent)
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating) .disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
AppPanel(compactLayout: true, radius: 18) { VStack(alignment: .leading, spacing: 10) {
Text("What works today") Text("What this watch does")
.font(.headline) .font(.headline)
.foregroundStyle(.white)
Text("The watch shows pending identity checks, recent alerts, and quick actions.") WatchSetupFeatureRow(
.font(.footnote) systemImage: "checkmark.shield",
.foregroundStyle(.secondary) 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(.horizontal, 8)
.padding(.top, 6)
.padding(.bottom, 20) .padding(.bottom, 20)
} }
.navigationTitle("Set Up Watch") .background(Color.black.ignoresSafeArea())
.navigationTitle("Link Watch")
} }
} }
private struct WatchInfoPill: View { private struct WatchSetupFeatureRow: View {
let systemImage: String
let title: String let title: String
let value: String let subtitle: String
let tone: Color
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(watchAccent)
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text(subtitle)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.68))
Text(value)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
} }
.padding(.horizontal, 10) }
.padding(.vertical, 8) }
}
private extension View {
func watchCard() -> some View {
padding(14)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(tone.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .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)
)
} }
} }
@@ -106,53 +150,82 @@ private struct WatchDashboardView: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
var body: some View { var body: some View {
List { ScrollView {
Section { VStack(alignment: .leading, spacing: 12) {
WatchPassportCard(model: model) WatchPassportCard(model: model)
} .watchCard()
WatchSectionHeader(
title: "Pending",
detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)"
)
Section("Pending") {
if model.pendingRequests.isEmpty { if model.pendingRequests.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("No checks waiting.") Text("No checks waiting.")
.foregroundStyle(.secondary) .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") { Button("Seed Identity Check") {
Task { Task {
await model.simulateIncomingRequest() await model.simulateIncomingRequest()
} }
} }
.buttonStyle(.bordered)
.tint(watchAccent)
}
.watchCard()
} else { } else {
ForEach(model.pendingRequests) { request in ForEach(model.pendingRequests) { request in
NavigationLink { NavigationLink {
WatchRequestDetailView(model: model, requestID: request.id) WatchRequestDetailView(model: model, requestID: request.id)
} label: { } label: {
WatchRequestRow(request: request) WatchRequestRow(request: request)
.watchCard()
} }
} .buttonStyle(.plain)
} }
} }
Section("Recent Activity") { WatchSectionHeader(title: "Activity")
if model.notifications.isEmpty { if model.notifications.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("No recent alerts.") Text("No recent alerts.")
.foregroundStyle(.secondary) .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 { } else {
ForEach(model.notifications.prefix(3)) { notification in ForEach(model.notifications.prefix(3)) { notification in
NavigationLink { NavigationLink {
WatchNotificationDetailView(model: model, notificationID: notification.id) WatchNotificationDetailView(model: model, notificationID: notification.id)
} label: { } label: {
WatchNotificationRow(notification: notification) WatchNotificationRow(notification: notification)
.watchCard()
} }
} .buttonStyle(.plain)
} }
} }
Section("Actions") { WatchSectionHeader(title: "Actions")
VStack(alignment: .leading, spacing: 10) {
Button("Refresh") { Button("Refresh") {
Task { Task {
await model.refreshDashboard() await model.refreshDashboard()
} }
} }
.buttonStyle(.bordered)
.tint(watchAccent)
.disabled(model.isRefreshing) .disabled(model.isRefreshing)
Button("Send Test Alert") { Button("Send Test Alert") {
@@ -160,6 +233,7 @@ private struct WatchDashboardView: View {
await model.sendTestNotification() await model.sendTestNotification()
} }
} }
.buttonStyle(.bordered)
if model.notificationPermission == .unknown || model.notificationPermission == .denied { if model.notificationPermission == .unknown || model.notificationPermission == .denied {
Button("Enable Alerts") { Button("Enable Alerts") {
@@ -167,33 +241,40 @@ private struct WatchDashboardView: View {
await model.requestNotificationAccess() await model.requestNotificationAccess()
} }
} }
} .buttonStyle(.bordered)
}
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) { Button("Sign Out", role: .destructive) {
model.signOut() 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") .navigationTitle("Passport")
.refreshable { .refreshable {
await model.refreshDashboard() await model.refreshDashboard()
@@ -201,21 +282,44 @@ private struct WatchDashboardView: View {
} }
} }
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 { private struct WatchPassportCard: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
AppBadge(title: "Passport active", tone: watchAccent)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(model.profile?.name ?? "Preview Session") Text(model.profile?.name ?? "Preview Session")
.font(.headline) .font(.headline)
.foregroundStyle(.white)
Text(model.pairedDeviceSummary) Text(model.pairedDeviceSummary)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.72))
if let session = model.session { if let session = model.session {
Text("Via \(session.pairingTransport.title)") Text("Via \(session.pairingTransport.title)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.58))
} }
} }
@@ -237,9 +341,10 @@ private struct WatchMetricPill: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(value) Text(value)
.font(.headline.monospacedDigit()) .font(.headline.monospacedDigit())
.foregroundStyle(.white)
Text(title) Text(title)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.68))
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -257,6 +362,7 @@ private struct WatchRequestRow: View {
Text(request.title) Text(request.title)
.font(.headline) .font(.headline)
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.white)
Spacer(minLength: 6) Spacer(minLength: 6)
@@ -266,13 +372,17 @@ private struct WatchRequestRow: View {
Text(request.source) Text(request.source)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .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) Text(request.createdAt.watchRelativeString)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.58))
} }
.padding(.vertical, 2)
} }
} }
@@ -285,6 +395,7 @@ private struct WatchNotificationRow: View {
Text(notification.title) Text(notification.title)
.font(.headline) .font(.headline)
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.white)
Spacer(minLength: 6) Spacer(minLength: 6)
@@ -297,14 +408,13 @@ private struct WatchNotificationRow: View {
Text(notification.message) Text(notification.message)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.72))
.lineLimit(2) .lineLimit(2)
Text(notification.sentAt.watchRelativeString) Text(notification.sentAt.watchRelativeString)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.58))
} }
.padding(.vertical, 2)
} }
} }