add three-column swiftui layout: sidebar, thread list, detail, account setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,62 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
import MailStore
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var viewModel = MailViewModel()
|
||||
@State private var accountSetup = AccountSetupViewModel()
|
||||
@State private var showingAccountSetup = false
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
Text("Sidebar")
|
||||
} content: {
|
||||
Text("Thread List")
|
||||
} detail: {
|
||||
Text("Detail")
|
||||
Group {
|
||||
if viewModel.hasAccount {
|
||||
mailView
|
||||
} else {
|
||||
NavigationStack {
|
||||
AccountSetupView(viewModel: accountSetup) {
|
||||
connectAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExistingAccount()
|
||||
}
|
||||
}
|
||||
|
||||
private var mailView: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(viewModel: viewModel)
|
||||
} content: {
|
||||
ThreadListView(viewModel: viewModel)
|
||||
} detail: {
|
||||
ThreadDetailView(
|
||||
thread: viewModel.selectedThread,
|
||||
messages: viewModel.messages
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await viewModel.syncNow()
|
||||
viewModel.startPeriodicSync()
|
||||
}
|
||||
}
|
||||
|
||||
private func connectAccount() {
|
||||
guard let (config, credentials) = accountSetup.buildConfig() else { return }
|
||||
do {
|
||||
try viewModel.setup(config: config, credentials: credentials)
|
||||
Task {
|
||||
await viewModel.syncNow()
|
||||
await viewModel.loadMailboxes(accountId: config.id)
|
||||
viewModel.startObservingThreads(accountId: config.id)
|
||||
viewModel.startPeriodicSync()
|
||||
}
|
||||
} catch {
|
||||
accountSetup.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExistingAccount() {
|
||||
// Keychain loading added in Task 15
|
||||
}
|
||||
}
|
||||
|
||||
59
Apps/MagnumOpus/Views/AccountSetupView.swift
Normal file
59
Apps/MagnumOpus/Views/AccountSetupView.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountSetupView: View {
|
||||
@Bindable var viewModel: AccountSetupViewModel
|
||||
var onComplete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Account") {
|
||||
TextField("Email", text: $viewModel.email)
|
||||
#if os(iOS)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
TextField("Account Name (optional)", text: $viewModel.accountName)
|
||||
}
|
||||
|
||||
if viewModel.isManualMode || viewModel.autoDiscoveryFailed {
|
||||
Section("Server Settings") {
|
||||
TextField("IMAP Host", text: $viewModel.imapHost)
|
||||
TextField("IMAP Port", text: $viewModel.imapPort)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if viewModel.isAutoDiscovering {
|
||||
ProgressView("Discovering settings…")
|
||||
} else {
|
||||
Button("Connect") {
|
||||
onComplete()
|
||||
}
|
||||
.disabled(!viewModel.canSubmit)
|
||||
|
||||
if !viewModel.isManualMode {
|
||||
Button("Enter server settings manually") {
|
||||
viewModel.isManualMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Add Account")
|
||||
.task {
|
||||
if !viewModel.email.isEmpty && !viewModel.isManualMode {
|
||||
await viewModel.autoDiscover()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Apps/MagnumOpus/Views/SidebarView.swift
Normal file
40
Apps/MagnumOpus/Views/SidebarView.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
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 }
|
||||
}
|
||||
)) {
|
||||
Section("Mailboxes") {
|
||||
ForEach(viewModel.mailboxes) { mailbox in
|
||||
Label(mailbox.name, systemImage: mailbox.systemImage)
|
||||
.tag(mailbox.id)
|
||||
.badge(mailbox.unreadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Magnum Opus")
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task { await viewModel.syncNow() }
|
||||
} label: {
|
||||
switch viewModel.syncState {
|
||||
case .syncing:
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
default:
|
||||
Label("Sync", systemImage: "arrow.trianglehead.2.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Apps/MagnumOpus/Views/ThreadDetailView.swift
Normal file
86
Apps/MagnumOpus/Views/ThreadDetailView.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
struct ThreadDetailView: View {
|
||||
let thread: ThreadSummary?
|
||||
let messages: [MessageSummary]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let thread {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(thread.subject ?? "(No Subject)")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(messages) { message in
|
||||
MessageView(message: message)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Thread Selected",
|
||||
systemImage: "envelope",
|
||||
description: Text("Select a thread to read")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageView: View {
|
||||
let message: MessageSummary
|
||||
@State private var showHTML = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(message.from?.displayName ?? "Unknown")
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
if message.bodyHtml != nil {
|
||||
Toggle(isOn: $showHTML) {
|
||||
Text("HTML")
|
||||
.font(.caption)
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(message.date, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !message.to.isEmpty {
|
||||
Text("To: \(message.to.map(\.displayName).joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if showHTML, let html = message.bodyHtml {
|
||||
MessageWebView(html: html)
|
||||
.frame(minHeight: 200)
|
||||
} else if let bodyText = message.bodyText {
|
||||
Text(bodyText)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
} else if let snippet = message.snippet {
|
||||
Text(snippet)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
} else {
|
||||
Text("Loading body…")
|
||||
.font(.body)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
74
Apps/MagnumOpus/Views/ThreadListView.swift
Normal file
74
Apps/MagnumOpus/Views/ThreadListView.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
struct ThreadListView: View {
|
||||
@Bindable var viewModel: MailViewModel
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.threads, selection: Binding(
|
||||
get: { viewModel.selectedThread?.id },
|
||||
set: { newId in
|
||||
if let thread = viewModel.threads.first(where: { $0.id == newId }) {
|
||||
viewModel.selectThread(thread)
|
||||
}
|
||||
}
|
||||
)) { thread in
|
||||
ThreadRow(thread: thread)
|
||||
.tag(thread.id)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.navigationTitle(viewModel.selectedMailbox?.name ?? "Mail")
|
||||
.overlay {
|
||||
if viewModel.threads.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Messages",
|
||||
systemImage: "tray",
|
||||
description: Text("No threads in this mailbox")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThreadRow: View {
|
||||
let thread: ThreadSummary
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(thread.senders)
|
||||
.fontWeight(thread.unreadCount > 0 ? .bold : .regular)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(thread.lastDate, style: .relative)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(thread.subject ?? "(No Subject)")
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
if let snippet = thread.snippet {
|
||||
Text(snippet)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
if thread.messageCount > 1 {
|
||||
Text("\(thread.messageCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
if thread.unreadCount > 0 {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user