Files
swiftapp/swift/Sources/App/AppViewModel.swift

291 lines
8.6 KiB
Swift
Raw Normal View History

2026-04-17 20:46:27 +02:00
import Foundation
import Observation
@MainActor
@Observable
final class AppViewModel {
var selectedMailbox: Mailbox = .inbox
Align Mail UI with social.io design handoff Rewrite the Mail feature to match the Apple-native look from the handoff spec: lane-split inbox, AI summary card, clean ThreadRow, Cc/From + format toolbar in Compose. Drop the gradient hero surfaces and blurred canvas backgrounds the spec calls out as anti-patterns, and introduce a token-backed design layer so the lane palette and SIO tint live in the asset catalog. - Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople (light + dark variants). - Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum, LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles, and a glass-chrome helper with iOS 26 / material fallback. - Extend MailThread with lane + summary; custom Codable keeps old payloads decodable. Seed mock threads with sensible lanes and hand-write summaries on launch-copy, investor-update, roadmap-notes. - Add lane filtering to AppViewModel (selectedLane, selectLane, laneUnreadCount, laneThreadCount). - Rewrite MailRootView end to end: sidebar with Inbox/lane rows, lane filter strip, Apple-native ThreadRow (avatar, unread dot, lane chip, summary chip), ThreadReadingView with AI summary + floating reply pill, ComposeView with To/Cc/From/Subject and a format toolbar. - Wire Assets.xcassets + SIODesign.swift into project.pbxproj. Accessibility identifiers preserved byte-identical; new ones (mailbox.lane.*, lane.chip.*) added only where new. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:10 +02:00
var selectedLane: Lane?
2026-04-17 20:46:27 +02:00
var selectedThreadID: MailThread.ID?
var focusedMessageRouteID: String?
var searchText = ""
var showUnreadOnly = false
var isComposing = false
var composeDraft = ComposeDraft()
var threads: [MailThread] = []
var isLoading = false
var isSending = false
2026-04-17 20:46:27 +02:00
var errorMessage: String?
var mailboxNavigationToken = UUID()
var threadNavigationToken = UUID()
private let service: MailServicing
private let controlService: AppControlServicing
private var pendingNavigationCommand: AppNavigationCommand?
private var isListeningForBackendCommands = false
init(
service: MailServicing = MockMailService(),
controlService: AppControlServicing = MockBackendControlService()
) {
self.service = service
self.controlService = controlService
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
apply(command: command)
}
}
var selectedThread: MailThread? {
get { threads.first(where: { $0.id == selectedThreadID }) }
set { selectedThreadID = newValue?.id }
}
var filteredThreads: [MailThread] {
threads
.filter { thread in
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
}
Align Mail UI with social.io design handoff Rewrite the Mail feature to match the Apple-native look from the handoff spec: lane-split inbox, AI summary card, clean ThreadRow, Cc/From + format toolbar in Compose. Drop the gradient hero surfaces and blurred canvas backgrounds the spec calls out as anti-patterns, and introduce a token-backed design layer so the lane palette and SIO tint live in the asset catalog. - Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople (light + dark variants). - Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum, LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles, and a glass-chrome helper with iOS 26 / material fallback. - Extend MailThread with lane + summary; custom Codable keeps old payloads decodable. Seed mock threads with sensible lanes and hand-write summaries on launch-copy, investor-update, roadmap-notes. - Add lane filtering to AppViewModel (selectedLane, selectLane, laneUnreadCount, laneThreadCount). - Rewrite MailRootView end to end: sidebar with Inbox/lane rows, lane filter strip, Apple-native ThreadRow (avatar, unread dot, lane chip, summary chip), ThreadReadingView with AI summary + floating reply pill, ComposeView with To/Cc/From/Subject and a format toolbar. - Wire Assets.xcassets + SIODesign.swift into project.pbxproj. Accessibility identifiers preserved byte-identical; new ones (mailbox.lane.*, lane.chip.*) added only where new. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:10 +02:00
.filter { thread in
guard let lane = selectedLane else { return true }
return thread.lane == lane
}
2026-04-17 20:46:27 +02:00
.filter { thread in
!showUnreadOnly || thread.isUnread
}
.filter(matchesSearch)
.sorted { $0.lastUpdated > $1.lastUpdated }
}
Align Mail UI with social.io design handoff Rewrite the Mail feature to match the Apple-native look from the handoff spec: lane-split inbox, AI summary card, clean ThreadRow, Cc/From + format toolbar in Compose. Drop the gradient hero surfaces and blurred canvas backgrounds the spec calls out as anti-patterns, and introduce a token-backed design layer so the lane palette and SIO tint live in the asset catalog. - Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople (light + dark variants). - Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum, LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles, and a glass-chrome helper with iOS 26 / material fallback. - Extend MailThread with lane + summary; custom Codable keeps old payloads decodable. Seed mock threads with sensible lanes and hand-write summaries on launch-copy, investor-update, roadmap-notes. - Add lane filtering to AppViewModel (selectedLane, selectLane, laneUnreadCount, laneThreadCount). - Rewrite MailRootView end to end: sidebar with Inbox/lane rows, lane filter strip, Apple-native ThreadRow (avatar, unread dot, lane chip, summary chip), ThreadReadingView with AI summary + floating reply pill, ComposeView with To/Cc/From/Subject and a format toolbar. - Wire Assets.xcassets + SIODesign.swift into project.pbxproj. Accessibility identifiers preserved byte-identical; new ones (mailbox.lane.*, lane.chip.*) added only where new. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:10 +02:00
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()
}
2026-04-17 20:46:27 +02:00
var totalUnreadCount: Int {
threads.filter(\.isUnread).count
}
func threadCount(in mailbox: Mailbox) -> Int {
threads.filter { thread in
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
}
.count
}
func unreadCount(in mailbox: Mailbox) -> Int {
threads.filter { thread in
let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
return matchesMailbox && thread.isUnread
}
.count
}
func load() async {
guard threads.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
threads = try await service.loadThreads()
if let command = pendingNavigationCommand {
pendingNavigationCommand = nil
apply(command: command)
} else {
reconcileSelectionForCurrentFilters()
}
} catch {
errorMessage = "Unable to load mail."
}
}
func toggleStar(for thread: MailThread) {
toggleStar(forThreadID: thread.id)
}
func toggleStar(forThreadID threadID: MailThread.ID) {
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
var updatedThread = threads[index]
updatedThread.isStarred.toggle()
threads[index] = updatedThread
reconcileSelectionForCurrentFilters()
}
func toggleRead(for thread: MailThread) {
toggleRead(forThreadID: thread.id)
}
func toggleRead(forThreadID threadID: MailThread.ID) {
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
var updatedThread = threads[index]
updatedThread.isUnread.toggle()
threads[index] = updatedThread
reconcileSelectionForCurrentFilters()
}
func selectMailbox(_ mailbox: Mailbox) {
selectedMailbox = mailbox
clearThreadSelection()
mailboxNavigationToken = UUID()
}
func setUnreadOnly(_ unreadOnly: Bool) {
showUnreadOnly = unreadOnly
clearThreadSelection()
mailboxNavigationToken = UUID()
}
func setSearchText(_ text: String) {
searchText = text
reconcileSelectionForCurrentFilters()
}
func startCompose() {
composeDraft = ComposeDraft()
focusedMessageRouteID = nil
isComposing = true
}
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
guard let thread = thread(withID: threadID) else { return }
selectedThreadID = threadID
if let focusedMessageRouteID,
thread.messages.contains(where: { $0.routeID == focusedMessageRouteID }) {
self.focusedMessageRouteID = focusedMessageRouteID
} else {
self.focusedMessageRouteID = nil
}
threadNavigationToken = UUID()
}
func dismissThreadSelection() {
clearThreadSelection()
}
func beginBackendControl() async {
guard !isListeningForBackendCommands else { return }
isListeningForBackendCommands = true
defer { isListeningForBackendCommands = false }
2026-04-17 20:46:27 +02:00
for await command in controlService.commands() {
apply(command: command)
}
}
func apply(url: URL) {
guard let command = AppNavigationCommand.from(url: url) else {
errorMessage = "Unable to open requested destination."
return
}
apply(command: command)
}
func apply(command: AppNavigationCommand) {
switch command {
case let .mailbox(mailbox, search, unreadOnly):
isComposing = false
searchText = search ?? ""
showUnreadOnly = unreadOnly ?? false
selectMailbox(mailbox)
case let .thread(threadRouteID, mailbox, messageRouteID, search, unreadOnly):
guard !threads.isEmpty else {
pendingNavigationCommand = command
return
}
searchText = search ?? ""
showUnreadOnly = unreadOnly ?? false
isComposing = false
guard let thread = threads.first(where: { $0.routeID == threadRouteID }) else {
errorMessage = "Unable to open requested conversation."
return
}
selectedMailbox = mailbox ?? thread.mailbox
openThread(withID: thread.id, focusedMessageRouteID: messageRouteID)
case let .compose(draft):
focusedMessageRouteID = nil
composeDraft = draft
isComposing = true
}
}
func sendCurrentDraft() async -> Bool {
guard !isSending else { return false }
2026-04-17 20:46:27 +02:00
let draft = composeDraft
isSending = true
defer { isSending = false }
2026-04-17 20:46:27 +02:00
do {
let sentThread = try await service.send(draft: draft)
threads.insert(sentThread, at: 0)
selectedMailbox = .sent
openThread(withID: sentThread.id)
isComposing = false
return true
2026-04-17 20:46:27 +02:00
} catch {
errorMessage = "Unable to send message."
return false
2026-04-17 20:46:27 +02:00
}
}
private func matchesSearch(thread: MailThread) -> Bool {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return true }
let haystack = [
thread.subject,
thread.previewText,
thread.participants.map(\.name).joined(separator: " "),
thread.tags.joined(separator: " ")
]
.joined(separator: " ")
.localizedLowercase
return haystack.contains(query.localizedLowercase)
}
func thread(withID threadID: MailThread.ID) -> MailThread? {
threads.first(where: { $0.id == threadID })
}
private func clearThreadSelection() {
selectedThreadID = nil
focusedMessageRouteID = nil
}
private func reconcileSelectionForCurrentFilters() {
if let selectedThreadID,
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
return
}
clearThreadSelection()
}
}