Files
swiftapp/swift/Sources/Features/Mail/MailRootView.swift
Jürgen Kunz ad059e9b8d Add MailRootView and related components for mail functionality
- 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.
2026-04-19 01:00:32 +02:00

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
}