From 0c3e5d772c454c939e5277676c34702311da3b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kunz?= Date: Sun, 19 Apr 2026 20:16:51 +0200 Subject: [PATCH] Refine Mail thread layout for clearer grouping and tighter spacing --- swift/Sources/Core/Design/LaneChip.swift | 2 +- .../Sources/Features/Mail/MailRootView.swift | 357 ++++++++++++------ 2 files changed, 233 insertions(+), 126 deletions(-) diff --git a/swift/Sources/Core/Design/LaneChip.swift b/swift/Sources/Core/Design/LaneChip.swift index f2548f9..d90ac7c 100644 --- a/swift/Sources/Core/Design/LaneChip.swift +++ b/swift/Sources/Core/Design/LaneChip.swift @@ -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) } diff --git a/swift/Sources/Features/Mail/MailRootView.swift b/swift/Sources/Features/Mail/MailRootView.swift index c26fdb7..6836a4b 100644 --- a/swift/Sources/Features/Mail/MailRootView.swift +++ b/swift/Sources/Features/Mail/MailRootView.swift @@ -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 {