Files
swiftapp/swift/Sources/Features/Mail/MailRootView.swift
Jürgen Kunz 549aaa634c Align Mail UI with social.io design handoff
Rewrite the Mail feature to match the Apple-native look from the
handoff spec: lane-split inbox, AI summary card, clean ThreadRow,
Cc/From + format toolbar in Compose. Drop the gradient hero
surfaces and blurred canvas backgrounds the spec calls out as
anti-patterns, and introduce a token-backed design layer so the
lane palette and SIO tint live in the asset catalog.

- Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople
  (light + dark variants).
- Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum,
  LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles,
  and a glass-chrome helper with iOS 26 / material fallback.
- Extend MailThread with lane + summary; custom Codable keeps old
  payloads decodable. Seed mock threads with sensible lanes and
  hand-write summaries on launch-copy, investor-update, roadmap-notes.
- Add lane filtering to AppViewModel (selectedLane, selectLane,
  laneUnreadCount, laneThreadCount).
- Rewrite MailRootView end to end: sidebar with Inbox/lane rows,
  lane filter strip, Apple-native ThreadRow (avatar, unread dot,
  lane chip, summary chip), ThreadReadingView with AI summary +
  floating reply pill, ComposeView with To/Cc/From/Subject and a
  format toolbar.
- Wire Assets.xcassets + SIODesign.swift into project.pbxproj.

Accessibility identifiers preserved byte-identical; new ones
(mailbox.lane.*, lane.chip.*) added only where new.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:10 +02:00

965 lines
33 KiB
Swift

