Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
This commit is contained in:
@@ -139,46 +139,156 @@ struct ApprovalRow: View {
|
||||
let handle: String
|
||||
var compact = false
|
||||
var highlighted = false
|
||||
/// When non-nil the row renders inline Deny/Approve buttons below the
|
||||
/// header — matching the shadcn inbox card.
|
||||
var onApprove: (() -> Void)? = nil
|
||||
var onDeny: (() -> Void)? = nil
|
||||
var isBusy = false
|
||||
|
||||
private var showsInlineActions: Bool {
|
||||
request.status == .pending && onApprove != nil && onDeny != nil && !compact
|
||||
}
|
||||
|
||||
private var tint: Color { BrandTint.color(for: request.appDisplayName) }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
MonogramAvatar(title: request.appDisplayName, size: compact ? 32 : 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(request.inboxTitle)
|
||||
.font(compact ? .subheadline.weight(.semibold) : .headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text("as \(handle) · \(request.locationSummary)")
|
||||
.font(compact ? .caption : .subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TimeChip(date: request.createdAt, compact: compact)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
VStack(alignment: .leading, spacing: showsInlineActions ? 12 : 0) {
|
||||
header
|
||||
if showsInlineActions {
|
||||
actionRow
|
||||
}
|
||||
}
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(highlighted ? IdP.tint.opacity(0.06) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(highlighted ? IdP.tint : Color.clear, lineWidth: highlighted ? 1.5 : 0)
|
||||
)
|
||||
.padding(showsInlineActions ? 14 : (compact ? 10 : 12))
|
||||
.background(background)
|
||||
.overlay(stroke)
|
||||
.background(glow)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))")
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MonogramAvatar(
|
||||
title: request.appDisplayName,
|
||||
size: compact ? 32 : 40,
|
||||
tint: tint,
|
||||
filled: true
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(request.appDisplayName)
|
||||
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(relativeTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
}
|
||||
|
||||
Text(request.kind.title)
|
||||
.font(compact ? .caption : .footnote)
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
.lineLimit(1)
|
||||
|
||||
if !compact {
|
||||
HStack(spacing: 8) {
|
||||
Label {
|
||||
Text(request.locationSummary)
|
||||
} icon: {
|
||||
Image(systemName: "location.fill")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
.labelStyle(.titleAndIcon)
|
||||
|
||||
ShadcnBadge(
|
||||
title: riskBadgeTitle,
|
||||
tone: riskBadgeTone
|
||||
)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
||||
if !showsInlineActions && compact {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: { onDeny?() }) {
|
||||
Text("Deny")
|
||||
}
|
||||
.buttonStyle(SecondaryActionStyle())
|
||||
.disabled(isBusy)
|
||||
|
||||
Button(action: { onApprove?() }) {
|
||||
if isBusy {
|
||||
ProgressView().tint(Color.idpPrimaryForeground)
|
||||
} else {
|
||||
Label("Approve", systemImage: "checkmark")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PrimaryActionStyle())
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private var background: some View {
|
||||
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
|
||||
.fill(Color.idpCard)
|
||||
}
|
||||
|
||||
private var stroke: some View {
|
||||
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
|
||||
.stroke(highlighted ? IdP.tint : Color.idpBorder, lineWidth: 1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var glow: some View {
|
||||
if highlighted {
|
||||
RoundedRectangle(cornerRadius: IdP.cardRadius + 3, style: .continuous)
|
||||
.fill(IdP.tint.opacity(0.10))
|
||||
.padding(-3)
|
||||
}
|
||||
}
|
||||
|
||||
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 var riskBadgeTitle: String {
|
||||
switch (request.status, request.risk) {
|
||||
case (.approved, _): return "approved"
|
||||
case (.rejected, _): return "denied"
|
||||
case (.pending, .routine): return "trusted"
|
||||
case (.pending, .elevated): return "new network"
|
||||
}
|
||||
}
|
||||
|
||||
private var riskBadgeTone: ShadcnBadge.Tone {
|
||||
switch (request.status, request.risk) {
|
||||
case (.approved, _): return .ok
|
||||
case (.rejected, _): return .danger
|
||||
case (.pending, .routine): return .ok
|
||||
case (.pending, .elevated): return .warn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationEventRow: View {
|
||||
@@ -270,27 +380,30 @@ struct DeviceItemRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: device.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(IdP.tint)
|
||||
.frame(width: 28)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.idpMuted)
|
||||
Image(systemName: device.systemImage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.body.weight(.medium))
|
||||
.font(.footnote.weight(.semibold))
|
||||
|
||||
Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(device.isCurrent ? "berlin · primary · this device" : "last seen \(device.lastSeen, style: .relative)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
StatusDot(color: device.isTrusted ? .green : .yellow)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
ShadcnBadge(
|
||||
title: device.isTrusted ? "high" : "med",
|
||||
tone: device.isTrusted ? .ok : .warn
|
||||
)
|
||||
}
|
||||
.deviceRowStyle()
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -300,34 +413,56 @@ struct DeviceItemRow: View {
|
||||
struct TrustSignalBanner: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: symbolName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(request.trustColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(request.trustHeadline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(request.trustExplanation)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
private var bg: Color {
|
||||
switch request.trustColor {
|
||||
case .green: return Color.idpOK.opacity(0.10)
|
||||
case .yellow: return Color.idpWarn.opacity(0.15)
|
||||
default: return Color.idpDestructive.opacity(0.10)
|
||||
}
|
||||
}
|
||||
|
||||
private var fg: Color {
|
||||
switch request.trustColor {
|
||||
case .green: return Color.idpOK
|
||||
case .yellow: return Color(red: 0.52, green: 0.30, blue: 0.05)
|
||||
default: return Color.idpDestructive
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
switch request.trustColor {
|
||||
case .green:
|
||||
return "checkmark.shield.fill"
|
||||
case .yellow:
|
||||
return "exclamationmark.triangle.fill"
|
||||
default:
|
||||
return "xmark.shield.fill"
|
||||
case .green: return "checkmark.shield"
|
||||
case .yellow: return "exclamationmark.triangle"
|
||||
default: return "xmark.shield"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: symbolName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(fg)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(request.trustHeadline)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(fg)
|
||||
|
||||
Text(request.trustExplanation)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.idpMutedForeground)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(12)
|
||||
.background(bg, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
|
||||
.stroke(Color.idpBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyPaneView: View {
|
||||
|
||||
Reference in New Issue
Block a user