From e3159d61e8f13a796bad54fb9bd8711f00b5d50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 13 Mar 2026 21:23:04 +0100 Subject: [PATCH] add three-column swiftui layout: sidebar, thread list, detail, account setup Co-Authored-By: Claude Opus 4.6 --- Apps/MagnumOpus/ContentView.swift | 61 ++++++++++++-- Apps/MagnumOpus/Views/AccountSetupView.swift | 59 ++++++++++++++ Apps/MagnumOpus/Views/SidebarView.swift | 40 +++++++++ Apps/MagnumOpus/Views/ThreadDetailView.swift | 86 ++++++++++++++++++++ Apps/MagnumOpus/Views/ThreadListView.swift | 74 +++++++++++++++++ 5 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 Apps/MagnumOpus/Views/AccountSetupView.swift create mode 100644 Apps/MagnumOpus/Views/SidebarView.swift create mode 100644 Apps/MagnumOpus/Views/ThreadDetailView.swift create mode 100644 Apps/MagnumOpus/Views/ThreadListView.swift diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index 3385d8c..676928b 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -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 + } } diff --git a/Apps/MagnumOpus/Views/AccountSetupView.swift b/Apps/MagnumOpus/Views/AccountSetupView.swift new file mode 100644 index 0000000..693455f --- /dev/null +++ b/Apps/MagnumOpus/Views/AccountSetupView.swift @@ -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() + } + } + } +} diff --git a/Apps/MagnumOpus/Views/SidebarView.swift b/Apps/MagnumOpus/Views/SidebarView.swift new file mode 100644 index 0000000..cd748e0 --- /dev/null +++ b/Apps/MagnumOpus/Views/SidebarView.swift @@ -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") + } + } + } + } + } +} diff --git a/Apps/MagnumOpus/Views/ThreadDetailView.swift b/Apps/MagnumOpus/Views/ThreadDetailView.swift new file mode 100644 index 0000000..9a531c3 --- /dev/null +++ b/Apps/MagnumOpus/Views/ThreadDetailView.swift @@ -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() + } +} diff --git a/Apps/MagnumOpus/Views/ThreadListView.swift b/Apps/MagnumOpus/Views/ThreadListView.swift new file mode 100644 index 0000000..77b4728 --- /dev/null +++ b/Apps/MagnumOpus/Views/ThreadListView.swift @@ -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) + } +}