import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
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: {
ThreadReadingView(model: model)
}
.navigationSplitViewStyle(.balanced)
.tint(SIO.tint)
.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
}
}
// MARK: - Sidebar
private struct MailSidebarView: View {
@Bindable var model: AppViewModel
var body: some View {
List {
Section {
SidebarAccountHeader()
.listRowInsets(EdgeInsets(top: 14, leading: 14, bottom: 10, trailing: 14))
.listRowBackground(Color.clear)
}
Section("Inbox") {
sidebarButton(
title: "All",
systemImage: "tray.full",
badge: model.threadCount(in: .inbox),
isSelected: model.selectedMailbox == .inbox && model.selectedLane == nil
) {
model.selectMailbox(.inbox)
model.selectLane(nil)
}
.accessibilityIdentifier("sidebar.mailbox.inbox")
ForEach(Lane.allCases, id: \.self) { lane in
sidebarLaneButton(lane: lane)
.accessibilityIdentifier("mailbox.lane.\(lane.rawValue)")
}
}
Section("Smart") {
sidebarButton(
title: "Starred",
systemImage: "star",
badge: model.threadCount(in: .starred),
isSelected: model.selectedMailbox == .starred
) {
model.selectMailbox(.starred)
model.selectLane(nil)
}
.accessibilityIdentifier("sidebar.mailbox.starred")
}
Section("Mailboxes") {
ForEach([Mailbox.sent, .drafts, .archive], id: \.self) { mailbox in
sidebarButton(
title: mailbox.title,
systemImage: mailbox.systemImage,
badge: model.threadCount(in: mailbox),
isSelected: model.selectedMailbox == mailbox
) {
model.selectMailbox(mailbox)
model.selectLane(nil)
}
.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)
}
}
.accessibilityIdentifier("filter.unread")
}
}
.listStyle(.sidebar)
.navigationTitle("social.io")
}
private var unreadOnlyBinding: Binding<Bool> {
Binding(
get: { model.showUnreadOnly },
set: { model.setUnreadOnly($0) }
)
}
@ViewBuilder
private func sidebarButton(
title: String,
systemImage: String,
badge: Int,
isSelected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: systemImage)
.frame(width: 20)
.foregroundStyle(isSelected ? SIO.tint : .secondary)
Text(title)
.foregroundStyle(isSelected ? .primary : .primary)
.fontWeight(isSelected ? .semibold : .regular)
Spacer()
if badge > 0 {
Text(badge, format: .number)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(isSelected ? SIO.tint.opacity(0.10) : Color.clear)
}
@ViewBuilder
private func sidebarLaneButton(lane: Lane) -> some View {
let isSelected = model.selectedMailbox == .inbox && model.selectedLane == lane
Button {
model.selectMailbox(.inbox)
model.selectLane(lane)
} label: {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(lane.color)
.frame(width: 14, height: 14)
Text(lane.label)
.foregroundStyle(.primary)
.fontWeight(isSelected ? .semibold : .regular)
Spacer()
let count = model.laneUnreadCount(lane)
if count > 0 {
Text(count, format: .number)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(isSelected ? SIO.tint.opacity(0.10) : Color.clear)
}
}
private struct SidebarAccountHeader: View {
var body: some View {
HStack(spacing: 12) {
AvatarView(name: "Phil Kunz", size: 34, tint: SIO.tint)
VStack(alignment: .leading, spacing: 1) {
Text("Phil Kunz")
.font(.subheadline.weight(.semibold))
Text("phil@social.io")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}
// MARK: - Thread list
private struct ThreadListView: View {
@Bindable var model: AppViewModel
var body: some View {
VStack(spacing: 0) {
if model.selectedMailbox == .inbox {
LaneFilterStrip(model: model)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 12)
}
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(selection: selectionBinding) {
ForEach(model.filteredThreads) { thread in
Button {
model.openThread(withID: thread.id)
} label: {
ThreadRow(thread: thread, isSelected: thread.id == model.selectedThreadID)
}
.buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
.listRowBackground(
thread.id == model.selectedThreadID
? SIO.tint.opacity(0.10)
: 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)
}
}
.safeAreaInset(edge: .bottom) {
FloatingComposeButton(model: model)
}
.navigationTitle(navigationTitleText)
.mailInlineNavigationTitle()
}
private var navigationTitleText: String {
if model.selectedMailbox == .inbox, let lane = model.selectedLane {
return lane.label
}
return model.selectedMailbox.title
}
private var selectionBinding: Binding<MailThread.ID?> {
Binding(
get: { model.selectedThreadID },
set: { newValue in
if let id = newValue {
model.openThread(withID: id)
} else {
model.dismissThreadSelection()
}
}
)
}
}
private struct LaneFilterStrip: View {
@Bindable var model: AppViewModel
var body: some View {
HStack(spacing: 8) {
laneChip(
label: "All",
color: .secondary,
count: model.threadCount(in: .inbox),
isSelected: model.selectedLane == nil,
id: "all"
) {
model.selectLane(nil)
}
ForEach(Lane.allCases, id: \.self) { lane in
laneChip(
label: lane.label,
color: lane.color,
count: model.laneUnreadCount(lane),
isSelected: model.selectedLane == lane,
id: lane.rawValue
) {
model.selectLane(lane)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func laneChip(
label: String,
color: Color,
count: Int,
isSelected: Bool,
id: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label.uppercased())
.font(.caption2.weight(.semibold))
.tracking(0.4)
.foregroundStyle(.secondary)
}
Text(count, format: .number)
.font(.system(size: 20, weight: .bold, design: .default))
.monospacedDigit()
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.fill(isSelected ? color.opacity(0.14) : Color.secondary.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.stroke(isSelected ? color.opacity(0.35) : Color.clear, lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("lane.chip.\(id)")
}
}
private struct ThreadRow: View {
let thread: MailThread
let isSelected: Bool
var body: some View {
HStack(alignment: .top, spacing: 10) {
// Unread dot or spacer
ZStack {
if thread.isUnread {
Circle().fill(SIO.tint).frame(width: 8, height: 8)
}
}
.frame(width: 8, height: 8)
.padding(.top, 6)
AvatarView(
name: senderName,
size: 32,
tint: thread.participants.first?.avatarTint ?? SIO.tint
)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(senderName)
.font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
.foregroundStyle(isSelected ? SIO.tint : .primary)
.lineLimit(1)
if thread.messages.count > 1 {
Text("\(thread.messages.count)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Spacer(minLength: 4)
if thread.isStarred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(thread.lastUpdated.formatted(.relative(presentation: .named)))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Text(thread.subject)
.font(.subheadline.weight(thread.isUnread ? .medium : .regular))
.foregroundStyle(thread.isUnread ? .primary : .secondary)
.lineLimit(1)
Text(thread.previewText)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 6) {
LaneChip(lane: thread.lane, compact: true)
if thread.summary != nil {
HStack(spacing: 3) {
Image(systemName: "sparkles")
Text("Summary")
}
.font(.caption2.weight(.semibold))
.foregroundStyle(SIO.tint)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
}
}
.padding(.top, 2)
}
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.accessibilityIdentifier("thread.\(thread.routeID)")
}
private var senderName: String {
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? ""
}
}
// MARK: - Thread reading
private struct ThreadReadingView: View {
@Bindable var model: AppViewModel
var body: some View {
Group {
if let thread = model.selectedThread {
ThreadReadingContent(thread: thread, model: model)
} 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 struct ThreadReadingContent: View {
let thread: MailThread
@Bindable var model: AppViewModel
var body: some View {
ZStack(alignment: .bottom) {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ThreadHeader(thread: thread)
if let summary = thread.summary, !summary.isEmpty {
AISummaryCard(
messageCount: thread.messages.count,
bullets: summary
)
}
ForEach(thread.messages) { message in
MessageCard(
message: message,
isFocused: message.routeID == model.focusedMessageRouteID
)
.id(message.routeID)
}
Color.clear.frame(height: 84)
}
.padding(.horizontal, 18)
.padding(.top, 10)
.frame(maxWidth: 820, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .top)
}
.onAppear { scrollToFocus(proxy, animated: false) }
.onChange(of: model.focusedMessageRouteID) { scrollToFocus(proxy) }
.onChange(of: thread.routeID) { scrollToFocus(proxy, animated: false) }
}
ReplyPill(thread: thread) {
model.startCompose()
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { model.toggleStar(for: thread) } label: {
Image(systemName: thread.isStarred ? "star.fill" : "star")
}
Button { model.toggleRead(for: thread) } label: {
Image(systemName: thread.isUnread ? "envelope.open" : "envelope.badge")
}
Button { model.startCompose() } label: {
Image(systemName: "arrowshape.turn.up.left")
}
}
}
}
private func scrollToFocus(_ proxy: ScrollViewProxy, animated: Bool = true) {
guard let id = model.focusedMessageRouteID else { return }
if animated {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(id, anchor: .center)
}
} else {
proxy.scrollTo(id, anchor: .center)
}
}
}
private struct ThreadHeader: View {
let thread: MailThread
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(thread.subject)
.font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
HStack(spacing: -8) {
ForEach(thread.participants.prefix(3)) { person in
AvatarView(name: person.name, size: 22, tint: person.avatarTint)
.overlay(Circle().stroke(Color.systemBackground, lineWidth: 2))
}
}
.padding(.leading, 4)
LaneChip(lane: thread.lane, compact: true)
Text("\(thread.participants.count) people · \(thread.messages.count) messages")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(thread.lastUpdated.formatted(.relative(presentation: .named)))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private struct MessageCard: View {
let message: MailMessage
let isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
AvatarView(name: message.sender.name, size: 32, tint: message.sender.avatarTint)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline) {
Text(message.sender.name)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
Text(message.sentAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Text("to \(message.recipients.map(\.name).joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text(message.body)
.font(.system(size: SIO.bodyFontSize))
.lineSpacing(5)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Color.cardBackground,
in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.stroke(Color.primary.opacity(0.08), lineWidth: 0.5)
)
.overlay(alignment: .topTrailing) {
if isFocused {
RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
.stroke(SIO.tint.opacity(0.35), lineWidth: 1.5)
}
}
.accessibilityIdentifier("message.\(message.routeID)")
}
}
private struct ReplyPill: View {
let thread: MailThread
let onTap: () -> Void
var body: some View {
HStack(spacing: 8) {
Button(action: onTap) {
HStack {
Text("Reply to \(senderFirstName)")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Capsule().fill(Color.cardBackground)
)
.overlay(Capsule().stroke(Color.primary.opacity(0.10), lineWidth: 0.5))
.contentShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onTap) {
Image(systemName: "paperplane.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(SIO.tint, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Send reply")
}
.sioGlassChromeContainer(spacing: 8)
}
private var senderFirstName: String {
let other = thread.latestMessage?.sender.name ?? ""
return other.split(separator: " ").first.map(String.init) ?? other
}
}
// MARK: - Floating compose (iPhone only)
private struct FloatingComposeButton: View {
@Bindable var model: AppViewModel
var body: some View {
if shouldShow {
HStack {
Spacer()
Button {
model.startCompose()
} label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(SIO.tint, in: Circle())
.shadow(color: SIO.tint.opacity(0.35), radius: 14, x: 0, y: 6)
}
.buttonStyle(.plain)
.accessibilityIdentifier("compose.floating")
.padding(.trailing, 18)
.padding(.bottom, 18)
}
}
}
private var shouldShow: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .phone
#else
false
#endif
}
}
// MARK: - Compose
private struct ComposeView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var model: AppViewModel
@State private var cc: String = ""
var body: some View {
Group {
if usesCompactLayout {
composeBody
} else {
composeBody.frame(minWidth: 560, minHeight: 520)
}
}
.accessibilityIdentifier("compose.view")
}
private var composeBody: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
composeRow(label: "To", placeholder: "name@example.com", text: $model.composeDraft.to)
.accessibilityIdentifier("compose.to")
Divider()
composeRow(label: "Cc", placeholder: "", text: $cc)
Divider()
fromRow
Divider()
composeRow(label: "Subject", placeholder: "What's this about?", text: $model.composeDraft.subject, accessID: "compose.subject")
Divider()
ZStack(alignment: .topLeading) {
if model.composeDraft.body.isEmpty {
Text("Write your message…")
.font(.system(size: SIO.bodyFontSize))
.foregroundStyle(.secondary)
.padding(.horizontal, 18)
.padding(.vertical, 14)
}
TextEditor(text: $model.composeDraft.body)
.font(.system(size: SIO.bodyFontSize))
.scrollContentBackground(.hidden)
.frame(minHeight: 220)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.disabled(model.isSending)
.accessibilityIdentifier("compose.body")
}
}
}
.scrollDismissesKeyboard(.interactively)
FormatToolbar()
}
.navigationTitle("New Message")
.composeTitleDisplay(compact: usesCompactLayout)
.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() }
}
.buttonStyle(.borderedProminent)
.tint(SIO.tint)
.disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
.accessibilityIdentifier("compose.send")
}
}
}
}
@ViewBuilder
private func composeRow(label: String, placeholder: String, text: Binding<String>, accessID: String? = nil) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(label)
.font(.system(size: 15))
.foregroundStyle(.secondary)
.frame(width: 52, alignment: .leading)
composeTextField(placeholder: placeholder, text: text)
.font(.system(size: 15))
.disabled(model.isSending)
.modifier(OptionalAccessibility(id: accessID))
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
@ViewBuilder
private func composeTextField(placeholder: String, text: Binding<String>) -> some View {
#if os(iOS)
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
#else
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.textContentType(.emailAddress)
#endif
}
private var fromRow: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text("From")
.font(.system(size: 15))
.foregroundStyle(.secondary)
.frame(width: 52, alignment: .leading)
Text("phil@social.io")
.font(.system(size: 15))
.foregroundStyle(.primary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
private var usesCompactLayout: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .phone
#else
false
#endif
}
}
private struct OptionalAccessibility: ViewModifier {
let id: String?
func body(content: Content) -> some View {
if let id { content.accessibilityIdentifier(id) } else { content }
}
}
private struct FormatToolbar: View {
var body: some View {
HStack(spacing: 18) {
Text("Aa").font(.system(size: 16))
Text("B").font(.system(size: 16, weight: .bold))
Text("I").font(.system(size: 16)).italic()
Text("U").font(.system(size: 16)).underline()
Image(systemName: "paperclip")
Image(systemName: "camera")
Spacer(minLength: 0)
}
.font(.system(size: 16))
.foregroundStyle(.secondary)
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(.bar)
}
}
// MARK: - Platform helpers
private extension View {
@ViewBuilder
func mailInlineNavigationTitle() -> some View {
#if os(iOS)
self.navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
@ViewBuilder
func composeTitleDisplay(compact: Bool) -> some View {
#if os(iOS)
self.navigationBarTitleDisplayMode(compact ? .inline : .automatic)
#else
self
#endif
}
}
private extension Color {
static var systemBackground: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
}
static var cardBackground: Color {
#if os(iOS)
Color(uiColor: .secondarySystemBackground)
#else
Color(nsColor: .textBackgroundColor)
#endif
}
}