diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index 7c839fa..3f5e485 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -2,12 +2,14 @@ import SwiftUI import Models import MailStore import SyncEngine +import TaskStore struct ContentView: View { @State private var viewModel = MailViewModel() @State private var accountSetup = AccountSetupViewModel() @State private var showingAccountSetup = false @State private var composeMode: ComposeMode? + @State private var showTaskEditor = false var body: some View { Group { @@ -39,6 +41,15 @@ struct ContentView: View { .keyboardShortcut("n", modifiers: .command) .help("New Message (⌘N)") } + ToolbarItem(placement: .automatic) { + Button { + showTaskEditor = true + } label: { + Label("New Task", systemImage: "plus.circle") + } + .keyboardShortcut("t", modifiers: .command) + .help("New Task (⌘T)") + } } } content: { ThreadListView(viewModel: viewModel) @@ -46,6 +57,7 @@ struct ContentView: View { ThreadDetailView( thread: viewModel.selectedThread, messages: viewModel.messages, + linkedTasks: viewModel.selectedThread.map { viewModel.tasksLinkedToThread(threadId: $0.id) } ?? [], onComposeRequest: { mode in requestCompose(mode) } @@ -68,6 +80,17 @@ struct ContentView: View { ) } } + .sheet(isPresented: $showTaskEditor) { + if let config = viewModel.accountConfig, + let taskStore = viewModel.taskStore { + TaskEditView( + viewModel: TaskEditViewModel( + accountId: config.id, + taskStore: taskStore + ) + ) + } + } .task { await viewModel.syncNow() viewModel.startPeriodicSync() diff --git a/Apps/MagnumOpus/ViewModels/MailViewModel.swift b/Apps/MagnumOpus/ViewModels/MailViewModel.swift index 3260c57..fbeb8da 100644 --- a/Apps/MagnumOpus/ViewModels/MailViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/MailViewModel.swift @@ -5,6 +5,40 @@ import MailStore import SyncEngine import IMAPClient import SMTPClient +import TaskStore + +enum Perspective: String, CaseIterable, Identifiable { + case inbox + case today + case upcoming + case projects + case someday + case archive + + var id: String { rawValue } + + var label: String { + switch self { + case .inbox: "Inbox" + case .today: "Today" + case .upcoming: "Upcoming" + case .projects: "Projects" + case .someday: "Someday" + case .archive: "Archive" + } + } + + var systemImage: String { + switch self { + case .inbox: "tray" + case .today: "sun.max" + case .upcoming: "calendar" + case .projects: "folder" + case .someday: "moon.zzz" + case .archive: "archivebox" + } + } +} @Observable @MainActor @@ -13,6 +47,7 @@ final class MailViewModel { private var coordinator: SyncCoordinator? var actionQueue: ActionQueue? var accountConfig: AccountConfig? + var taskStore: TaskStore? var threads: [ThreadSummary] = [] var selectedThread: ThreadSummary? @@ -22,6 +57,10 @@ final class MailViewModel { var syncState: SyncState = .idle var errorMessage: String? + var selectedPerspective: Perspective = .inbox + var items: [ItemSummary] = [] + var perspectiveCounts: [Perspective: Int] = [:] + private var imapClientProvider: (() -> IMAPClient)? private var threadObservation: Task? private var messageObservation: Task? @@ -88,6 +127,12 @@ final class MailViewModel { store: mailStore, actionQueue: queue ) + + // Set up TaskStore sharing the same database + let taskDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("MagnumOpus", isDirectory: true) + .appendingPathComponent("tasks-\(config.id)", isDirectory: true) + taskStore = TaskStore(taskDirectory: taskDir, dbWriter: dbPool) } func loadMailboxes(accountId: String) async { @@ -162,6 +207,139 @@ final class MailViewModel { messageObservation?.cancel() } + // MARK: - Perspective Loading + + func loadPerspective(_ perspective: Perspective) { + selectedPerspective = perspective + guard let store, let accountConfig else { return } + let accountId = accountConfig.id + do { + var result: [ItemSummary] = [] + let todayString = VTODOParser.formatDateOnly(Date()) + + switch perspective { + case .inbox: + // Emails in INBOX with no deferral + if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) { + let msgs = try store.messages(mailboxId: inboxMailbox.id) + for msg in msgs { + // Skip deferred emails + if let _ = try? store.deferralForMessage(messageId: msg.id) { + continue + } + result.append(.email(MailStore.toMessageSummary(msg))) + } + } + // Tasks: NEEDS-ACTION, no deferUntil, not someday + let tasks = try store.inboxTasks(accountId: accountId) + for task in tasks { + result.append(.task(taskRecordToSummary(task))) + } + + case .today: + // Tasks due today or overdue + let tasks = try store.todayTasks(accountId: accountId, todayDate: todayString) + for task in tasks { + result.append(.task(taskRecordToSummary(task))) + } + + case .upcoming: + // Tasks with due date after today + let tasks = try store.upcomingTasks(accountId: accountId, afterDate: todayString) + for task in tasks { + result.append(.task(taskRecordToSummary(task))) + } + + case .projects: + // Items with project labels + let projectLabels = try store.projectLabels(accountId: accountId) + for label in projectLabels { + let itemLabels = try store.itemsWithLabel(labelId: label.id) + for il in itemLabels { + if il.itemType == "task" { + if let task = try store.task(id: il.itemId) { + result.append(.task(taskRecordToSummary(task))) + } + } + } + } + + case .someday: + // Emails with deferral where deferUntil IS NULL (someday) + let somedayDeferrals = try store.somedayDeferrals() + for deferral in somedayDeferrals { + if let msg = try store.message(id: deferral.messageId) { + result.append(.email(MailStore.toMessageSummary(msg))) + } + } + // Tasks with isSomeday=true + let tasks = try store.somedayTasks(accountId: accountId) + for task in tasks { + result.append(.task(taskRecordToSummary(task))) + } + + case .archive: + // Tasks with COMPLETED/CANCELLED status + let tasks = try store.archivedTasks(accountId: accountId) + for task in tasks { + result.append(.task(taskRecordToSummary(task))) + } + } + + items = result.sorted { $0.date > $1.date } + } catch { + errorMessage = error.localizedDescription + } + } + + func loadAllPerspectiveCounts() { + guard let store, let accountConfig else { return } + let accountId = accountConfig.id + let todayString = VTODOParser.formatDateOnly(Date()) + + var counts: [Perspective: Int] = [:] + + do { + // Inbox count + var inboxCount = 0 + if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) { + let msgs = try store.messages(mailboxId: inboxMailbox.id) + for msg in msgs { + if (try? store.deferralForMessage(messageId: msg.id)) == nil { + inboxCount += 1 + } + } + } + inboxCount += try store.inboxTasks(accountId: accountId).count + counts[.inbox] = inboxCount + + // Today count + counts[.today] = try store.todayTasks(accountId: accountId, todayDate: todayString).count + + // Upcoming count + counts[.upcoming] = try store.upcomingTasks(accountId: accountId, afterDate: todayString).count + + // Projects count + var projectCount = 0 + let projectLabels = try store.projectLabels(accountId: accountId) + for label in projectLabels { + projectCount += try store.itemsWithLabel(labelId: label.id).count + } + counts[.projects] = projectCount + + // Someday count + let somedayCount = try store.somedayDeferrals().count + store.somedayTasks(accountId: accountId).count + counts[.someday] = somedayCount + + // Archive count + counts[.archive] = try store.archivedTasks(accountId: accountId).count + } catch { + // Counts are best-effort + } + + perspectiveCounts = counts + } + // MARK: - Triage Actions func archiveSelectedThread() { @@ -287,6 +465,148 @@ final class MailViewModel { } } + // MARK: - GTD Triage Actions (unified items) + + func deferSelectedItem(until date: Date?) { + guard let store, let accountConfig else { return } + // Find the currently selected item from the items list + // We use the selected thread for emails, or need context for tasks + // For now, work with the first selected item concept + } + + func deferItem(_ item: ItemSummary, until date: Date?) { + guard let store, let accountConfig else { return } + do { + switch item { + case .email(let msg): + // Create a DeferralRecord + let deferUntilStr = date.map { VTODOParser.formatDateOnly($0) } + let deferral = DeferralRecord( + id: UUID().uuidString, + messageId: msg.id, + deferUntil: deferUntilStr, + originalMailbox: nil, + createdAt: ISO8601DateFormatter().string(from: Date()) + ) + try store.insertDeferral(deferral) + + case .task(let task): + let isSomeday = date == nil + try taskStore?.updateDeferral(id: task.id, deferUntil: date, isSomeday: isSomeday) + } + loadPerspective(selectedPerspective) + loadAllPerspectiveCounts() + } catch { + errorMessage = error.localizedDescription + } + } + + func fileItem(_ item: ItemSummary, label: LabelInfo) { + guard let store, let accountConfig else { return } + do { + switch item { + case .email(let msg): + try store.attachLabel(labelId: label.id, itemType: "email", itemId: msg.id) + case .task(let task): + try store.attachLabel(labelId: label.id, itemType: "task", itemId: task.id) + // Also update VTODO CATEGORIES + var categories = task.categories + if !categories.contains(label.name) { + categories.append(label.name) + } + try taskStore?.writeTask( + id: task.id, + accountId: task.accountId, + summary: task.summary, + description: task.description, + status: task.status.rawValue, + priority: task.priority, + dueDate: task.dueDate, + deferUntil: task.deferUntil, + linkedMessageId: task.linkedMessageId, + categories: categories, + isSomeday: task.isSomeday + ) + } + loadPerspective(selectedPerspective) + loadAllPerspectiveCounts() + } catch { + errorMessage = error.localizedDescription + } + } + + func discardItem(_ item: ItemSummary) { + guard let store, let actionQueue, let accountConfig else { return } + do { + switch item { + case .email(let msg): + // Archive the email + guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else { + errorMessage = "No archive mailbox found" + return + } + guard let record = try store.message(id: msg.id) else { return } + try store.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id) + let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX" + let action = PendingAction( + accountId: accountConfig.id, + actionType: .move, + payload: .move(uid: record.uid, from: mailboxName, to: archiveMailbox.name) + ) + Task { try await actionQueue.enqueue(action) } + + case .task(let task): + try taskStore?.updateStatus(id: task.id, status: TaskStatus.cancelled.rawValue) + } + loadPerspective(selectedPerspective) + loadAllPerspectiveCounts() + } catch { + errorMessage = error.localizedDescription + } + } + + func completeItem(_ item: ItemSummary) { + guard let store, let actionQueue, let accountConfig else { return } + do { + switch item { + case .email(let msg): + // Archive the email + guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else { + errorMessage = "No archive mailbox found" + return + } + guard let record = try store.message(id: msg.id) else { return } + try store.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id) + let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX" + let action = PendingAction( + accountId: accountConfig.id, + actionType: .move, + payload: .move(uid: record.uid, from: mailboxName, to: archiveMailbox.name) + ) + Task { try await actionQueue.enqueue(action) } + + case .task(let task): + try taskStore?.updateStatus(id: task.id, status: TaskStatus.completed.rawValue) + } + loadPerspective(selectedPerspective) + loadAllPerspectiveCounts() + } catch { + errorMessage = error.localizedDescription + } + } + + // MARK: - Linked Tasks + + func tasksLinkedToThread(threadId: String) -> [TaskSummary] { + guard let store else { return [] } + do { + let records = try store.tasksLinkedToThread(threadId: threadId) + return records.map { taskRecordToSummary($0) } + } catch { + return [] + } + } + // MARK: - Auto Advance private func autoAdvance() { @@ -346,6 +666,23 @@ final class MailViewModel { mailboxes.first(where: { $0.id == mailboxId })?.name } + private func taskRecordToSummary(_ record: TaskRecord) -> TaskSummary { + TaskSummary( + id: record.id, + accountId: record.accountId, + summary: record.summary, + description: record.description, + status: TaskStatus(rawValue: record.status) ?? .needsAction, + priority: record.priority, + dueDate: record.dueDate.flatMap { VTODOParser.parseDate($0) }, + deferUntil: record.deferUntil.flatMap { VTODOParser.parseDate($0) }, + createdAt: VTODOParser.parseDate(record.createdAt) ?? Date.distantPast, + linkedMessageId: record.linkedMessageId, + categories: [], + isSomeday: record.isSomeday + ) + } + static func databasePath(for accountId: String) -> String { let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("MagnumOpus", isDirectory: true) diff --git a/Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift b/Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift new file mode 100644 index 0000000..c292ff0 --- /dev/null +++ b/Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Foundation +import Models +import TaskStore + +@Observable +@MainActor +final class TaskEditViewModel { + var title: String + var body: String + var dueDate: Date? + var hasDueDate: Bool + var linkedMessageId: String? + var errorMessage: String? + + private let accountId: String + private let taskStore: TaskStore + + init( + accountId: String, + taskStore: TaskStore, + linkedMessageId: String? = nil, + title: String = "" + ) { + self.accountId = accountId + self.taskStore = taskStore + self.linkedMessageId = linkedMessageId + self.title = title + self.body = "" + self.dueDate = nil + self.hasDueDate = false + } + + var canSave: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func save() throws { + let id = UUID().uuidString + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + + try taskStore.writeTask( + id: id, + accountId: accountId, + summary: title.trimmingCharacters(in: .whitespacesAndNewlines), + description: trimmedBody.isEmpty ? nil : trimmedBody, + status: TaskStatus.needsAction.rawValue, + priority: 0, + dueDate: hasDueDate ? dueDate : nil, + deferUntil: nil, + linkedMessageId: linkedMessageId, + categories: nil, + isSomeday: false + ) + } +} diff --git a/Apps/MagnumOpus/Views/DeferPicker.swift b/Apps/MagnumOpus/Views/DeferPicker.swift new file mode 100644 index 0000000..c6c2ab7 --- /dev/null +++ b/Apps/MagnumOpus/Views/DeferPicker.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct DeferPicker: View { + let onDefer: (Date?) -> Void + @Environment(\.dismiss) private var dismiss + @State private var showDatePicker = false + @State private var customDate = Date() + + var body: some View { + VStack(spacing: 0) { + Button { + onDefer(Calendar.current.date(byAdding: .day, value: 1, to: Date())) + dismiss() + } label: { + Label("Tomorrow", systemImage: "sunrise") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + Button { + onDefer(nextMonday()) + dismiss() + } label: { + Label("Next Week", systemImage: "calendar.badge.clock") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + Button { + showDatePicker.toggle() + } label: { + Label("Pick Date", systemImage: "calendar") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if showDatePicker { + DatePicker( + "Defer until", + selection: $customDate, + in: Date()..., + displayedComponents: .date + ) + .datePickerStyle(.graphical) + .padding(.horizontal, 12) + + Button("Confirm") { + onDefer(customDate) + dismiss() + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + Divider() + + Button { + onDefer(nil) + dismiss() + } label: { + Label("Someday", systemImage: "moon.zzz") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(minWidth: 220) + .padding(.vertical, 4) + } + + /// Returns the next Monday at midnight. + private func nextMonday() -> Date { + let calendar = Calendar.current + let today = Date() + let weekday = calendar.component(.weekday, from: today) + // Sunday=1, Monday=2, ..., Saturday=7 + // Days until next Monday + let daysUntilMonday: Int + if weekday == 1 { + // Sunday → 1 day + daysUntilMonday = 1 + } else if weekday == 2 { + // Already Monday → next Monday = 7 days + daysUntilMonday = 7 + } else { + // Tuesday(3)→6, Wednesday(4)→5, Thursday(5)→4, Friday(6)→3, Saturday(7)→2 + daysUntilMonday = 9 - weekday + } + return calendar.date(byAdding: .day, value: daysUntilMonday, to: calendar.startOfDay(for: today))! + } +} diff --git a/Apps/MagnumOpus/Views/LabelPicker.swift b/Apps/MagnumOpus/Views/LabelPicker.swift new file mode 100644 index 0000000..70ace01 --- /dev/null +++ b/Apps/MagnumOpus/Views/LabelPicker.swift @@ -0,0 +1,58 @@ +import SwiftUI +import Models + +struct LabelPicker: View { + let labels: [LabelInfo] + let onSelect: (LabelInfo) -> Void + let onCreate: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + private var filteredLabels: [LabelInfo] { + if searchText.isEmpty { + return labels + } + return labels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private var showCreateOption: Bool { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return !labels.contains { $0.name.lowercased() == trimmed.lowercased() } + } + + var body: some View { + NavigationStack { + List { + if showCreateOption { + Button { + let name = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + onCreate(name) + dismiss() + } label: { + Label("Create project: \(searchText.trimmingCharacters(in: .whitespacesAndNewlines))", systemImage: "plus.circle") + } + } + + ForEach(filteredLabels) { label in + Button { + onSelect(label) + dismiss() + } label: { + Label(label.name, systemImage: label.isProject ? "folder.fill" : "tag") + } + } + } + .searchable(text: $searchText, prompt: "Search or create project") + .navigationTitle("Projects") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .frame(minWidth: 300, minHeight: 300) + } +} diff --git a/Apps/MagnumOpus/Views/SidebarView.swift b/Apps/MagnumOpus/Views/SidebarView.swift index cd748e0..070f8fb 100644 --- a/Apps/MagnumOpus/Views/SidebarView.swift +++ b/Apps/MagnumOpus/Views/SidebarView.swift @@ -5,17 +5,30 @@ struct SidebarView: View { @Bindable var viewModel: MailViewModel var body: some View { - List(selection: Binding( - get: { viewModel.selectedMailbox?.id }, - set: { newId in - viewModel.selectedMailbox = viewModel.mailboxes.first { $0.id == newId } + List { + Section("Perspectives") { + ForEach(Perspective.allCases) { perspective in + Button { + viewModel.loadPerspective(perspective) + } label: { + Label(perspective.label, systemImage: perspective.systemImage) + .badge(viewModel.perspectiveCounts[perspective] ?? 0) + } + .buttonStyle(.plain) + .listItemTint(viewModel.selectedPerspective == perspective ? .accentColor : nil) + .fontWeight(viewModel.selectedPerspective == perspective ? .semibold : .regular) + } } - )) { + Section("Mailboxes") { ForEach(viewModel.mailboxes) { mailbox in - Label(mailbox.name, systemImage: mailbox.systemImage) - .tag(mailbox.id) - .badge(mailbox.unreadCount) + Button { + viewModel.selectedMailbox = mailbox + } label: { + Label(mailbox.name, systemImage: mailbox.systemImage) + .badge(mailbox.unreadCount) + } + .buttonStyle(.plain) } } } @@ -36,5 +49,8 @@ struct SidebarView: View { } } } + .onAppear { + viewModel.loadAllPerspectiveCounts() + } } } diff --git a/Apps/MagnumOpus/Views/TaskEditView.swift b/Apps/MagnumOpus/Views/TaskEditView.swift new file mode 100644 index 0000000..d06b779 --- /dev/null +++ b/Apps/MagnumOpus/Views/TaskEditView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct TaskEditView: View { + @Bindable var viewModel: TaskEditViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Title", text: $viewModel.title) + .textFieldStyle(.plain) + } + + Section { + TextEditor(text: $viewModel.body) + .frame(minHeight: 80) + } header: { + Text("Notes") + } + + Section { + Toggle("Due Date", isOn: $viewModel.hasDueDate) + if viewModel.hasDueDate { + DatePicker( + "Due", + selection: Binding( + get: { viewModel.dueDate ?? Date() }, + set: { viewModel.dueDate = $0 } + ), + displayedComponents: .date + ) + } + } + } + .formStyle(.grouped) + .navigationTitle("New Task") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + saveTask() + } + .keyboardShortcut(.return, modifiers: .command) + .disabled(!viewModel.canSave) + } + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { viewModel.errorMessage = nil } + } message: { + if let msg = viewModel.errorMessage { + Text(msg) + } + } + } + .frame(minWidth: 400, minHeight: 300) + } + + private func saveTask() { + do { + try viewModel.save() + dismiss() + } catch { + viewModel.errorMessage = error.localizedDescription + } + } +} diff --git a/Apps/MagnumOpus/Views/ThreadDetailView.swift b/Apps/MagnumOpus/Views/ThreadDetailView.swift index 67c605c..b0cf9f5 100644 --- a/Apps/MagnumOpus/Views/ThreadDetailView.swift +++ b/Apps/MagnumOpus/Views/ThreadDetailView.swift @@ -4,6 +4,7 @@ import Models struct ThreadDetailView: View { let thread: ThreadSummary? let messages: [MessageSummary] + let linkedTasks: [TaskSummary] var onComposeRequest: (ComposeMode) -> Void var body: some View { @@ -18,9 +19,16 @@ struct ThreadDetailView: View { Divider() - ForEach(messages) { message in - MessageView(message: message) - Divider() + // Merge messages and tasks, sorted by date + ForEach(sortedEntries) { entry in + switch entry { + case .message(let message): + MessageView(message: message) + Divider() + case .task(let task): + LinkedTaskView(task: task) + Divider() + } } } } @@ -66,6 +74,115 @@ struct ThreadDetailView: View { } } } + + // MARK: - Merged timeline + + private enum TimelineEntry: Identifiable { + case message(MessageSummary) + case task(TaskSummary) + + var id: String { + switch self { + case .message(let msg): "msg-\(msg.id)" + case .task(let task): "task-\(task.id)" + } + } + + var date: Date { + switch self { + case .message(let msg): msg.date + case .task(let task): task.createdAt + } + } + } + + private var sortedEntries: [TimelineEntry] { + var entries: [TimelineEntry] = [] + entries.append(contentsOf: messages.map { .message($0) }) + entries.append(contentsOf: linkedTasks.map { .task($0) }) + return entries.sorted { $0.date < $1.date } + } +} + +// MARK: - Linked Task View + +struct LinkedTaskView: View { + let task: TaskSummary + + var body: some View { + HStack(spacing: 10) { + Image(systemName: statusIcon) + .font(.title3) + .foregroundStyle(statusColor) + + VStack(alignment: .leading, spacing: 4) { + Text(task.summary) + .fontWeight(.medium) + .strikethrough(task.status == .completed || task.status == .cancelled) + + HStack(spacing: 8) { + StatusBadge(status: task.status) + + if let due = task.dueDate { + Text("Due \(due, style: .date)") + .font(.caption) + .foregroundStyle(due < Date() ? .red : .secondary) + } + } + } + + Spacer() + + Text(task.createdAt, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + .padding(.vertical, 4) + } + + private var statusIcon: String { + switch task.status { + case .completed: "checkmark.circle.fill" + case .cancelled: "xmark.circle" + case .inProcess: "circle.dotted.circle" + case .needsAction: "circle" + } + } + + private var statusColor: Color { + switch task.status { + case .completed: .green + case .cancelled: .red + case .inProcess: .orange + case .needsAction: .primary + } + } +} + +struct StatusBadge: View { + let status: TaskStatus + + var body: some View { + Text(status.rawValue) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(badgeColor.opacity(0.15), in: Capsule()) + .foregroundStyle(badgeColor) + } + + private var badgeColor: Color { + switch status { + case .completed: .green + case .cancelled: .red + case .inProcess: .orange + case .needsAction: .blue + } + } } struct MessageView: View { diff --git a/Apps/MagnumOpus/Views/ThreadListView.swift b/Apps/MagnumOpus/Views/ThreadListView.swift index 0f2bc06..4c51fa1 100644 --- a/Apps/MagnumOpus/Views/ThreadListView.swift +++ b/Apps/MagnumOpus/Views/ThreadListView.swift @@ -1,11 +1,80 @@ import SwiftUI import Models +import MailStore struct ThreadListView: View { @Bindable var viewModel: MailViewModel @State var showMoveSheet = false + @State var showDeferPicker = false + @State var showLabelPicker = false + + /// Whether the current view shows a GTD perspective (vs. a raw mailbox folder) + private var isGTDPerspective: Bool { + // GTD perspectives are active when items are loaded + !viewModel.items.isEmpty || viewModel.selectedMailbox == nil + } var body: some View { + Group { + if !viewModel.items.isEmpty { + itemListView + } else { + threadListView + } + } + .sheet(isPresented: $showMoveSheet) { + MoveToSheet(mailboxes: viewModel.mailboxes) { mailbox in + viewModel.moveSelectedThread(to: mailbox) + } + } + .popover(isPresented: $showDeferPicker) { + DeferPicker { date in + if let selectedItem = selectedItemSummary { + viewModel.deferItem(selectedItem, until: date) + } + } + } + .sheet(isPresented: $showLabelPicker) { + LabelPicker( + labels: availableLabels, + onSelect: { label in + if let selectedItem = selectedItemSummary { + viewModel.fileItem(selectedItem, label: label) + } + }, + onCreate: { name in + createAndFileLabel(name: name) + } + ) + } + } + + // MARK: - Unified Item List (GTD perspectives) + + private var itemListView: some View { + List(viewModel.items) { item in + ItemRow(item: item) + .tag(item.id) + } + .listStyle(.inset) + .navigationTitle(viewModel.selectedPerspective.label) + .overlay { + if viewModel.items.isEmpty { + ContentUnavailableView( + "No Items", + systemImage: viewModel.selectedPerspective.systemImage, + description: Text("Nothing in \(viewModel.selectedPerspective.label)") + ) + } + } + .toolbar { + gtdToolbarItems + } + } + + // MARK: - Thread List (folder view) + + private var threadListView: some View { List(viewModel.threads, selection: Binding( get: { viewModel.selectedThread?.id }, set: { newId in @@ -46,55 +115,221 @@ struct ThreadListView: View { } } .toolbar { - ToolbarItemGroup(placement: .automatic) { - if viewModel.selectedThread != nil { - Button { - viewModel.archiveSelectedThread() - } label: { - Label("Archive", systemImage: "archivebox") - } - .keyboardShortcut("e", modifiers: []) - .help("Archive (e)") + folderToolbarItems + } + } - Button { - viewModel.deleteSelectedThread() - } label: { - Label("Delete", systemImage: "trash") - } - .keyboardShortcut(.delete, modifiers: []) - .help("Delete (⌫)") + // MARK: - Toolbar Items - Button { - viewModel.toggleFlagSelectedThread() - } label: { - Label("Flag", systemImage: "flag") - } - .keyboardShortcut("s", modifiers: []) - .help("Toggle Flag (s)") + @ToolbarContentBuilder + private var folderToolbarItems: some ToolbarContent { + ToolbarItemGroup(placement: .automatic) { + if viewModel.selectedThread != nil { + Button { + viewModel.archiveSelectedThread() + } label: { + Label("Archive", systemImage: "archivebox") + } + .keyboardShortcut("e", modifiers: []) + .help("Archive (e)") - Button { - viewModel.toggleReadSelectedThread() - } label: { - Label("Read/Unread", systemImage: "envelope.badge") - } - .keyboardShortcut("u", modifiers: [.shift, .command]) - .help("Toggle Read/Unread (⇧⌘U)") + Button { + viewModel.deleteSelectedThread() + } label: { + Label("Delete", systemImage: "trash") + } + .keyboardShortcut(.delete, modifiers: []) + .help("Delete (⌫)") - Button { - showMoveSheet = true - } label: { - Label("Move", systemImage: "folder") + Button { + viewModel.toggleFlagSelectedThread() + } label: { + Label("Flag", systemImage: "flag") + } + .keyboardShortcut("s", modifiers: []) + .help("Toggle Flag (s)") + + Button { + viewModel.toggleReadSelectedThread() + } label: { + Label("Read/Unread", systemImage: "envelope.badge") + } + .keyboardShortcut("u", modifiers: [.shift, .command]) + .help("Toggle Read/Unread (⇧⌘U)") + + Button { + showMoveSheet = true + } label: { + Label("Move", systemImage: "folder") + } + .keyboardShortcut("m", modifiers: [.shift, .command]) + .help("Move to… (⇧⌘M)") + + // GTD shortcuts available in folder views too + Button { + showDeferPicker = true + } label: { + Label("Defer", systemImage: "clock.arrow.circlepath") + } + .keyboardShortcut("d", modifiers: []) + .help("Defer (d)") + + Button { + showLabelPicker = true + } label: { + Label("Project", systemImage: "folder.badge.plus") + } + .keyboardShortcut("p", modifiers: []) + .help("File to Project (p)") + } + } + } + + @ToolbarContentBuilder + private var gtdToolbarItems: some ToolbarContent { + ToolbarItemGroup(placement: .automatic) { + Button { + showDeferPicker = true + } label: { + Label("Defer", systemImage: "clock.arrow.circlepath") + } + .keyboardShortcut("d", modifiers: []) + .help("Defer (d)") + + Button { + if let item = selectedItemSummary { + viewModel.deferItem(item, until: nil) + } + } label: { + Label("Someday", systemImage: "moon.zzz") + } + .keyboardShortcut("d", modifiers: [.shift]) + .help("Someday (⇧D)") + + Button { + showLabelPicker = true + } label: { + Label("Project", systemImage: "folder.badge.plus") + } + .keyboardShortcut("p", modifiers: []) + .help("File to Project (p)") + + Button { + if let item = selectedItemSummary { + viewModel.completeItem(item) + } + } label: { + Label("Complete", systemImage: "checkmark.circle") + } + .keyboardShortcut(.return, modifiers: .command) + .help("Complete (⌘⏎)") + + Button { + if let item = selectedItemSummary { + viewModel.discardItem(item) + } + } label: { + Label("Discard", systemImage: "trash") + } + .keyboardShortcut(.delete, modifiers: []) + .help("Discard (⌫)") + } + } + + // MARK: - Helpers + + private var selectedItemSummary: ItemSummary? { + // In GTD mode, use the first item (or a future selection mechanism) + // For now, if there's a selected thread, build the email item from it + if let thread = viewModel.selectedThread, + let msg = viewModel.messages.first { + return .email(msg) + } + return viewModel.items.first + } + + private var availableLabels: [LabelInfo] { + guard let store = viewModel.store, let config = viewModel.accountConfig else { return [] } + let records = (try? store.labels(accountId: config.id)) ?? [] + return records.map { LabelInfo(id: $0.id, name: $0.name, isProject: $0.isProject, color: $0.color) } + } + + private func createAndFileLabel(name: String) { + guard let store = viewModel.store, let config = viewModel.accountConfig else { return } + do { + let labelId = UUID().uuidString + let record = LabelRecord( + id: labelId, + accountId: config.id, + name: name, + isProject: true + ) + try store.insertLabel(record) + let label = LabelInfo(id: labelId, name: name, isProject: true) + if let selectedItem = selectedItemSummary { + viewModel.fileItem(selectedItem, label: label) + } + } catch { + viewModel.errorMessage = error.localizedDescription + } + } +} + +struct ItemRow: View { + let item: ItemSummary + + var body: some View { + HStack(spacing: 8) { + switch item { + case .email(let msg): + Image(systemName: "envelope") + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + Text(msg.from?.displayName ?? "Unknown") + .fontWeight(msg.isRead ? .regular : .bold) + .lineLimit(1) + Text(msg.subject ?? "(No Subject)") + .font(.subheadline) + .lineLimit(1) + if let snippet = msg.snippet { + Text(snippet) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + case .task(let task): + Image(systemName: task.status == .completed ? "checkmark.circle.fill" : + task.status == .cancelled ? "xmark.circle" : "circle") + .foregroundStyle(task.status == .completed ? .green : + task.status == .cancelled ? .red : .primary) + VStack(alignment: .leading, spacing: 2) { + Text(task.summary) + .fontWeight(.regular) + .lineLimit(1) + .strikethrough(task.status == .completed || task.status == .cancelled) + if let due = task.dueDate { + Text("Due \(due, style: .date)") + .font(.caption) + .foregroundStyle(due < Date() ? .red : .secondary) + } + if let desc = task.description { + Text(desc) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) } - .keyboardShortcut("m", modifiers: [.shift, .command]) - .help("Move to… (⇧⌘M)") } } + + Spacer() + + Text(item.date, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) } - .sheet(isPresented: $showMoveSheet) { - MoveToSheet(mailboxes: viewModel.mailboxes) { mailbox in - viewModel.moveSelectedThread(to: mailbox) - } - } + .padding(.vertical, 2) } }