241 lines
12 KiB
Swift
241 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|