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>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.420",
|
||||||
|
"red" : "0.184"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.561",
|
||||||
|
"red" : "0.369"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.039",
|
||||||
|
"green" : "0.624",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.251",
|
||||||
|
"green" : "0.702",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.345",
|
||||||
|
"green" : "0.820",
|
||||||
|
"red" : "0.188"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.447",
|
||||||
|
"green" : "0.847",
|
||||||
|
"red" : "0.392"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.420",
|
||||||
|
"red" : "0.184"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.561",
|
||||||
|
"red" : "0.369"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
|
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
|
||||||
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
|
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
|
||||||
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
|
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
|
||||||
|
A10000000000000000000010 /* SIODesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* SIODesign.swift */; };
|
||||||
|
A10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* Assets.xcassets */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
|
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
|
||||||
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
|
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A20000000000000000000020 /* SIODesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIODesign.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000021 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
children = (
|
children = (
|
||||||
A40000000000000000000003 /* Sources */,
|
A40000000000000000000003 /* Sources */,
|
||||||
A4000000000000000000000B /* Tests */,
|
A4000000000000000000000B /* Tests */,
|
||||||
|
A20000000000000000000021 /* Assets.xcassets */,
|
||||||
);
|
);
|
||||||
name = SocialIO;
|
name = SocialIO;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -103,10 +108,19 @@
|
|||||||
children = (
|
children = (
|
||||||
A40000000000000000000006 /* Models */,
|
A40000000000000000000006 /* Models */,
|
||||||
A40000000000000000000007 /* Services */,
|
A40000000000000000000007 /* Services */,
|
||||||
|
A40000000000000000000020 /* Design */,
|
||||||
);
|
);
|
||||||
path = Core;
|
path = Core;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A40000000000000000000020 /* Design */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000020 /* SIODesign.swift */,
|
||||||
|
);
|
||||||
|
path = Design;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A40000000000000000000006 /* Models */ = {
|
A40000000000000000000006 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -238,6 +252,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A10000000000000000000011 /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -262,6 +277,7 @@
|
|||||||
A10000000000000000000001 /* SocialIOApp.swift in Sources */,
|
A10000000000000000000001 /* SocialIOApp.swift in Sources */,
|
||||||
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
|
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
|
||||||
A10000000000000000000007 /* AppControlService.swift in Sources */,
|
A10000000000000000000007 /* AppControlService.swift in Sources */,
|
||||||
|
A10000000000000000000010 /* SIODesign.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Observation
|
|||||||
@Observable
|
@Observable
|
||||||
final class AppViewModel {
|
final class AppViewModel {
|
||||||
var selectedMailbox: Mailbox = .inbox
|
var selectedMailbox: Mailbox = .inbox
|
||||||
|
var selectedLane: Lane?
|
||||||
var selectedThreadID: MailThread.ID?
|
var selectedThreadID: MailThread.ID?
|
||||||
var focusedMessageRouteID: String?
|
var focusedMessageRouteID: String?
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
@@ -44,6 +45,10 @@ final class AppViewModel {
|
|||||||
.filter { thread in
|
.filter { thread in
|
||||||
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
|
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
|
||||||
}
|
}
|
||||||
|
.filter { thread in
|
||||||
|
guard let lane = selectedLane else { return true }
|
||||||
|
return thread.lane == lane
|
||||||
|
}
|
||||||
.filter { thread in
|
.filter { thread in
|
||||||
!showUnreadOnly || thread.isUnread
|
!showUnreadOnly || thread.isUnread
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,27 @@ final class AppViewModel {
|
|||||||
.sorted { $0.lastUpdated > $1.lastUpdated }
|
.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func laneUnreadCount(_ lane: Lane) -> Int {
|
||||||
|
threads.filter { thread in
|
||||||
|
let inCurrentMailbox = selectedMailbox == .starred
|
||||||
|
? thread.isStarred
|
||||||
|
: thread.mailbox == selectedMailbox
|
||||||
|
return inCurrentMailbox && thread.lane == lane && thread.isUnread
|
||||||
|
}.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func laneThreadCount(_ lane: Lane) -> Int {
|
||||||
|
threads.filter { thread in
|
||||||
|
thread.mailbox == .inbox && thread.lane == lane
|
||||||
|
}.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectLane(_ lane: Lane?) {
|
||||||
|
selectedLane = lane
|
||||||
|
clearThreadSelection()
|
||||||
|
mailboxNavigationToken = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
var totalUnreadCount: Int {
|
var totalUnreadCount: Int {
|
||||||
threads.filter(\.isUnread).count
|
threads.filter(\.isUnread).count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct SocialIOApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MailRootView(model: model)
|
MailRootView(model: model)
|
||||||
.tint(MailTheme.accent)
|
.tint(SIO.tint)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
model.apply(url: url)
|
model.apply(url: url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ struct MailThread: Identifiable, Hashable, Codable {
|
|||||||
var isUnread: Bool
|
var isUnread: Bool
|
||||||
var isStarred: Bool
|
var isStarred: Bool
|
||||||
var tags: [String]
|
var tags: [String]
|
||||||
|
var lane: Lane
|
||||||
|
var summary: [String]?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -90,7 +92,9 @@ struct MailThread: Identifiable, Hashable, Codable {
|
|||||||
messages: [MailMessage],
|
messages: [MailMessage],
|
||||||
isUnread: Bool,
|
isUnread: Bool,
|
||||||
isStarred: Bool,
|
isStarred: Bool,
|
||||||
tags: [String] = []
|
tags: [String] = [],
|
||||||
|
lane: Lane = .people,
|
||||||
|
summary: [String]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.routeID = routeID
|
self.routeID = routeID
|
||||||
@@ -101,6 +105,29 @@ struct MailThread: Identifiable, Hashable, Codable {
|
|||||||
self.isUnread = isUnread
|
self.isUnread = isUnread
|
||||||
self.isStarred = isStarred
|
self.isStarred = isStarred
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
|
self.lane = lane
|
||||||
|
self.summary = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, routeID, mailbox, subject, participants, messages
|
||||||
|
case isUnread, isStarred, tags, lane, summary
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(UUID.self, forKey: .id)
|
||||||
|
self.routeID = try c.decode(String.self, forKey: .routeID)
|
||||||
|
self.mailbox = try c.decode(Mailbox.self, forKey: .mailbox)
|
||||||
|
self.subject = try c.decode(String.self, forKey: .subject)
|
||||||
|
self.participants = try c.decode([MailPerson].self, forKey: .participants)
|
||||||
|
let rawMessages = try c.decode([MailMessage].self, forKey: .messages)
|
||||||
|
self.messages = rawMessages.sorted { $0.sentAt < $1.sentAt }
|
||||||
|
self.isUnread = try c.decode(Bool.self, forKey: .isUnread)
|
||||||
|
self.isStarred = try c.decode(Bool.self, forKey: .isStarred)
|
||||||
|
self.tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
|
||||||
|
self.lane = try c.decodeIfPresent(Lane.self, forKey: .lane) ?? .people
|
||||||
|
self.summary = try c.decodeIfPresent([String].self, forKey: .summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestMessage: MailMessage? {
|
var latestMessage: MailMessage? {
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: true,
|
isUnread: true,
|
||||||
isStarred: true,
|
isStarred: true,
|
||||||
tags: ["Design", "Launch"]
|
tags: ["Design", "Launch"],
|
||||||
|
lane: .people,
|
||||||
|
summary: [
|
||||||
|
"Tanya tightened the onboarding copy and softened the empty-state tone.",
|
||||||
|
"Phil signed off with a note to keep playful language on iPhone.",
|
||||||
|
"Desktop first-run copy still needs a lighter pass before handoff."
|
||||||
|
]
|
||||||
),
|
),
|
||||||
MailThread(
|
MailThread(
|
||||||
routeID: "daily-sync-status",
|
routeID: "daily-sync-status",
|
||||||
@@ -100,7 +106,8 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: false,
|
isUnread: false,
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
tags: ["System"]
|
tags: ["System"],
|
||||||
|
lane: .feed
|
||||||
),
|
),
|
||||||
MailThread(
|
MailThread(
|
||||||
routeID: "investor-update",
|
routeID: "investor-update",
|
||||||
@@ -118,7 +125,13 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: true,
|
isUnread: true,
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
tags: ["External"]
|
tags: ["External"],
|
||||||
|
lane: .paper,
|
||||||
|
summary: [
|
||||||
|
"Mina wants a concise product update before Friday.",
|
||||||
|
"She's specifically asking for screenshots of the new mail experience.",
|
||||||
|
"Interested in how we differentiate from commodity inboxes."
|
||||||
|
]
|
||||||
),
|
),
|
||||||
MailThread(
|
MailThread(
|
||||||
routeID: "search-ranking-polish",
|
routeID: "search-ranking-polish",
|
||||||
@@ -143,7 +156,8 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: false,
|
isUnread: false,
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
tags: ["Search"]
|
tags: ["Search"],
|
||||||
|
lane: .people
|
||||||
),
|
),
|
||||||
MailThread(
|
MailThread(
|
||||||
routeID: "welcome-to-socialio",
|
routeID: "welcome-to-socialio",
|
||||||
@@ -162,7 +176,8 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: false,
|
isUnread: false,
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
tags: ["Draft"]
|
tags: ["Draft"],
|
||||||
|
lane: .people
|
||||||
),
|
),
|
||||||
MailThread(
|
MailThread(
|
||||||
routeID: "roadmap-notes",
|
routeID: "roadmap-notes",
|
||||||
@@ -180,7 +195,13 @@ struct MockMailService: MailServicing {
|
|||||||
],
|
],
|
||||||
isUnread: false,
|
isUnread: false,
|
||||||
isStarred: true,
|
isStarred: true,
|
||||||
tags: ["Product"]
|
tags: ["Product"],
|
||||||
|
lane: .paper,
|
||||||
|
summary: [
|
||||||
|
"Three roadmap themes: faster triage, identity-rich threads, calmer notifications.",
|
||||||
|
"Raw notes were cleaned up and archived by Nora.",
|
||||||
|
"Owner split still needs to be reconciled with eng capacity."
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user