Refine Mail thread layout for clearer grouping and tighter spacing

This commit is contained in:
2026-04-19 20:16:51 +02:00
parent 7631380215
commit 0c3e5d772c
2 changed files with 233 additions and 126 deletions

View File

@@ -17,7 +17,7 @@ struct LaneChip: View {
}
.font(.caption2.weight(.semibold))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.padding(.vertical, 3)
.background(lane.color.opacity(0.14), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(lane.color)
}

View File

@@ -137,9 +137,11 @@ struct MailRootView: View {
preferredCompactColumn: $preferredCompactColumn
) {
SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 280)
} content: {
ThreadListView(model: model, layoutMode: .regular)
.navigationTitle(model.selectedMailbox.title)
.navigationSplitViewColumnWidth(min: 380, ideal: 420, max: 520)
} detail: {
if model.isComposing {
ComposeView(model: model)
@@ -446,38 +448,65 @@ struct ThreadListView: View {
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(model.filteredThreads) { thread in
Button {
Haptics.selection()
model.openThread(withID: thread.id)
} label: {
ThreadRow(
thread: thread,
density: density,
isSelected: thread.id == model.selectedThreadID
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.contextMenu {
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
model.toggleRead(for: thread)
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 20) {
ForEach(groupedThreads(model.filteredThreads)) { group in
VStack(alignment: .leading, spacing: 6) {
Text(group.title.uppercased())
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.kerning(0.4)
.padding(.horizontal, 28)
Button(thread.isStarred ? "Remove Star" : "Star") {
model.toggleStar(for: thread)
}
VStack(spacing: 0) {
ForEach(Array(group.threads.enumerated()), id: \.element.id) { index, thread in
Button {
Haptics.selection()
model.openThread(withID: thread.id)
} label: {
ThreadRow(
thread: thread,
density: density,
isSelected: thread.id == model.selectedThreadID
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
model.toggleRead(for: thread)
}
Button(thread.isStarred ? "Remove Star" : "Star") {
model.toggleStar(for: thread)
}
Button("Archive") {
model.moveThread(withID: thread.id, to: .archive)
}
}
Button("Archive") {
model.moveThread(withID: thread.id, to: .archive)
if index < group.threads.count - 1 {
Rectangle()
.fill(Color.primary.opacity(0.08))
.frame(height: 0.5)
.padding(.leading, 60)
}
}
}
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(platformSurfaceColor)
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
)
.padding(.horizontal, 16)
}
}
}
.padding(.vertical, 12)
.padding(.bottom, layoutMode == .compact ? 80 : 12)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
}
@@ -500,6 +529,45 @@ struct ThreadListView: View {
return .comfortable
#endif
}
private func groupedThreads(_ threads: [MailThread]) -> [ThreadGroup] {
let calendar = Calendar.current
let now = Date()
let startOfToday = calendar.startOfDay(for: now)
let startOfWeek = calendar.date(byAdding: .day, value: -6, to: startOfToday) ?? startOfToday
let startOfMonth = calendar.date(byAdding: .day, value: -30, to: startOfToday) ?? startOfToday
var today: [MailThread] = []
var thisWeek: [MailThread] = []
var thisMonth: [MailThread] = []
var older: [MailThread] = []
for thread in threads {
let d = thread.lastUpdated
if d >= startOfToday {
today.append(thread)
} else if d >= startOfWeek {
thisWeek.append(thread)
} else if d >= startOfMonth {
thisMonth.append(thread)
} else {
older.append(thread)
}
}
var groups: [ThreadGroup] = []
if !today.isEmpty { groups.append(ThreadGroup(title: "Today", threads: today)) }
if !thisWeek.isEmpty { groups.append(ThreadGroup(title: "This week", threads: thisWeek)) }
if !thisMonth.isEmpty { groups.append(ThreadGroup(title: "Earlier this month", threads: thisMonth)) }
if !older.isEmpty { groups.append(ThreadGroup(title: "Older", threads: older)) }
return groups
}
}
private struct ThreadGroup: Identifiable {
let title: String
let threads: [MailThread]
var id: String { title }
}
private struct ThreadListSearchHeader: View {
@@ -651,21 +719,19 @@ private struct FloatingComposeButton: View {
Button {
model.startCompose()
} label: {
HStack(spacing: 10) {
Image(systemName: "square.and.pencil")
Text("Compose")
}
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.sioGlassSurface(in: Capsule(), tint: SIO.tint)
Image(systemName: "square.and.pencil")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(SIO.tint, in: Circle())
.shadow(color: SIO.tint.opacity(0.45), radius: 12, x: 0, y: 8)
.shadow(color: SIO.tint.opacity(0.30), radius: 3, x: 0, y: 2)
}
.buttonStyle(.plain)
.accessibilityLabel("Compose")
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
.padding(.horizontal, 18)
.padding(.bottom, 4)
}
}
@@ -694,15 +760,15 @@ private struct CompactLaneOverview: View {
.foregroundStyle(model.laneFilter == lane ? lane.color : Color.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(model.laneFilter == lane ? lane.color.opacity(0.12) : Color.secondary.opacity(0.08))
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(model.laneFilter == lane ? lane.color.opacity(0.10) : platformSurfaceColor)
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.06), lineWidth: 1)
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.10), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
@@ -717,83 +783,83 @@ private struct ThreadRow: View {
let isSelected: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
unreadDot
HStack(alignment: .top, spacing: 10) {
unreadDot
AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(senderName)
.font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(senderName)
.font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1)
.layoutPriority(1)
if thread.messageCount > 1 {
Text(thread.messageCount, format: .number)
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.12), in: Capsule())
}
if thread.messageCount > 1 {
Text(thread.messageCount, format: .number)
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.secondary.opacity(0.12), in: Capsule())
.fixedSize()
}
Spacer(minLength: 0)
Spacer(minLength: 4)
if thread.isStarred {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
if thread.isStarred {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
}
if thread.hasAttachments {
Image(systemName: "paperclip")
.foregroundStyle(.secondary)
}
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
if thread.hasAttachments {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
}
Text(thread.subject)
.font(.headline)
.lineLimit(1)
Text(thread.previewText)
.font(.subheadline)
Text(shortTimeLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(density.previewLineLimit)
.lineLimit(1)
.fixedSize()
}
if density.showsMetaChips {
HStack(spacing: 8) {
LaneChip(lane: thread.lane)
Text(thread.subject)
.font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
.foregroundStyle(isSelected ? SIO.tint : Color.primary)
.lineLimit(1)
if thread.summary != nil {
HStack(spacing: 6) {
Image(systemName: "sparkles")
Text("AI Summary")
}
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(SIO.tint)
Text(thread.previewText)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(density.previewLineLimit)
if density.showsMetaChips {
HStack(spacing: 6) {
LaneChip(lane: thread.lane)
if thread.summary != nil {
HStack(spacing: 4) {
Image(systemName: "sparkles")
Text("Summary")
}
.font(.caption2.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
.foregroundStyle(SIO.tint)
}
}
.padding(.top, 3)
}
}
}
.padding(density.rowPadding)
.background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(isSelected ? SIO.tint.opacity(0.18) : Color.primary.opacity(0.06), lineWidth: 1)
)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(isSelected ? SIO.tint.opacity(0.10) : Color.clear)
.accessibilityIdentifier("thread.\(thread.routeID)")
}
@@ -801,6 +867,27 @@ private struct ThreadRow: View {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown Sender"
}
private var shortTimeLabel: String {
let calendar = Calendar.current
let now = Date()
let date = thread.lastUpdated
if calendar.isDateInToday(date) {
return date.formatted(date: .omitted, time: .shortened)
}
if calendar.isDateInYesterday(date) {
return "Yesterday"
}
let daysAgo = calendar.dateComponents([.day], from: date, to: now).day ?? 0
if daysAgo < 7 {
return date.formatted(.dateTime.weekday(.abbreviated))
}
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
if sameYear {
return date.formatted(.dateTime.day().month(.abbreviated))
}
return date.formatted(.dateTime.day().month(.abbreviated).year(.twoDigits))
}
@ViewBuilder
private var unreadDot: some View {
Circle()
@@ -906,36 +993,48 @@ private struct ReadingHeader: View {
let thread: MailThread
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(thread.subject)
.font(.title.weight(.bold))
HStack(alignment: .center, spacing: 12) {
participantStack
.font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
HStack(alignment: .center, spacing: 10) {
LaneChip(lane: thread.lane)
Text("\(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
Text("·")
.foregroundStyle(.secondary)
Text("\(thread.participants.count) people · \(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
Text(readingTimeLabel)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
}
}
.padding(18)
.sioCardBackground(tint: thread.lane.color)
.padding(.horizontal, 2)
}
private var participantStack: some View {
HStack(spacing: -10) {
ForEach(Array(thread.participants.prefix(3))) { participant in
AvatarView(name: participant.name, color: thread.lane.color, size: 28)
}
private var readingTimeLabel: String {
let calendar = Calendar.current
let now = Date()
let date = thread.lastUpdated
if calendar.isDateInToday(date) {
return "Today · " + date.formatted(date: .omitted, time: .shortened)
}
if calendar.isDateInYesterday(date) {
return "Yesterday · " + date.formatted(date: .omitted, time: .shortened)
}
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
let datePart = sameYear
? date.formatted(.dateTime.day().month(.abbreviated))
: date.formatted(.dateTime.day().month(.abbreviated).year())
return datePart + " · " + date.formatted(date: .omitted, time: .shortened)
}
}
@@ -1054,14 +1153,14 @@ private struct MessageCard: View {
}
}
}
.padding(18)
.padding(14)
.background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isFocused ? SIO.tint.opacity(0.12) : (isLatest ? SIO.tint.opacity(0.08) : Color.secondary.opacity(0.05)))
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isFocused ? SIO.tint.opacity(0.08) : platformSurfaceColor)
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 1)
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.10), lineWidth: 0.5)
)
.accessibilityIdentifier("message.\(message.routeID)")
}
@@ -1090,14 +1189,20 @@ private struct InlineReplyComposer: View {
.padding(.vertical, 6)
.background(Color.clear)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.background(platformSurfaceColor, in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 6)
Button(action: onSend) {
Image(systemName: "arrow.up")
Image(systemName: "paperplane.fill")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.frame(width: 48, height: 48)
.background(SIO.tint, in: Circle())
.shadow(color: SIO.tint.opacity(0.4), radius: 10, x: 0, y: 6)
}
.buttonStyle(.plain)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -1779,8 +1884,10 @@ private struct AppearanceSettingsContent: View {
#if os(macOS)
private let platformBackground = Color(nsColor: .windowBackgroundColor)
private let platformSurfaceColor = Color(nsColor: .controlBackgroundColor)
#else
private let platformBackground = Color(uiColor: .systemBackground)
private let platformSurfaceColor = Color(uiColor: .secondarySystemGroupedBackground)
#endif
private extension View {