add app UI for GTD triage: perspectives, task creation, defer/label pickers, linked tasks

- replace mailbox-only sidebar with 6 GTD perspectives (inbox, today, upcoming, projects, someday, archive)
- add TaskEditViewModel/TaskEditView for creating tasks with optional due date, linked message
- add DeferPicker (tomorrow, next week, pick date, someday) and LabelPicker (searchable, type-to-create)
- add GTD triage actions in MailViewModel: defer, file, discard, complete for unified ItemSummary
- add GTD keyboard shortcuts in ThreadListView: d (defer), ⇧D (someday), p (project), ⌘⏎ (complete), ⌫ (discard)
- show linked tasks inline in ThreadDetailView with status badges, merged timeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 09:06:57 +01:00
parent ac824cd72e
commit bec9b7eb40
9 changed files with 1068 additions and 52 deletions

View File

@@ -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()

View File

@@ -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<Void, Never>?
private var messageObservation: Task<Void, Never>?
@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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))!
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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)
}
}