Refine Mail thread layout for clearer grouping and tighter spacing
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user