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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
56
Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift
Normal file
56
Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
103
Apps/MagnumOpus/Views/DeferPicker.swift
Normal file
103
Apps/MagnumOpus/Views/DeferPicker.swift
Normal 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))!
|
||||
}
|
||||
}
|
||||
58
Apps/MagnumOpus/Views/LabelPicker.swift
Normal file
58
Apps/MagnumOpus/Views/LabelPicker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71
Apps/MagnumOpus/Views/TaskEditView.swift
Normal file
71
Apps/MagnumOpus/Views/TaskEditView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user