import SwiftUI struct SettingsView: View { let accountStore: AccountStore @State private var showAddAccount = false var body: some View { VStack(spacing: 0) { // Header HStack { Text("Accounts") .font(.headline) Spacer() Button { showAddAccount = true } label: { Image(systemName: "plus") } } .padding() Divider() // Account list if accountStore.accounts.isEmpty { VStack(spacing: 8) { Image(systemName: "person.crop.circle.badge.plus") .font(.largeTitle) .foregroundStyle(.secondary) Text("No accounts yet") .font(.subheadline) .foregroundStyle(.secondary) Text("Add a Mastodon, WordPress, or Micro.blog account to get started.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } else { List { ForEach(accountStore.accounts) { account in HStack { Image(systemName: account.serviceType.iconName) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text(account.displayName) .font(.body) Text(account.serviceType.displayName) .font(.caption) .foregroundStyle(.secondary) } Spacer() Button(role: .destructive) { accountStore.removeAccount(account) } label: { Image(systemName: "trash") .font(.caption) } .buttonStyle(.plain) .foregroundStyle(.red.opacity(0.7)) } .padding(.vertical, 2) } } } } .frame(width: 360, height: 300) .sheet(isPresented: $showAddAccount) { AddAccountView(accountStore: accountStore, isPresented: $showAddAccount) } } } struct AddAccountView: View { let accountStore: AccountStore @Binding var isPresented: Bool @State private var selectedService: ServiceType = .mastodon @State private var instanceURL = "" @State private var token = "" @State private var username = "" @State private var isLoading = false @State private var errorMessage: String? var body: some View { VStack(spacing: 16) { Text("Add Account") .font(.headline) // Service picker Picker("Service", selection: $selectedService) { ForEach(ServiceType.allCases) { service in Text(service.displayName).tag(service) } } .pickerStyle(.segmented) .disabled(waitingForOAuth || isLoading) if waitingForOAuth { VStack(spacing: 8) { ProgressView() Text("Waiting for authorization in your browser...") .font(.caption) .foregroundStyle(.secondary) Text("Authorize qStatus on your Mastodon instance, then return here.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } .padding(.vertical, 8) } else { // Service-specific fields switch selectedService { case .mastodon: mastodonFields case .wordpress: wordpressFields case .microblog: microblogFields } } if let errorMessage { Text(errorMessage) .font(.caption) .foregroundStyle(.red) } HStack { Button(waitingForOAuth ? "Cancel Auth" : "Cancel") { if waitingForOAuth { waitingForOAuth = false UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account") } isPresented = false } .keyboardShortcut(.cancelAction) Spacer() if isLoading && !waitingForOAuth { ProgressView() .controlSize(.small) } if !waitingForOAuth { Button("Add") { Task { await addAccount() } } .buttonStyle(.borderedProminent) .disabled(!canAdd || isLoading) .keyboardShortcut(.defaultAction) } } } .padding() .frame(width: 360) } private var canAdd: Bool { switch selectedService { case .mastodon: !instanceURL.trimmingCharacters(in: .whitespaces).isEmpty case .wordpress: !instanceURL.trimmingCharacters(in: .whitespaces).isEmpty && !username.trimmingCharacters(in: .whitespaces).isEmpty && !token.trimmingCharacters(in: .whitespaces).isEmpty case .microblog: !token.trimmingCharacters(in: .whitespaces).isEmpty } } @ViewBuilder private var mastodonFields: some View { VStack(alignment: .leading, spacing: 4) { Text("Instance URL") .font(.caption) .foregroundStyle(.secondary) TextField("https://mastodon.social", text: $instanceURL) .textFieldStyle(.roundedBorder) } Text("You'll be redirected to your instance to authorize qStatus.") .font(.caption) .foregroundStyle(.secondary) } @ViewBuilder private var wordpressFields: some View { VStack(alignment: .leading, spacing: 4) { Text("Site URL") .font(.caption) .foregroundStyle(.secondary) TextField("https://your-site.com", text: $instanceURL) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("Username") .font(.caption) .foregroundStyle(.secondary) TextField("admin", text: $username) .textFieldStyle(.roundedBorder) } VStack(alignment: .leading, spacing: 4) { Text("Application Password") .font(.caption) .foregroundStyle(.secondary) SecureField("xxxx xxxx xxxx xxxx", text: $token) .textFieldStyle(.roundedBorder) } Text("Generate an Application Password in WordPress: Users > Profile > Application Passwords") .font(.caption) .foregroundStyle(.secondary) } @ViewBuilder private var microblogFields: some View { VStack(alignment: .leading, spacing: 4) { Text("App Token") .font(.caption) .foregroundStyle(.secondary) SecureField("Paste your token", text: $token) .textFieldStyle(.roundedBorder) } Text("Get your token at micro.blog/account/apps") .font(.caption) .foregroundStyle(.secondary) } @State private var waitingForOAuth = false private func addAccount() async { isLoading = true errorMessage = nil do { switch selectedService { case .mastodon: try await addMastodonAccount() // Don't close sheet - waiting for OAuth callback isLoading = false return case .wordpress: try await addWordPressAccount() case .microblog: try await addMicroblogAccount() } isPresented = false } catch { errorMessage = error.localizedDescription } isLoading = false } private func addMastodonAccount() async throws { var url = instanceURL.trimmingCharacters(in: .whitespaces) if !url.hasPrefix("https://") && !url.hasPrefix("http://") { url = "https://\(url)" } let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url) var account = Account( serviceType: .mastodon, displayName: url.replacingOccurrences(of: "https://", with: ""), instanceURL: url, username: "" ) account.mastodonClientID = clientID account.mastodonClientSecret = clientSecret let accountData = try JSONEncoder().encode(account) UserDefaults.standard.set(accountData, forKey: "qstatus.pending-mastodon-account") let authURL = MastodonClient.authorizeURL(instanceURL: url, clientID: clientID) NSWorkspace.shared.open(authURL) waitingForOAuth = true // Poll for the account to be added by the URL callback handler for _ in 0..<120 { // wait up to 2 minutes try? await Task.sleep(for: .seconds(1)) if UserDefaults.standard.data(forKey: "qstatus.pending-mastodon-account") == nil { // Callback was handled, account was added waitingForOAuth = false isPresented = false return } } waitingForOAuth = false throw PostingError(message: "Authorization timed out. Please try again.") } private func addWordPressAccount() async throws { var url = instanceURL.trimmingCharacters(in: .whitespaces) if !url.hasPrefix("https://") && !url.hasPrefix("http://") { url = "https://\(url)" } let user = username.trimmingCharacters(in: .whitespaces) let pass = token.trimmingCharacters(in: .whitespaces) let valid = try await WordPressClient.verifyCredentials( siteURL: url, username: user, password: pass ) guard valid else { throw PostingError(message: "Invalid credentials. Check username and application password.") } let credentials = "\(user):\(pass)" let account = Account( serviceType: .wordpress, displayName: "\(user)@\(url.replacingOccurrences(of: "https://", with: ""))", instanceURL: url, username: user ) try accountStore.addAccount(account, token: credentials) } private func addMicroblogAccount() async throws { let appToken = token.trimmingCharacters(in: .whitespaces) let blogName = try await MicroblogClient.verifyToken(appToken) let account = Account( serviceType: .microblog, displayName: blogName, instanceURL: "https://micro.blog", username: blogName ) try accountStore.addAccount(account, token: appToken) } }