From a53500f643d13bf728b4cf78ad41b56673e6780d Mon Sep 17 00:00:00 2001 From: Stuart Breckenridge Date: Mon, 13 Jul 2020 21:41:02 +0800 Subject: [PATCH] Adding and deleting accounts works --- .../Accounts/AccountsPreferenceModel.swift | 50 +++ .../Accounts/AccountsPreferencesView.swift | 151 +++++++++ .../Add Account/AddAccountModel.swift | 304 ++++++++++++++++++ .../Accounts/Add Account/AddAccountView.swift | 138 ++++++++ .../AccountsPreferencesView.swift | 230 ------------- .../AdvancedPreferencesView.swift | 0 .../GeneralPreferencesView.swift | 0 NetNewsWire.xcodeproj/project.pbxproj | 50 ++- 8 files changed, 690 insertions(+), 233 deletions(-) create mode 100644 Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferenceModel.swift create mode 100644 Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferencesView.swift create mode 100644 Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountModel.swift create mode 100644 Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountView.swift delete mode 100644 Multiplatform/macOS/Preferences/Preference Panes/AccountsPreferencesView.swift rename Multiplatform/macOS/Preferences/Preference Panes/{ => Advanced}/AdvancedPreferencesView.swift (100%) rename Multiplatform/macOS/Preferences/Preference Panes/{ => General}/GeneralPreferencesView.swift (100%) diff --git a/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferenceModel.swift b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferenceModel.swift new file mode 100644 index 000000000..79b751be3 --- /dev/null +++ b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferenceModel.swift @@ -0,0 +1,50 @@ +// +// AccountsPreferenceModel.swift +// Multiplatform macOS +// +// Created by Stuart Breckenridge on 13/7/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import Account +import Combine + +class AccountsPreferenceModel: ObservableObject { + + + @Published var sortedAccounts: [Account] = [] + + // Configured Accounts + @Published var selectedConfiguredAccountID: String? = nil + + // Sheets + @Published var showAddAccountView: Bool = false + + var selectedAccountIsDefault: Bool { + guard let selected = selectedConfiguredAccountID else { + return true + } + if selected == AccountManager.shared.defaultAccount.accountID { + return true + } + return false + } + + // Subscriptions + var notifcationSubscriptions = Set() + + init() { + sortedAccounts = AccountManager.shared.sortedAccounts + + NotificationCenter.default.publisher(for: .UserDidAddAccount).sink(receiveValue: { _ in + self.sortedAccounts = AccountManager.shared.sortedAccounts + }).store(in: ¬ifcationSubscriptions) + + NotificationCenter.default.publisher(for: .UserDidDeleteAccount).sink(receiveValue: { _ in + self.selectedConfiguredAccountID = nil + self.sortedAccounts = AccountManager.shared.sortedAccounts + }).store(in: ¬ifcationSubscriptions) + } + +} diff --git a/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferencesView.swift b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferencesView.swift new file mode 100644 index 000000000..26f83114e --- /dev/null +++ b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/AccountsPreferencesView.swift @@ -0,0 +1,151 @@ +// +// AccountsPreferencesView.swift +// macOS +// +// Created by Stuart Breckenridge on 27/6/20. +// + +import SwiftUI +import Account + + +struct AccountsPreferencesView: View { + + @StateObject var viewModel = AccountsPreferenceModel() + + @State private var hoverOnAdd: Bool = false + @State private var hoverOnRemove: Bool = false + + var body: some View { + VStack { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading) { + List(viewModel.sortedAccounts, id: \.accountID, selection: $viewModel.selectedConfiguredAccountID) { + ConfiguredAccountRow(account: $0) + .id($0.accountID) + }.overlay( + Group { + bottomButtonStack + }, alignment: .bottom) + } + .frame(width: 225, height: 300, alignment: .leading) + .border(Color.gray, width: 1) + VStack(alignment: .leading) { + EmptyView() + Spacer() + }.frame(width: 225, height: 300, alignment: .leading) + } + Spacer() + }.sheet(isPresented: $viewModel.showAddAccountView, + onDismiss: { viewModel.showAddAccountView.toggle() }, + content: { + AddAccountView(preferencesModel: viewModel) + }) + + } + + var bottomButtonStack: some View { + VStack(alignment: .leading, spacing: 0) { + Divider() + HStack(alignment: .center, spacing: 4) { + Button(action: { + viewModel.showAddAccountView.toggle() + }, label: { + Image(systemName: "plus") + .font(.title) + .frame(width: 30, height: 30) + .overlay(RoundedRectangle(cornerRadius: 4, style: .continuous) + .foregroundColor(hoverOnAdd ? Color.gray.opacity(0.1) : Color.clear)) + .padding(4) + }) + .buttonStyle(BorderlessButtonStyle()) + .onHover { hovering in + hoverOnAdd = hovering + } + .help("Add Account") + + Button(action: { + if let account = viewModel.sortedAccounts.first(where: { $0.accountID == viewModel.selectedConfiguredAccountID }) { + AccountManager.shared.deleteAccount(account) + } + + }, label: { + Image(systemName: "minus") + .font(.title) + .frame(width: 30, height: 30) + .overlay(RoundedRectangle(cornerRadius: 4, style: .continuous) + .foregroundColor(hoverOnRemove ? Color.gray.opacity(0.1) : Color.clear)) + .padding(4) + }) + .buttonStyle(BorderlessButtonStyle()) + .onHover { hovering in + hoverOnRemove = hovering + } + .disabled(viewModel.selectedAccountIsDefault) + .help("Delete Account") + + Spacer() + } + .background(Color.white) + } + + + } + +} + +struct ConfiguredAccountRow: View { + + var account: Account + + var body: some View { + HStack(alignment: .center) { + if let img = account.smallIcon?.image { + Image(rsImage: img) + .resizable() + .frame(width: 30, height: 30) + .aspectRatio(contentMode: .fit) + } + Text(account.nameForDisplay) + }.padding(.vertical, 4) + } + +} + +struct AddAccountPickerRow: View { + + var accountType: AccountType + + var body: some View { + HStack { + if let img = AppAssets.image(for: accountType) { + Image(rsImage: img) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 15, height: 15) + } + + switch accountType { + case .onMyMac: + Text(Account.defaultLocalAccountName) + case .cloudKit: + Text("iCloud") + case .feedbin: + Text("Feedbin") + case .feedWrangler: + Text("FeedWrangler") + case .freshRSS: + Text("FreshRSS") + case .feedly: + Text("Feedly") + case .newsBlur: + Text("NewsBlur") + } + } + } +} + + + + + diff --git a/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountModel.swift b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountModel.swift new file mode 100644 index 000000000..8ffa3a555 --- /dev/null +++ b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountModel.swift @@ -0,0 +1,304 @@ +// +// AddAccountModel.swift +// Multiplatform macOS +// +// Created by Stuart Breckenridge on 13/7/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import Account +import RSWeb +import Secrets + +class AddAccountModel: ObservableObject { + + enum AddAccountErrors: CustomStringConvertible { + case invalidUsernamePassword, invalidUsernamePasswordAPI, networkError, keyChainError, other(error: Error) , none + + var description: String { + switch self { + case .invalidUsernamePassword: + return NSLocalizedString("Invalid email or password combination.", comment: "Invalid email/password combination.") + case .invalidUsernamePasswordAPI: + return NSLocalizedString("Invalid email, password, or API URL combination.", comment: "Invalid email/password/API combination.") + case .networkError: + return NSLocalizedString("Network Error. Please try later.", comment: "Network Error. Please try later.") + case .keyChainError: + return NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + case .other(let error): + return NSLocalizedString(error.localizedDescription, comment: "Other add account error") + default: + return NSLocalizedString("N/A", comment: "N/A") + } + } + + static func ==(lhs: AddAccountErrors, rhs: AddAccountErrors) -> Bool { + switch (lhs, rhs) { + case (.other(let lhsError), .other(let rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return lhs == rhs + } + } + } + + #if DEBUG + let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS, .cloudKit, .newsBlur] + #else + let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] + #endif + + // Add Accounts + @Published var selectedAddAccount: AccountType = .onMyMac + @Published var userName: String = "" + @Published var password: String = "" + @Published var apiUrl: String = "" + @Published var newLocalAccountName: String = "" + @Published var accountIsAuthenticating: Bool = false + @Published var addAccountError: AddAccountErrors = .none { + didSet { + if addAccountError == .none { + showError = false + } else { + showError = true + } + } + } + @Published var showError: Bool = false + @Published var accountAdded: Bool = false + + func resetUserEntries() { + userName = "" + password = "" + newLocalAccountName = "" + apiUrl = "" + } + + func authenticateAccount() { + switch selectedAddAccount { + case .onMyMac: + addLocalAccount() + case .cloudKit: + authenticateCloudKit() + case .feedbin: + authenticateFeedbin() + case .feedWrangler: + authenticateFeedWrangler() + case .freshRSS: + authenticateFreshRSS() + case .feedly: + authenticateFeedly() + case .newsBlur: + authenticateNewsBlur() + } + } + +} + +// MARK:- Authentication API + +extension AddAccountModel { + + private func addLocalAccount() { + let account = AccountManager.shared.createAccount(type: .onMyMac) + account.name = newLocalAccountName + accountAdded.toggle() + } + + private func authenticateFeedbin() { + accountIsAuthenticating = true + let credentials = Credentials(type: .basic, username: userName, secret: password) + + Account.validateCredentials(type: .feedbin, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.accountIsAuthenticating = false + + switch result { + case .success(let validatedCredentials): + + guard let validatedCredentials = validatedCredentials else { + self.addAccountError = .invalidUsernamePassword + return + } + + let account = AccountManager.shared.createAccount(type: .feedbin) + + do { + try account.removeCredentials(type: .basic) + try account.storeCredentials(validatedCredentials) + + account.refreshAll(completion: { result in + switch result { + case .success: + self.accountAdded.toggle() + break + case .failure(let error): + self.addAccountError = .other(error: error) + } + }) + + } catch { + self.addAccountError = .keyChainError + } + + case .failure: + self.addAccountError = .networkError + } + + } + + } + + private func authenticateFeedWrangler() { + + accountIsAuthenticating = true + let credentials = Credentials(type: .feedWranglerBasic, username: userName, secret: password) + + Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.accountIsAuthenticating = false + + switch result { + case .success(let validatedCredentials): + + guard let validatedCredentials = validatedCredentials else { + self.addAccountError = .invalidUsernamePassword + return + } + + let account = AccountManager.shared.createAccount(type: .feedWrangler) + + do { + try account.removeCredentials(type: .feedWranglerBasic) + try account.removeCredentials(type: .feedWranglerToken) + try account.storeCredentials(credentials) + try account.storeCredentials(validatedCredentials) + + account.refreshAll(completion: { result in + switch result { + case .success: + self.accountAdded.toggle() + break + case .failure(let error): + self.addAccountError = .other(error: error) + } + }) + + } catch { + self.addAccountError = .keyChainError + } + + case .failure: + self.addAccountError = .networkError + } + } + } + + private func authenticateNewsBlur() { + accountIsAuthenticating = true + let credentials = Credentials(type: .newsBlurBasic, username: userName, secret: password) + + Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.accountIsAuthenticating = false + + switch result { + case .success(let validatedCredentials): + + guard let validatedCredentials = validatedCredentials else { + self.addAccountError = .invalidUsernamePassword + return + } + + let account = AccountManager.shared.createAccount(type: .newsBlur) + + do { + try account.removeCredentials(type: .newsBlurBasic) + try account.removeCredentials(type: .newsBlurSessionId) + try account.storeCredentials(credentials) + try account.storeCredentials(validatedCredentials) + + account.refreshAll(completion: { result in + switch result { + case .success: + self.accountAdded.toggle() + break + case .failure(let error): + self.addAccountError = .other(error: error) + } + }) + + } catch { + self.addAccountError = .keyChainError + } + + case .failure: + self.addAccountError = .networkError + } + } + + } + + private func authenticateFreshRSS() { + accountIsAuthenticating = true + let credentials = Credentials(type: .readerBasic, username: userName, secret: password) + + Account.validateCredentials(type: .freshRSS, credentials: credentials, endpoint: URL(string: apiUrl)!) { [weak self] result in + + guard let self = self else { return } + + self.accountIsAuthenticating = false + + switch result { + case .success(let validatedCredentials): + + guard let validatedCredentials = validatedCredentials else { + self.addAccountError = .invalidUsernamePassword + return + } + + let account = AccountManager.shared.createAccount(type: .newsBlur) + + do { + try account.removeCredentials(type: .readerBasic) + try account.removeCredentials(type: .readerAPIKey) + try account.storeCredentials(credentials) + try account.storeCredentials(validatedCredentials) + + account.refreshAll(completion: { result in + switch result { + case .success: + self.accountAdded.toggle() + break + case .failure(let error): + self.addAccountError = .other(error: error) + } + }) + + } catch { + self.addAccountError = .keyChainError + } + + case .failure: + self.addAccountError = .networkError + } + } + } + + private func authenticateCloudKit() { + let _ = AccountManager.shared.createAccount(type: .cloudKit) + self.accountAdded.toggle() + } + + private func authenticateFeedly() { + // TBC + } + +} diff --git a/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountView.swift b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountView.swift new file mode 100644 index 000000000..22ab4b694 --- /dev/null +++ b/Multiplatform/macOS/Preferences/Preference Panes/Accounts/Add Account/AddAccountView.swift @@ -0,0 +1,138 @@ +// +// AddAccountView.swift +// Multiplatform macOS +// +// Created by Stuart Breckenridge on 13/7/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct AddAccountView: View { + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject var preferencesModel: AccountsPreferenceModel + @StateObject private var viewModel = AddAccountModel() + + var body: some View { + + VStack(alignment: .leading) { + Text("Add an Account").font(.headline) + Form { + Picker("Account Type", + selection: $viewModel.selectedAddAccount, + content: { + ForEach(0..