Polish watch companion layouts

This commit is contained in:
2026-04-18 06:11:07 +02:00
parent ea6b45388f
commit b5cf3d9e01

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) {
.frame(maxWidth: .infinity, alignment: .leading) ProgressView()
.tint(watchAccent)
Text("Preparing preview passport...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
}
.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 {
VStack(alignment: .leading, spacing: 2) { HStack(alignment: .top, spacing: 10) {
Text(title) Image(systemName: systemImage)
.font(.caption2) .font(.footnote.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(watchAccent)
Text(value) .frame(width: 18, height: 18)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary) VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
}
} }
.padding(.horizontal, 10) }
.padding(.vertical, 8) }
.frame(maxWidth: .infinity, alignment: .leading)
.background(tone.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) 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)
)
} }
} }
@@ -106,94 +150,131 @@ 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 {
Text("No checks waiting.") VStack(alignment: .leading, spacing: 10) {
.foregroundStyle(.secondary) Text("No checks waiting.")
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Button("Seed Identity Check") { Text("New identity checks will appear here when a site or device asks you to prove it is really you.")
Task { .font(.caption2)
await model.simulateIncomingRequest() .foregroundStyle(.white.opacity(0.68))
Button("Seed Identity Check") {
Task {
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 {
Text("No recent alerts.") VStack(alignment: .leading, spacing: 8) {
.foregroundStyle(.secondary) 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 { } 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") {
Button("Refresh") {
Task {
await model.refreshDashboard()
}
}
.disabled(model.isRefreshing)
Button("Send Test Alert") {
Task {
await model.sendTestNotification()
} }
} }
if model.notificationPermission == .unknown || model.notificationPermission == .denied { WatchSectionHeader(title: "Actions")
Button("Enable Alerts") {
VStack(alignment: .leading, spacing: 10) {
Button("Refresh") {
Task { Task {
await model.requestNotificationAccess() await model.refreshDashboard()
} }
} }
} .buttonStyle(.bordered)
} .tint(watchAccent)
.disabled(model.isRefreshing)
Section("Account") { Button("Send Test Alert") {
if let profile = model.profile { Task {
VStack(alignment: .leading, spacing: 4) { await model.sendTestNotification()
Text(profile.handle) }
.font(.headline)
Text(profile.organization)
.font(.footnote)
.foregroundStyle(.secondary)
} }
} .buttonStyle(.bordered)
VStack(alignment: .leading, spacing: 4) { if model.notificationPermission == .unknown || model.notificationPermission == .denied {
Text("Notifications") Button("Enable Alerts") {
.font(.headline) Task {
Text(model.notificationPermission.title) await model.requestNotificationAccess()
.font(.footnote) }
.foregroundStyle(.secondary) }
} .buttonStyle(.bordered)
}
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)
} }
} }