diff --git a/swift/Assets.xcassets/Contents.json b/swift/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/swift/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/swift/Assets.xcassets/LaneFeed.colorset/Contents.json b/swift/Assets.xcassets/LaneFeed.colorset/Contents.json new file mode 100644 index 0000000..f5d2687 --- /dev/null +++ b/swift/Assets.xcassets/LaneFeed.colorset/Contents.json @@ -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 + } +} diff --git a/swift/Assets.xcassets/LanePaper.colorset/Contents.json b/swift/Assets.xcassets/LanePaper.colorset/Contents.json new file mode 100644 index 0000000..2efea97 --- /dev/null +++ b/swift/Assets.xcassets/LanePaper.colorset/Contents.json @@ -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 + } +} diff --git a/swift/Assets.xcassets/LanePeople.colorset/Contents.json b/swift/Assets.xcassets/LanePeople.colorset/Contents.json new file mode 100644 index 0000000..cda3283 --- /dev/null +++ b/swift/Assets.xcassets/LanePeople.colorset/Contents.json @@ -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 + } +} diff --git a/swift/Assets.xcassets/SIOTint.colorset/Contents.json b/swift/Assets.xcassets/SIOTint.colorset/Contents.json new file mode 100644 index 0000000..f5d2687 --- /dev/null +++ b/swift/Assets.xcassets/SIOTint.colorset/Contents.json @@ -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 + } +} diff --git a/swift/SocialIO.xcodeproj/project.pbxproj b/swift/SocialIO.xcodeproj/project.pbxproj index 9ca7727..0fdb809 100644 --- a/swift/SocialIO.xcodeproj/project.pbxproj +++ b/swift/SocialIO.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; }; A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.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 */ /* Begin PBXContainerItemProxy section */ @@ -40,6 +42,8 @@ A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = ""; }; A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = ""; }; 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 = ""; }; + A20000000000000000000021 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -73,6 +77,7 @@ children = ( A40000000000000000000003 /* Sources */, A4000000000000000000000B /* Tests */, + A20000000000000000000021 /* Assets.xcassets */, ); name = SocialIO; sourceTree = ""; @@ -103,10 +108,19 @@ children = ( A40000000000000000000006 /* Models */, A40000000000000000000007 /* Services */, + A40000000000000000000020 /* Design */, ); path = Core; sourceTree = ""; }; + A40000000000000000000020 /* Design */ = { + isa = PBXGroup; + children = ( + A20000000000000000000020 /* SIODesign.swift */, + ); + path = Design; + sourceTree = ""; + }; A40000000000000000000006 /* Models */ = { isa = PBXGroup; children = ( @@ -238,6 +252,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A10000000000000000000011 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -262,6 +277,7 @@ A10000000000000000000001 /* SocialIOApp.swift in Sources */, A10000000000000000000006 /* AppNavigationCommand.swift in Sources */, A10000000000000000000007 /* AppControlService.swift in Sources */, + A10000000000000000000010 /* SIODesign.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/swift/Sources/App/AppViewModel.swift b/swift/Sources/App/AppViewModel.swift index 0fb8e3c..a8f27c3 100644 --- a/swift/Sources/App/AppViewModel.swift +++ b/swift/Sources/App/AppViewModel.swift @@ -5,6 +5,7 @@ import Observation @Observable final class AppViewModel { var selectedMailbox: Mailbox = .inbox + var selectedLane: Lane? var selectedThreadID: MailThread.ID? var focusedMessageRouteID: String? var searchText = "" @@ -44,6 +45,10 @@ final class AppViewModel { .filter { thread in selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox } + .filter { thread in + guard let lane = selectedLane else { return true } + return thread.lane == lane + } .filter { thread in !showUnreadOnly || thread.isUnread } @@ -51,6 +56,27 @@ final class AppViewModel { .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 { threads.filter(\.isUnread).count } diff --git a/swift/Sources/App/SocialIOApp.swift b/swift/Sources/App/SocialIOApp.swift index eac9789..71a4bc7 100644 --- a/swift/Sources/App/SocialIOApp.swift +++ b/swift/Sources/App/SocialIOApp.swift @@ -7,7 +7,7 @@ struct SocialIOApp: App { var body: some Scene { WindowGroup { MailRootView(model: model) - .tint(MailTheme.accent) + .tint(SIO.tint) .onOpenURL { url in model.apply(url: url) } diff --git a/swift/Sources/Core/Design/SIODesign.swift b/swift/Sources/Core/Design/SIODesign.swift new file mode 100644 index 0000000..ff22b67 --- /dev/null +++ b/swift/Sources/Core/Design/SIODesign.swift @@ -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(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] + } +} diff --git a/swift/Sources/Core/Models/MailModels.swift b/swift/Sources/Core/Models/MailModels.swift index 8d6f821..8ab2a3e 100644 --- a/swift/Sources/Core/Models/MailModels.swift +++ b/swift/Sources/Core/Models/MailModels.swift @@ -80,6 +80,8 @@ struct MailThread: Identifiable, Hashable, Codable { var isUnread: Bool var isStarred: Bool var tags: [String] + var lane: Lane + var summary: [String]? init( id: UUID = UUID(), @@ -90,7 +92,9 @@ struct MailThread: Identifiable, Hashable, Codable { messages: [MailMessage], isUnread: Bool, isStarred: Bool, - tags: [String] = [] + tags: [String] = [], + lane: Lane = .people, + summary: [String]? = nil ) { self.id = id self.routeID = routeID @@ -101,6 +105,29 @@ struct MailThread: Identifiable, Hashable, Codable { self.isUnread = isUnread self.isStarred = isStarred 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? { diff --git a/swift/Sources/Core/Services/MockMailService.swift b/swift/Sources/Core/Services/MockMailService.swift index 8219786..b95424b 100644 --- a/swift/Sources/Core/Services/MockMailService.swift +++ b/swift/Sources/Core/Services/MockMailService.swift @@ -82,7 +82,13 @@ struct MockMailService: MailServicing { ], isUnread: 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( routeID: "daily-sync-status", @@ -100,7 +106,8 @@ struct MockMailService: MailServicing { ], isUnread: false, isStarred: false, - tags: ["System"] + tags: ["System"], + lane: .feed ), MailThread( routeID: "investor-update", @@ -118,7 +125,13 @@ struct MockMailService: MailServicing { ], isUnread: true, 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( routeID: "search-ranking-polish", @@ -143,7 +156,8 @@ struct MockMailService: MailServicing { ], isUnread: false, isStarred: false, - tags: ["Search"] + tags: ["Search"], + lane: .people ), MailThread( routeID: "welcome-to-socialio", @@ -162,7 +176,8 @@ struct MockMailService: MailServicing { ], isUnread: false, isStarred: false, - tags: ["Draft"] + tags: ["Draft"], + lane: .people ), MailThread( routeID: "roadmap-notes", @@ -180,7 +195,13 @@ struct MockMailService: MailServicing { ], isUnread: false, 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." + ] ) ] } diff --git a/swift/Sources/Features/Mail/MailRootView.swift b/swift/Sources/Features/Mail/MailRootView.swift index 4e1c934..2c5c4df 100644 --- a/swift/Sources/Features/Mail/MailRootView.swift +++ b/swift/Sources/Features/Mail/MailRootView.swift @@ -5,14 +5,6 @@ import AppKit 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 @@ -23,9 +15,10 @@ struct MailRootView: View { } content: { ThreadListView(model: model) } detail: { - ThreadDetailView(model: model) + ThreadReadingView(model: model) } .navigationSplitViewStyle(.balanced) + .tint(SIO.tint) .searchable(text: searchTextBinding, prompt: "Search mail") .toolbar { if showsToolbarCompose { @@ -68,9 +61,7 @@ struct MailRootView: View { model.dismissThreadSelection() } .alert("Something went wrong", isPresented: errorPresented) { - Button("OK") { - model.errorMessage = nil - } + Button("OK") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") } @@ -80,9 +71,7 @@ struct MailRootView: View { Binding( get: { model.errorMessage != nil }, set: { isPresented in - if !isPresented { - model.errorMessage = nil - } + if !isPresented { model.errorMessage = nil } } ) } @@ -116,32 +105,61 @@ struct MailRootView: View { } } +// MARK: - Sidebar + 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)) + 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.allCases) { mailbox in - Button { + 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) - } label: { - mailboxRow(for: mailbox) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + model.selectLane(nil) } - .buttonStyle(.plain) - .listRowBackground( - mailbox == model.selectedMailbox - ? MailTheme.accent.opacity(0.10) - : Color.clear - ) .accessibilityIdentifier("sidebar.mailbox.\(mailbox.id)") } } @@ -149,18 +167,17 @@ private struct MailSidebarView: View { Section("Filters") { Toggle(isOn: unreadOnlyBinding) { HStack { - Label("Unread Only", systemImage: "circle.badge") + Label("Unread only", systemImage: "circle.badge") Spacer() Text(model.totalUnreadCount, format: .number) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } + .accessibilityIdentifier("filter.unread") } } .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .background(MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.mint)) .navigationTitle("social.io") } @@ -171,264 +188,567 @@ private struct MailSidebarView: View { ) } - 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) + @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) + } - 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) - ) + @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 SummaryChip: View { - let title: String - let value: Int - let tint: Color? - +private struct SidebarAccountHeader: View { var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - Text(value, format: .number) - .font(.headline.weight(.semibold)) + 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) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .socialGlass(in: RoundedRectangle(cornerRadius: 18, style: .continuous), tint: tint) } } +// MARK: - Thread list + private struct ThreadListView: View { @Bindable var model: AppViewModel var body: some View { - ZStack { - MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.sunrise) - .ignoresSafeArea() + VStack(spacing: 0) { + if model.selectedMailbox == .inbox { + LaneFilterStrip(model: model) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + } - 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.") + 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 ) - .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()) + .contextMenu { + Button(thread.isUnread ? "Mark Read" : "Mark Unread") { + model.toggleRead(for: thread) } - .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) - } + Button(thread.isStarred ? "Remove Star" : "Star Thread") { + model.toggleStar(for: thread) } } - .listStyle(.plain) - .scrollContentBackground(.hidden) } } + .listStyle(.plain) } } .safeAreaInset(edge: .bottom) { FloatingComposeButton(model: model) } - .navigationTitle(model.selectedMailbox.title) + .navigationTitle(navigationTitleText) .mailInlineNavigationTitle() - .mailNavigationChrome() + } + + private var navigationTitleText: String { + if model.selectedMailbox == .inbox, let lane = model.selectedLane { + return lane.label + } + return model.selectedMailbox.title + } + + private var selectionBinding: Binding { + Binding( + get: { model.selectedThreadID }, + set: { newValue in + if let id = newValue { + model.openThread(withID: id) + } else { + model.dismissThreadSelection() + } + } + ) } } -private struct MailboxHeroCard: View { +private struct LaneFilterStrip: 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) - ) - } + HStack(spacing: 8) { + laneChip( + label: "All", + color: .secondary, + count: model.threadCount(in: .inbox), + isSelected: model.selectedLane == nil, + id: "all" + ) { + model.selectLane(nil) } - if let latestThread = model.filteredThreads.first { - HStack(spacing: 8) { - Image(systemName: "clock") - Text("Latest activity \(latestThread.lastUpdated.formatted(date: .abbreviated, time: .shortened))") + 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) } - .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." + @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()) } - } - - 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 - ) + .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 { - 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") + 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) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 12) - .background(Color.clear) + .buttonStyle(.plain) + .accessibilityIdentifier("compose.floating") + .padding(.trailing, 18) + .padding(.bottom, 18) } } } @@ -442,519 +762,76 @@ private struct FloatingComposeButton: View { } } -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) - } -} +// 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 usesCompactComposeLayout { - composeScene + if usesCompactLayout { + composeBody } else { - composeScene - .frame(minWidth: 560, minHeight: 520) + composeBody.frame(minWidth: 560, minHeight: 520) } } .accessibilityIdentifier("compose.view") } - private var composeScene: some View { + private var composeBody: some View { NavigationStack { - ZStack { - MailCanvasBackground(primary: MailTheme.accent, secondary: MailTheme.sunrise) - .ignoresSafeArea() - + VStack(spacing: 0) { 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) - } + 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() - 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") { + 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: 240) + .frame(minHeight: 220) + .padding(.horizontal, 14) + .padding(.vertical, 8) .disabled(model.isSending) .accessibilityIdentifier("compose.body") } - - Spacer(minLength: 0) } - .padding(usesCompactComposeLayout ? 20 : 24) - .frame(maxWidth: 720, alignment: .topLeading) - .frame(maxWidth: .infinity, alignment: .top) } + .scrollDismissesKeyboard(.interactively) + + FormatToolbar() } - .navigationTitle("Compose") - .composeNavigationTitleDisplayMode(isCompact: usesCompactComposeLayout) + .navigationTitle("New Message") + .composeTitleDisplay(compact: usesCompactLayout) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(model.isSending) - .accessibilityIdentifier("compose.cancel") + Button("Cancel") { dismiss() } + .disabled(model.isSending) + .accessibilityIdentifier("compose.cancel") } - ToolbarItem(placement: .confirmationAction) { Button(model.isSending ? "Sending…" : "Send") { - Task { - _ = await model.sendCurrentDraft() - } + Task { _ = await model.sendCurrentDraft() } } + .buttonStyle(.borderedProminent) + .tint(SIO.tint) .disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty) .accessibilityIdentifier("compose.send") } @@ -963,25 +840,55 @@ private struct ComposeView: View { } @ViewBuilder - private var toField: some View { + private func composeRow(label: String, placeholder: String, text: Binding, 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) -> some View { #if os(iOS) - TextField("name@example.com", text: $model.composeDraft.to) + TextField(placeholder, text: text) .textFieldStyle(.plain) .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) - .disabled(model.isSending) - .accessibilityIdentifier("compose.to") #else - TextField("name@example.com", text: $model.composeDraft.to) + TextField(placeholder, text: text) .textFieldStyle(.plain) .textContentType(.emailAddress) - .disabled(model.isSending) - .accessibilityIdentifier("compose.to") #endif } - private var usesCompactComposeLayout: Bool { + 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 @@ -990,163 +897,68 @@ private struct ComposeView: View { } } -private extension View { - @ViewBuilder - func composeNavigationTitleDisplayMode(isCompact: Bool) -> some View { - #if os(iOS) - navigationBarTitleDisplayMode(isCompact ? .inline : .automatic) - #else - self - #endif +private struct OptionalAccessibility: ViewModifier { + let id: String? + func body(content: Content) -> some View { + if let id { content.accessibilityIdentifier(id) } else { content } } } -private struct ComposeFieldCard: View { - let title: String - let content: Content - - init(title: String, @ViewBuilder content: () -> Content) { - self.title = title - self.content = content() - } - +private struct FormatToolbar: View { var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - content + 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) } - .padding(18) - .mailPanelBackground(in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(.bar) } } -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: 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 - } - } -} +// MARK: - Platform helpers private extension View { - @ViewBuilder - func socialGlass( - 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( - 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) + 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 var platformBackgroundColor: Color { - #if os(macOS) - Color(nsColor: .windowBackgroundColor) - #else - Color(uiColor: .systemBackground) - #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 + } }