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>
196 lines
6.1 KiB
Swift
196 lines
6.1 KiB
Swift
import SwiftUI
|
|
|
|
enum SIO {
|
|
static let tint = Color("SIOTint")
|
|
static let laneFeed = Color("LaneFeed")
|
|
static let lanePaper = Color("LanePaper")
|
|
static let lanePeople = Color("LanePeople")
|
|
|
|
static let cardRadius: CGFloat = 14
|
|
static let controlRadius: CGFloat = 10
|
|
static let chipRadius: CGFloat = 6
|
|
|
|
static let bodyFontSize: CGFloat = 15.5
|
|
static let bodyLineSpacing: CGFloat = 15.5 * 0.55
|
|
}
|
|
|
|
enum Lane: String, CaseIterable, Codable, Hashable {
|
|
case feed
|
|
case paper
|
|
case people
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .feed: "Feed"
|
|
case .paper: "Paper"
|
|
case .people: "People"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .feed: SIO.laneFeed
|
|
case .paper: SIO.lanePaper
|
|
case .people: SIO.lanePeople
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
@ViewBuilder
|
|
func sioGlassChrome<S: Shape>(in shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
|
if #available(iOS 26.0, macOS 26.0, *) {
|
|
self.glassEffect(
|
|
Glass.regular.tint(tint).interactive(interactive),
|
|
in: shape
|
|
)
|
|
} else {
|
|
self
|
|
.background(.ultraThinMaterial, in: shape)
|
|
.overlay(shape.stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func sioGlassChromeContainer(spacing: CGFloat? = nil) -> some View {
|
|
if #available(iOS 26.0, macOS 26.0, *) {
|
|
GlassEffectContainer(spacing: spacing) { self }
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LaneChip: View {
|
|
let lane: Lane
|
|
var compact: Bool = false
|
|
|
|
var body: some View {
|
|
Text(lane.label)
|
|
.font(.caption2.weight(.semibold))
|
|
.tracking(0.2)
|
|
.foregroundStyle(lane.color)
|
|
.padding(.horizontal, compact ? 6 : 8)
|
|
.padding(.vertical, compact ? 2 : 3)
|
|
.background(
|
|
lane.color.opacity(0.14),
|
|
in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct AvatarView: View {
|
|
let name: String
|
|
let size: CGFloat
|
|
var tint: Color = SIO.tint
|
|
|
|
var initials: String {
|
|
let parts = name.split(separator: " ").prefix(2)
|
|
return parts.compactMap { $0.first.map(String.init) }.joined().uppercased()
|
|
}
|
|
|
|
var body: some View {
|
|
Text(initials)
|
|
.font(.system(size: size * 0.42, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: size, height: size)
|
|
.background(tint, in: Circle())
|
|
}
|
|
}
|
|
|
|
struct AISummaryCard: View {
|
|
let messageCount: Int
|
|
let bullets: [String]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "sparkles")
|
|
.font(.caption2.weight(.bold))
|
|
Text("SUMMARY · \(messageCount) messages")
|
|
.font(.caption2.weight(.bold))
|
|
.tracking(0.6)
|
|
}
|
|
.foregroundStyle(SIO.tint)
|
|
|
|
ForEach(Array(bullets.enumerated()), id: \.offset) { _, line in
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text("·").foregroundStyle(SIO.tint)
|
|
Text(line)
|
|
.font(.callout)
|
|
.foregroundStyle(.primary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(SIO.tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(SIO.tint.opacity(0.22), lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct KeyboardHint: View {
|
|
let label: String
|
|
|
|
var body: some View {
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1)
|
|
.background(.secondary.opacity(0.10), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
.stroke(.secondary.opacity(0.20), lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct PrimaryActionStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(SIO.tint, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
|
|
.opacity(configuration.isPressed ? 0.85 : 1)
|
|
}
|
|
}
|
|
|
|
struct SecondaryActionStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.background(.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
|
|
.opacity(configuration.isPressed ? 0.85 : 1)
|
|
}
|
|
}
|
|
|
|
struct DestructiveActionStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.red)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
|
|
.opacity(configuration.isPressed ? 0.85 : 1)
|
|
}
|
|
}
|
|
|
|
extension MailPerson {
|
|
var avatarTint: Color {
|
|
let hash = email.unicodeScalars.reduce(0) { $0 &+ Int($1.value) }
|
|
let palette: [Color] = [SIO.laneFeed, SIO.lanePaper, SIO.lanePeople, SIO.tint, .purple, .pink, .teal]
|
|
return palette[abs(hash) % palette.count]
|
|
}
|
|
}
|