Refine inbox and watch approval presentation
CI / test (push) Has been cancelled

Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
This commit is contained in:
2026-04-19 21:50:03 +02:00
parent 61a0cc1f7d
commit 271d9657bf
13 changed files with 1122 additions and 516 deletions
+100 -39
View File
@@ -31,71 +31,132 @@ struct InboxListView: View {
filteredRequests.first?.id
}
var body: some View {
List {
if filteredRequests.isEmpty {
EmptyPaneView(
title: "No sign-in requests",
message: "New approval requests will appear here as soon as a relying party asks for proof.",
systemImage: "tray"
)
.listRowBackground(Color.clear)
} else {
ForEach(recentRequests) { request in
row(for: request, compact: false)
.transition(.move(edge: .top).combined(with: .opacity))
}
private var oldestPendingMinutes: Int? {
guard let oldest = model.pendingRequests.min(by: { $0.createdAt < $1.createdAt }) else {
return nil
}
return max(1, Int(Date.now.timeIntervalSince(oldest.createdAt)) / 60)
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10, pinnedViews: []) {
InboxHeader(
pendingCount: model.pendingRequests.count,
oldestMinutes: oldestPendingMinutes
)
.padding(.horizontal, 4)
.padding(.bottom, 6)
if filteredRequests.isEmpty {
EmptyPaneView(
title: "No sign-in requests",
message: "New approval requests will appear here as soon as a relying party asks for proof.",
systemImage: "tray"
)
.padding(.top, 40)
} else {
ForEach(recentRequests) { request in
row(for: request, compact: false)
.transition(.move(edge: .top).combined(with: .opacity))
}
if !earlierRequests.isEmpty {
Text("Earlier today")
.font(.caption2.weight(.semibold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(Color.idpMutedForeground)
.padding(.top, 12)
.padding(.leading, 4)
if !earlierRequests.isEmpty {
Section {
ForEach(earlierRequests) { request in
row(for: request, compact: true)
.transition(.move(edge: .top).combined(with: .opacity))
}
} header: {
Text("Earlier today")
.textCase(nil)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 120)
}
.listStyle(.plain)
.scrollIndicators(.hidden)
.background(Color.idpBackground.ignoresSafeArea())
.navigationTitle("Inbox")
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
.idpSearchable(text: $searchText, isPresented: $isSearchPresented)
#if !os(macOS)
.searchable(text: $searchText, isPresented: $isSearchPresented, placement: .navigationBarDrawer(displayMode: .automatic))
#else
.searchable(text: $searchText, isPresented: $isSearchPresented)
#endif
}
@ViewBuilder
private func row(for request: ApprovalRequest, compact: Bool) -> some View {
let handle = model.profile?.handle ?? "@you"
let highlighted = highlightedRequestID == request.id
let isBusy = model.activeRequestID == request.id
let rowContent = ApprovalRow(
request: request,
handle: handle,
compact: compact,
highlighted: highlighted,
onApprove: compact ? nil : { Task { await model.approve(request) } },
onDeny: compact ? nil : {
Haptics.warning()
Task { await model.reject(request) }
},
isBusy: isBusy
)
if usesSelection {
Button {
selectedRequestID = request.id
Haptics.selection()
} label: {
ApprovalRow(
request: request,
handle: model.profile?.handle ?? "@you",
compact: compact,
highlighted: highlightedRequestID == request.id
)
rowContent
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
NavigationLink(value: request.id) {
ApprovalRow(
request: request,
handle: model.profile?.handle ?? "@you",
compact: compact,
highlighted: highlightedRequestID == request.id
)
rowContent
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
}
private struct InboxHeader: View {
let pendingCount: Int
let oldestMinutes: Int?
var body: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.idpPrimary)
Image(systemName: "shield.lefthalf.filled")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.idpPrimaryForeground)
}
.frame(width: 24, height: 24)
Text("idp.global")
.font(.subheadline.weight(.semibold))
Spacer()
if pendingCount > 0 {
ShadcnBadge(title: "\(pendingCount) pending", tone: .ok)
if let oldestMinutes {
Text("oldest \(oldestMinutes) min ago")
.font(.caption)
.foregroundStyle(Color.idpMutedForeground)
}
} else {
ShadcnBadge(title: "all clear", tone: .ok)
}
}
}
}