Files
MikeMail/MikeMail/AccountsView.swift
T
2026-11-08 22:00:47 +01:00

241 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct AccountsView: View {
let store: MailStore
@Environment(\.dismiss) private var dismiss
@State private var selection: UUID?
@State private var drafts: [UUID: MailAccount] = [:]
@State private var passwords: [UUID: String] = [:]
@State private var newAccount: MailAccount?
@State private var error: String?
@State private var confirmingDelete = false
@State private var resetAccount: MailAccount?
@State private var confirmingReset = false
@State private var resetResult: String?
@State private var testing = false
@State private var testedAccount: MailAccount?
@State private var testedPassword = ""
@State private var testMailAccount: MailAccount?
@State private var askingTestMail = false
@State private var accountsNeedingRefresh: Set<UUID> = []
@AppStorage("offerTestMail") private var offerTestMail = true
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
List(selection: $selection) {
ForEach(store.accounts) { account in
VStack(alignment: .leading) {
Text(account.name.isEmpty ? account.email : account.name)
Text(account.email).font(.caption).foregroundStyle(.secondary)
}.tag(account.id)
}
if let newAccount {
Label("Neues Konto", systemImage: "plus.circle").tag(newAccount.id)
}
}
.frame(width: 230)
.accessibilityIdentifier("accountList")
Divider()
VStack(alignment: .leading, spacing: 18) {
Text("Konteneinstellungen").font(.title2).bold()
if let id = selection, let account = drafts[id] {
AccountSettingsForm(account: Binding(
get: { drafts[id] ?? account },
set: { drafts[id] = $0 }
), password: Binding(
get: { passwords[id] ?? "" },
set: { passwords[id] = $0 }
))
Text("Ein leeres Passwortfeld behält das gespeicherte Passwort bei. Änderungen mit „Speichern“ übernehmen.")
.font(.caption).foregroundStyle(.secondary)
} else {
ContentUnavailableView("Kein Konto ausgewählt", systemImage: "person.crop.circle",
description: Text("Füge ein Konto hinzu oder wähle links ein Konto aus."))
}
if let saved = store.accounts.first(where: { $0.id == selection }) {
Button("Lokale Mails löschen …", systemImage: "trash", role: .destructive) {
resetAccount = saved
confirmingReset = true
}
.disabled(store.isBusy || store.sendingAccountIDs.contains(saved.id))
.accessibilityIdentifier("clearLocalMessages")
}
if let resetResult { Text(resetResult).font(.caption).foregroundStyle(.secondary) }
if let error { Text(error).foregroundStyle(.red) }
Spacer()
HStack {
Spacer()
if testing { ProgressView().controlSize(.small) }
Button("Verbindung testen") { testConnection() }
.disabled(selection == nil || testing)
Button("Speichern") { save() }
.disabled(selection == nil || testing || store.isBusy ||
(newAccount?.id == selection && (testedAccount != selection.flatMap { drafts[$0] } ||
testedPassword != (selection.flatMap { passwords[$0] } ?? ""))))
.keyboardShortcut(.defaultAction)
}
}
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
Divider()
HStack {
Button("Neues Konto hinzufügen", systemImage: "plus") {
if let newAccount { selection = newAccount.id }
else {
let account = MailAccount()
newAccount = account
drafts[account.id] = account
selection = account.id
}
error = nil
}
Button("Konto löschen", systemImage: "minus", role: .destructive) { confirmingDelete = true }
.disabled(selection == nil || store.isBusy)
Spacer()
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
}.padding(16)
}
.frame(width: 850, height: 520)
.interactiveDismissDisabled(testing)
.disabled(testing)
.alert("Testmail senden?", isPresented: $askingTestMail) {
Button("Nein", role: .cancel) {}
Button("Testmail senden") {
if let account = testMailAccount {
Task {
do {
try await store.send(accountID: account.id, recipients: account.email,
subject: "MikeMail – Testnachricht", body: "Versand und Empfang mit MikeMail sind eingerichtet.", attachments: [])
} catch { self.error = error.localizedDescription }
}
}
}
} message: { Text("Eine Testnachricht an \(testMailAccount?.email ?? "") senden?") }
.onAppear {
drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) })
selection = store.accounts.first?.id
}
.onChange(of: selection) { _, _ in error = nil; resetResult = nil }
.onDisappear { refreshChangedAccounts() }
.confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) {
Button("Lokale Mails endgültig löschen", role: .destructive) {
guard let account = resetAccount else { return }
do {
let failures = try store.clearLocalMessages(accountID: account.id)
error = nil
resetResult = failures == 0
? "Lokale Mails gelöscht. „Abrufen“ lädt die vorhandenen Servermails vollständig neu."
: "Lokaler Bestand zurückgesetzt. \(failures) nicht mehr verwendete Dateien konnten nicht entfernt werden. Beim nächsten Abrufen werden trotzdem alle Servermails neu geladen."
} catch { self.error = error.localizedDescription }
resetAccount = nil
}
Button("Abbrechen", role: .cancel) { resetAccount = nil }
} message: {
Text("Konto: \(resetAccount?.email ?? "")\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen.")
}
.confirmationDialog("Konto aus MikeMail entfernen?", isPresented: $confirmingDelete) {
Button("Konto löschen", role: .destructive) { delete() }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Die lokalen Kontoeinstellungen und das gespeicherte Passwort werden entfernt. Mails auf dem Server bleiben erhalten.")
}
}
private func save() {
guard let id = selection, let account = drafts[id] else { return }
do {
let isNew = !store.accounts.contains { $0.id == id }
try store.save(account: account, password: passwords[id] ?? "")
passwords[id] = nil
if newAccount?.id == id { newAccount = nil }
if isNew {
Task {
while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) }
await store.refresh(folderID: nil, accountID: id)
if offerTestMail { testMailAccount = account; askingTestMail = true }
}
} else {
accountsNeedingRefresh.insert(id)
}
error = nil
} catch { self.error = error.localizedDescription }
}
private func refreshChangedAccounts() {
let accountIDs = accountsNeedingRefresh
accountsNeedingRefresh.removeAll()
guard !accountIDs.isEmpty else { return }
Task {
for id in accountIDs where store.accounts.contains(where: { $0.id == id }) {
while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) }
await store.refresh(folderID: nil, accountID: id)
}
}
}
private func testConnection() {
guard let id = selection, let account = drafts[id] else { return }
let password = passwords[id] ?? ""
testing = true
error = nil
testedAccount = nil
Task {
defer { testing = false }
do {
try await store.testConnection(account: account, password: password)
testedAccount = account
testedPassword = password
resetResult = "IMAP- und SMTP-Verbindung erfolgreich geprüft."
} catch { self.error = error.localizedDescription }
}
}
private func delete() {
guard let id = selection else { return }
do {
if newAccount?.id == id { newAccount = nil }
else { try store.deleteAccount(id: id) }
drafts[id] = nil
passwords[id] = nil
selection = store.accounts.first?.id ?? newAccount?.id
} catch { self.error = error.localizedDescription }
}
}
struct AccountSettingsForm: View {
@Binding var account: MailAccount
@Binding var password: String
@State private var showsPassword = false
var body: some View {
Form {
TextField("Name", text: $account.name)
TextField("Anzeigename", text: Binding(
get: { account.displayName ?? "" },
set: { account.displayName = $0 }
))
TextField("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail")
TextField("Benutzername", text: $account.username)
TextField("Server", text: $account.host)
TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never))
TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never))
LabeledContent("Passwort") {
HStack(spacing: 8) {
if showsPassword {
TextField("Passwort", text: $password)
} else {
SecureField("Passwort", text: $password)
}
Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen",
systemImage: showsPassword ? "eye.slash" : "eye") {
showsPassword.toggle()
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
}
}
}
}
}