- Implement MailRootView with navigation and sidebar for mail management. - Create MailSidebarView, ThreadListView, and ThreadDetailView for displaying mail content. - Introduce ComposeView for composing new messages. - Add MailTheme for consistent styling across mail components. - Implement adaptive layouts for iOS and macOS. - Create unit tests for AppNavigationCommand and AppViewModel to ensure correct functionality.
1153 lines
39 KiB
Swift
1153 lines
39 KiB
Swift
import SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
enum MailTheme {
|
|
static let accent = Color(red: 0.20, green: 0.47, blue: 0.94)
|
|
static let ocean = Color(red: 0.18, green: 0.53, blue: 0.97)
|
|
static let mint = Color(red: 0.26, green: 0.74, blue: 0.68)
|
|
static let sunrise = Color(red: 1.00, green: 0.67, blue: 0.38)
|
|
static let ink = Color(red: 0.10, green: 0.17, blue: 0.27)
|
|
}
|
|
|
|
struct MailRootView: View {
|
|
@Bindable var model: AppViewModel
|
|
@State private var preferredCompactColumn: NavigationSplitViewColumn = .content
|
|
|
|
var body: some View {
|
|
NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) {
|
|
MailSidebarView(model: model)
|
|
} content: {
|
|
ThreadListView(model: model)
|
|
} detail: {
|
|
ThreadDetailView(model: model)
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
.searchable(text: searchTextBinding, prompt: "Search mail")
|
|
.toolbar {
|
|
if showsToolbarCompose {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
model.startCompose()
|
|
} label: {
|
|
Label("Compose", systemImage: "square.and.pencil")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $model.isComposing) {
|
|
ComposeView(model: model)
|
|
}
|
|
.task {
|
|
await model.load()
|
|
}
|
|
.task {
|
|
await model.beginBackendControl()
|
|
}
|
|
.onChange(of: model.mailboxNavigationToken) {
|
|
showCompactColumn(.content)
|
|
}
|
|
.onChange(of: model.threadNavigationToken) {
|
|
showCompactColumn(.detail)
|
|
}
|
|
.onChange(of: model.selectedThreadID) {
|
|
if model.selectedThreadID == nil {
|
|
showCompactColumn(.content)
|
|
}
|
|
}
|
|
.onChange(of: model.isComposing) {
|
|
guard model.isComposing, usesCompactSplitNavigation else { return }
|
|
model.dismissThreadSelection()
|
|
showCompactColumn(.content)
|
|
}
|
|
.onChange(of: preferredCompactColumn) {
|
|
guard usesCompactSplitNavigation, preferredCompactColumn != .detail else { return }
|
|
model.dismissThreadSelection()
|
|
}
|
|
.alert("Something went wrong", isPresented: errorPresented) {
|
|
Button("OK") {
|
|
model.errorMessage = nil
|
|
}
|
|
} message: {
|
|
Text(model.errorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
private var errorPresented: Binding<Bool> {
|
|
Binding(
|
|
get: { model.errorMessage != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
model.errorMessage = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var searchTextBinding: Binding<String> {
|
|
Binding(
|
|
get: { model.searchText },
|
|
set: { model.setSearchText($0) }
|
|
)
|
|
}
|
|
|
|
private var showsToolbarCompose: Bool {
|
|
#if os(iOS)
|
|
UIDevice.current.userInterfaceIdiom != .phone
|
|
#else
|
|
true
|
|
#endif
|
|
}
|
|
|
|
private var usesCompactSplitNavigation: Bool {
|
|
#if os(iOS)
|
|
UIDevice.current.userInterfaceIdiom == .phone
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
private func showCompactColumn(_ column: NavigationSplitViewColumn) {
|
|
guard usesCompactSplitNavigation else { return }
|
|
preferredCompactColumn = column
|
|
}
|
|
}
|
|
|
|
private struct MailSidebarView: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
SidebarHeader(model: model)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 16, trailing: 14))
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
|
|
Section("Mailboxes") {
|
|
ForEach(Mailbox.allCases) { mailbox in
|
|
Button {
|
|
model.selectMailbox(mailbox)
|
|
} label: {
|
|
mailboxRow(for: mailbox)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowBackground(
|
|
mailbox == model.selectedMailbox
|
|
? MailTheme.accent.opacity(0.10)
|
|
: Color.clear
|
|
)
|
|
.accessibilityIdentifier("sidebar.mailbox.\(mailbox.id)")
|
|
}
|
|
}
|
|
|
|
Section("Filters") {
|
|
Toggle(isOn: unreadOnlyBinding) {
|
|
HStack {
|
|
Label("Unread Only", systemImage: "circle.badge")
|
|
Spacer()
|
|
Text(model.totalUnreadCount, format: .number)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
.scrollContentBackground(.hidden)
|
|
.background(MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.mint))
|
|
.navigationTitle("social.io")
|
|
}
|
|
|
|
private var unreadOnlyBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { model.showUnreadOnly },
|
|
set: { model.setUnreadOnly($0) }
|
|
)
|
|
}
|
|
|
|
private func mailboxRow(for mailbox: Mailbox) -> some View {
|
|
HStack(spacing: 12) {
|
|
Label(mailbox.title, systemImage: mailbox.systemImage)
|
|
.foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary)
|
|
|
|
Spacer()
|
|
|
|
Text(model.threadCount(in: mailbox), format: .number)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
mailbox == model.selectedMailbox
|
|
? MailTheme.accent.opacity(0.14)
|
|
: Color.secondary.opacity(0.10),
|
|
in: Capsule()
|
|
)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct SidebarHeader: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .center, spacing: 14) {
|
|
Image(systemName: "at.circle.fill")
|
|
.font(.system(size: 30, weight: .semibold))
|
|
.foregroundStyle(MailTheme.accent, MailTheme.mint)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("social.io mail")
|
|
.font(.title3.weight(.bold))
|
|
Text("Calm inboxes for real conversations.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
AdaptiveGlassGroup(spacing: 16) {
|
|
HStack(spacing: 12) {
|
|
SummaryChip(
|
|
title: "Unread",
|
|
value: model.totalUnreadCount,
|
|
tint: MailTheme.accent.opacity(0.18)
|
|
)
|
|
|
|
SummaryChip(
|
|
title: "Starred",
|
|
value: model.threadCount(in: .starred),
|
|
tint: MailTheme.sunrise.opacity(0.18)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SummaryChip: View {
|
|
let title: String
|
|
let value: Int
|
|
let tint: Color?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(value, format: .number)
|
|
.font(.headline.weight(.semibold))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.socialGlass(in: RoundedRectangle(cornerRadius: 18, style: .continuous), tint: tint)
|
|
}
|
|
}
|
|
|
|
private struct ThreadListView: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.sunrise)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
MailboxFilterBar(model: model)
|
|
MailboxHeroCard(model: model)
|
|
|
|
Group {
|
|
if model.isLoading {
|
|
ProgressView("Loading mail…")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if model.filteredThreads.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Messages",
|
|
systemImage: "tray",
|
|
description: Text("Try another mailbox or relax the filters.")
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
List(model.filteredThreads) { thread in
|
|
Button {
|
|
model.openThread(withID: thread.id)
|
|
} label: {
|
|
ThreadRow(thread: thread, isSelected: thread.id == model.selectedThreadID)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 8, trailing: 18))
|
|
.listRowBackground(Color.clear)
|
|
.contextMenu {
|
|
Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
|
|
model.toggleRead(for: thread)
|
|
}
|
|
|
|
Button(thread.isStarred ? "Remove Star" : "Star Thread") {
|
|
model.toggleStar(for: thread)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom) {
|
|
FloatingComposeButton(model: model)
|
|
}
|
|
.navigationTitle(model.selectedMailbox.title)
|
|
.mailInlineNavigationTitle()
|
|
.mailNavigationChrome()
|
|
}
|
|
}
|
|
|
|
private struct MailboxHeroCard: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(model.selectedMailbox.title)
|
|
.font(.system(.largeTitle, design: .rounded, weight: .bold))
|
|
|
|
Text(mailboxDescription)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
AdaptiveGlassGroup(spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
SummaryChip(
|
|
title: "Visible",
|
|
value: model.filteredThreads.count,
|
|
tint: MailTheme.accent.opacity(0.20)
|
|
)
|
|
|
|
SummaryChip(
|
|
title: "Unread",
|
|
value: model.filteredThreads.filter(\.isUnread).count,
|
|
tint: MailTheme.mint.opacity(0.18)
|
|
)
|
|
|
|
SummaryChip(
|
|
title: "Starred",
|
|
value: model.filteredThreads.filter(\.isStarred).count,
|
|
tint: MailTheme.sunrise.opacity(0.18)
|
|
)
|
|
}
|
|
}
|
|
|
|
if let latestThread = model.filteredThreads.first {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock")
|
|
Text("Latest activity \(latestThread.lastUpdated.formatted(date: .abbreviated, time: .shortened))")
|
|
}
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 22)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(heroBackground, in: RoundedRectangle(cornerRadius: 30, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.stroke(Color.white.opacity(0.20), lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
private var mailboxDescription: String {
|
|
switch model.selectedMailbox {
|
|
case .inbox:
|
|
"Fresh conversations, live signals, and mail worth deciding on now."
|
|
case .starred:
|
|
"Pinned threads that still deserve attention, not just memory."
|
|
case .sent:
|
|
"Everything you shipped recently, ready for quick follow-up."
|
|
case .drafts:
|
|
"Half-finished notes and messages waiting for a final pass."
|
|
case .archive:
|
|
"Quieted threads with context still close at hand."
|
|
}
|
|
}
|
|
|
|
private var heroBackground: some ShapeStyle {
|
|
LinearGradient(
|
|
colors: [
|
|
MailTheme.accent.opacity(0.28),
|
|
MailTheme.ocean.opacity(0.16),
|
|
Color.white.opacity(0.08)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct FloatingComposeButton: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
if shouldShow {
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button {
|
|
model.startCompose()
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "square.and.pencil")
|
|
Text("Compose")
|
|
}
|
|
.font(.headline.weight(.semibold))
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.socialGlass(
|
|
in: Capsule(),
|
|
tint: MailTheme.accent.opacity(0.22),
|
|
interactive: true
|
|
)
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("compose.floating")
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 12)
|
|
.background(Color.clear)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var shouldShow: Bool {
|
|
#if os(iOS)
|
|
UIDevice.current.userInterfaceIdiom == .phone
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct MailboxFilterBar: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
AdaptiveGlassGroup(spacing: 16) {
|
|
HStack(spacing: 12) {
|
|
ForEach(Mailbox.allCases) { mailbox in
|
|
Button {
|
|
model.selectMailbox(mailbox)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: mailbox.systemImage)
|
|
Text(mailbox.title)
|
|
Text(model.threadCount(in: mailbox), format: .number)
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.socialGlass(
|
|
in: Capsule(),
|
|
tint: mailbox == model.selectedMailbox ? MailTheme.accent.opacity(0.18) : nil,
|
|
interactive: true
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("mailbox.\(mailbox.id)")
|
|
}
|
|
|
|
Button {
|
|
model.setUnreadOnly(!model.showUnreadOnly)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: model.showUnreadOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
|
Text("Unread")
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.socialGlass(
|
|
in: Capsule(),
|
|
tint: model.showUnreadOnly ? MailTheme.mint.opacity(0.18) : nil,
|
|
interactive: true
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("filter.unread")
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 14)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ThreadRow: View {
|
|
let thread: MailThread
|
|
let isSelected: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(thread.participants.map(\.name).joined(separator: ", "))
|
|
.font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
|
|
.lineLimit(1)
|
|
|
|
Text(thread.subject)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
if thread.isStarred {
|
|
Image(systemName: "star.fill")
|
|
.foregroundStyle(.yellow)
|
|
}
|
|
|
|
Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Text(thread.previewText)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
|
|
if !thread.tags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(thread.tags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.caption.weight(.medium))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.secondary.opacity(0.10), in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.mailPanelBackground(
|
|
in: RoundedRectangle(cornerRadius: 24, style: .continuous),
|
|
highlight: isSelected ? MailTheme.accent.opacity(0.28) : Color.white.opacity(0.10)
|
|
)
|
|
.accessibilityIdentifier("thread.\(thread.routeID)")
|
|
}
|
|
}
|
|
|
|
private struct ThreadDetailView: View {
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
MailCanvasBackground(primary: MailTheme.mint, secondary: MailTheme.sunrise)
|
|
.ignoresSafeArea()
|
|
|
|
Group {
|
|
if let thread = model.selectedThread {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
ThreadHero(threadID: thread.id, model: model)
|
|
|
|
ForEach(thread.messages) { message in
|
|
MessageCard(
|
|
message: message,
|
|
isLatest: message.id == thread.latestMessage?.id,
|
|
isFocused: message.routeID == model.focusedMessageRouteID
|
|
)
|
|
.id(message.routeID)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(maxWidth: 920, alignment: .leading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.onAppear {
|
|
scrollToFocusedMessage(using: proxy, animated: false)
|
|
}
|
|
.onChange(of: model.focusedMessageRouteID) {
|
|
scrollToFocusedMessage(using: proxy)
|
|
}
|
|
.onChange(of: thread.routeID) {
|
|
scrollToFocusedMessage(using: proxy, animated: false)
|
|
}
|
|
}
|
|
} else {
|
|
ContentUnavailableView(
|
|
"Select a Thread",
|
|
systemImage: "envelope.open",
|
|
description: Text("Choose a conversation to read or compose a new message.")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.navigationTitle("Conversation")
|
|
}
|
|
|
|
private func scrollToFocusedMessage(using proxy: ScrollViewProxy, animated: Bool = true) {
|
|
guard let focusedMessageRouteID = model.focusedMessageRouteID else { return }
|
|
|
|
if animated {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
proxy.scrollTo(focusedMessageRouteID, anchor: .center)
|
|
}
|
|
} else {
|
|
proxy.scrollTo(focusedMessageRouteID, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ThreadHero: View {
|
|
let threadID: MailThread.ID
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let thread = model.thread(withID: threadID) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
if usesCompactHeroLayout {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
heroHeaderContent(for: thread)
|
|
ThreadActionBar(threadID: thread.id, model: model, compact: true)
|
|
}
|
|
} else {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
heroHeaderContent(for: thread)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
ThreadActionBar(threadID: thread.id, model: model)
|
|
}
|
|
}
|
|
|
|
if !thread.tags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(thread.tags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.caption.weight(.medium))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.secondary.opacity(0.10), in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text("Latest update \(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(24)
|
|
.background(heroBackground, in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
.stroke(Color.white.opacity(0.18), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func heroHeaderContent(for thread: MailThread) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
AdaptiveGlassGroup(spacing: 14) {
|
|
if usesCompactHeroLayout {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
heroStatusChips(for: thread)
|
|
}
|
|
}
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
heroStatusChips(for: thread)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text(thread.subject)
|
|
.font(.system(.largeTitle, design: .rounded, weight: .bold))
|
|
|
|
Text(thread.participants.map(\.email).joined(separator: ", "))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func heroStatusChips(for thread: MailThread) -> some View {
|
|
StatusChip(
|
|
title: thread.mailbox.title,
|
|
systemImage: thread.mailbox.systemImage,
|
|
tint: MailTheme.accent.opacity(0.18)
|
|
)
|
|
|
|
StatusChip(
|
|
title: "Unread",
|
|
systemImage: "circle.badge.fill",
|
|
tint: MailTheme.sunrise.opacity(0.18)
|
|
)
|
|
.opacity(thread.isUnread ? 1 : 0)
|
|
.accessibilityHidden(!thread.isUnread)
|
|
}
|
|
|
|
private var heroBackground: some ShapeStyle {
|
|
LinearGradient(
|
|
colors: [
|
|
MailTheme.accent.opacity(0.22),
|
|
MailTheme.mint.opacity(0.12),
|
|
Color.white.opacity(0.06)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
}
|
|
|
|
private var usesCompactHeroLayout: Bool {
|
|
#if os(iOS)
|
|
UIDevice.current.userInterfaceIdiom == .phone
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct StatusChip: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let tint: Color?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
Text(title)
|
|
}
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.socialGlass(in: Capsule(), tint: tint)
|
|
}
|
|
}
|
|
|
|
private struct ThreadActionBar: View {
|
|
let threadID: MailThread.ID
|
|
@Bindable var model: AppViewModel
|
|
var compact = false
|
|
private let controlAnimation = Animation.snappy(duration: 0.24, extraBounce: 0.03)
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let thread = model.thread(withID: threadID) {
|
|
HStack(spacing: compact ? 10 : 12) {
|
|
actionButtons(for: thread)
|
|
}
|
|
.animation(controlAnimation, value: thread.isStarred)
|
|
.animation(controlAnimation, value: thread.isUnread)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func actionButtons(for thread: MailThread) -> some View {
|
|
Group {
|
|
actionButton(
|
|
title: thread.isStarred ? "Starred" : "Star",
|
|
systemImage: thread.isStarred ? "star.fill" : "star",
|
|
tint: thread.isStarred ? MailTheme.sunrise.opacity(0.22) : nil
|
|
) {
|
|
withAnimation(controlAnimation) {
|
|
model.toggleStar(forThreadID: thread.id)
|
|
}
|
|
}
|
|
|
|
actionButton(
|
|
title: thread.isUnread ? "Mark Read" : "Mark Unread",
|
|
systemImage: thread.isUnread ? "envelope.open.fill" : "envelope.badge",
|
|
tint: thread.isUnread ? MailTheme.mint.opacity(0.20) : nil
|
|
) {
|
|
withAnimation(controlAnimation) {
|
|
model.toggleRead(forThreadID: thread.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func actionButton(
|
|
title: String,
|
|
systemImage: String,
|
|
tint: Color?,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
.contentTransition(.symbolEffect(.replace))
|
|
Text(title)
|
|
.lineLimit(1)
|
|
.contentTransition(.opacity)
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.stableControlPill(tint: tint)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.animation(controlAnimation, value: title)
|
|
.animation(controlAnimation, value: systemImage)
|
|
.animation(controlAnimation, value: tint != nil)
|
|
}
|
|
}
|
|
|
|
private struct MessageCard: View {
|
|
let message: MailMessage
|
|
let isLatest: Bool
|
|
let isFocused: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(message.sender.name)
|
|
.font(.headline)
|
|
Text(message.sender.email)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Text(message.sentAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Text(message.body)
|
|
.font(.body)
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(20)
|
|
.mailPanelBackground(
|
|
in: RoundedRectangle(cornerRadius: 28, style: .continuous),
|
|
highlight: messageHighlight
|
|
)
|
|
.overlay(alignment: .topTrailing) {
|
|
if isFocused {
|
|
Text("Focused")
|
|
.font(.caption2.weight(.bold))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.socialGlass(in: Capsule(), tint: MailTheme.accent.opacity(0.18))
|
|
.padding(14)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("message.\(message.routeID)")
|
|
}
|
|
|
|
private var messageHighlight: Color {
|
|
if isFocused {
|
|
return MailTheme.accent.opacity(0.38)
|
|
}
|
|
|
|
if isLatest {
|
|
return MailTheme.accent.opacity(0.22)
|
|
}
|
|
|
|
return Color.white.opacity(0.10)
|
|
}
|
|
}
|
|
|
|
private struct ComposeView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Bindable var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
if usesCompactComposeLayout {
|
|
composeScene
|
|
} else {
|
|
composeScene
|
|
.frame(minWidth: 560, minHeight: 520)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("compose.view")
|
|
}
|
|
|
|
private var composeScene: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
MailCanvasBackground(primary: MailTheme.accent, secondary: MailTheme.sunrise)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("New Message")
|
|
.font(.system(.largeTitle, design: .rounded, weight: .bold))
|
|
Text("Keep the controls light and let the conversation do the work.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
ComposeFieldCard(title: "To") {
|
|
toField
|
|
}
|
|
|
|
ComposeFieldCard(title: "Subject") {
|
|
TextField("What's this about?", text: $model.composeDraft.subject)
|
|
.textFieldStyle(.plain)
|
|
.disabled(model.isSending)
|
|
.accessibilityIdentifier("compose.subject")
|
|
}
|
|
|
|
ComposeFieldCard(title: "Message") {
|
|
TextEditor(text: $model.composeDraft.body)
|
|
.scrollContentBackground(.hidden)
|
|
.frame(minHeight: 240)
|
|
.disabled(model.isSending)
|
|
.accessibilityIdentifier("compose.body")
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(usesCompactComposeLayout ? 20 : 24)
|
|
.frame(maxWidth: 720, alignment: .topLeading)
|
|
.frame(maxWidth: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
.navigationTitle("Compose")
|
|
.composeNavigationTitleDisplayMode(isCompact: usesCompactComposeLayout)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
.disabled(model.isSending)
|
|
.accessibilityIdentifier("compose.cancel")
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(model.isSending ? "Sending…" : "Send") {
|
|
Task {
|
|
_ = await model.sendCurrentDraft()
|
|
}
|
|
}
|
|
.disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
|
|
.accessibilityIdentifier("compose.send")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var toField: some View {
|
|
#if os(iOS)
|
|
TextField("name@example.com", text: $model.composeDraft.to)
|
|
.textFieldStyle(.plain)
|
|
.textContentType(.emailAddress)
|
|
.keyboardType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.disabled(model.isSending)
|
|
.accessibilityIdentifier("compose.to")
|
|
#else
|
|
TextField("name@example.com", text: $model.composeDraft.to)
|
|
.textFieldStyle(.plain)
|
|
.textContentType(.emailAddress)
|
|
.disabled(model.isSending)
|
|
.accessibilityIdentifier("compose.to")
|
|
#endif
|
|
}
|
|
|
|
private var usesCompactComposeLayout: Bool {
|
|
#if os(iOS)
|
|
UIDevice.current.userInterfaceIdiom == .phone
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func composeNavigationTitleDisplayMode(isCompact: Bool) -> some View {
|
|
#if os(iOS)
|
|
navigationBarTitleDisplayMode(isCompact ? .inline : .automatic)
|
|
#else
|
|
self
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct ComposeFieldCard<Content: View>: View {
|
|
let title: String
|
|
let content: Content
|
|
|
|
init(title: String, @ViewBuilder content: () -> Content) {
|
|
self.title = title
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
content
|
|
}
|
|
.padding(18)
|
|
.mailPanelBackground(in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct MailCanvasBackground: View {
|
|
let primary: Color
|
|
let secondary: Color
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [
|
|
platformBackgroundColor,
|
|
primary.opacity(0.10),
|
|
secondary.opacity(0.12)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
Circle()
|
|
.fill(primary.opacity(0.22))
|
|
.frame(width: 360, height: 360)
|
|
.blur(radius: 90)
|
|
.offset(x: -160, y: -240)
|
|
|
|
Circle()
|
|
.fill(secondary.opacity(0.20))
|
|
.frame(width: 300, height: 300)
|
|
.blur(radius: 90)
|
|
.offset(x: 210, y: 260)
|
|
|
|
Circle()
|
|
.fill(Color.white.opacity(0.10))
|
|
.frame(width: 220, height: 220)
|
|
.blur(radius: 70)
|
|
.offset(x: 180, y: -220)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AdaptiveGlassGroup<Content: View>: View {
|
|
let spacing: CGFloat?
|
|
let content: Content
|
|
|
|
init(spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
|
|
self.spacing = spacing
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
if #available(iOS 26.0, macOS 26.0, *) {
|
|
GlassEffectContainer(spacing: spacing) {
|
|
content
|
|
}
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func socialGlass<S: Shape>(
|
|
in shape: S,
|
|
tint: Color? = nil,
|
|
interactive: Bool = false
|
|
) -> some View {
|
|
if #available(iOS 26.0, macOS 26.0, *) {
|
|
glassEffect(
|
|
Glass.regular.tint(tint).interactive(interactive),
|
|
in: shape
|
|
)
|
|
} else {
|
|
background(.ultraThinMaterial, in: shape)
|
|
.overlay(
|
|
shape.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
func mailPanelBackground<S: Shape>(
|
|
in shape: S,
|
|
highlight: Color = Color.white.opacity(0.10)
|
|
) -> some View {
|
|
background(.regularMaterial, in: shape)
|
|
.overlay(
|
|
shape.stroke(highlight, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
func stableControlPill(tint: Color?) -> some View {
|
|
background {
|
|
Capsule()
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
Capsule()
|
|
.fill(tint ?? .clear)
|
|
)
|
|
.overlay(
|
|
Capsule()
|
|
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func mailNavigationChrome() -> some View {
|
|
#if os(iOS)
|
|
toolbarBackground(.hidden, for: .navigationBar)
|
|
#else
|
|
self
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
func mailInlineNavigationTitle() -> some View {
|
|
#if os(iOS)
|
|
navigationBarTitleDisplayMode(.inline)
|
|
#else
|
|
self
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private var platformBackgroundColor: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .windowBackgroundColor)
|
|
#else
|
|
Color(uiColor: .systemBackground)
|
|
#endif
|
|
}
|