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:
2026-03-13 21:23:04 +01:00
parent 6f0ba20d86
commit e3159d61e8
5 changed files with 314 additions and 6 deletions

View File

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

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

View 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")
}
}
}
}
}
}

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

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