Commit 7cc4459477
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -491,7 +491,7 @@ | |||
| 491 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 491 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 492 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 492 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 493 | CODE_SIGN_STYLE = Automatic; | 493 | CODE_SIGN_STYLE = Automatic; |
| 494 | CURRENT_PROJECT_VERSION = 14; | 494 | CURRENT_PROJECT_VERSION = 15; |
| 495 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 495 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 496 | ENABLE_PREVIEWS = YES; | 496 | ENABLE_PREVIEWS = YES; |
| 497 | GENERATE_INFOPLIST_FILE = YES; | 497 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -508,7 +508,7 @@ | |||
| 508 | "$(inherited)", | 508 | "$(inherited)", |
| 509 | "@executable_path/Frameworks", | 509 | "@executable_path/Frameworks", |
| 510 | ); | 510 | ); |
| 511 | MARKETING_VERSION = 2.5.2; | 511 | MARKETING_VERSION = 2.6.0; |
| 512 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 512 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 513 | PRODUCT_NAME = "$(TARGET_NAME)"; | 513 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 514 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 514 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -528,7 +528,7 @@ | |||
| 528 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 528 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 529 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 529 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 530 | CODE_SIGN_STYLE = Automatic; | 530 | CODE_SIGN_STYLE = Automatic; |
| 531 | CURRENT_PROJECT_VERSION = 14; | 531 | CURRENT_PROJECT_VERSION = 15; |
| 532 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 532 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 533 | ENABLE_PREVIEWS = YES; | 533 | ENABLE_PREVIEWS = YES; |
| 534 | GENERATE_INFOPLIST_FILE = YES; | 534 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -545,7 +545,7 @@ | |||
| 545 | "$(inherited)", | 545 | "$(inherited)", |
| 546 | "@executable_path/Frameworks", | 546 | "@executable_path/Frameworks", |
| 547 | ); | 547 | ); |
| 548 | MARKETING_VERSION = 2.5.2; | 548 | MARKETING_VERSION = 2.6.0; |
| 549 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 549 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 550 | PRODUCT_NAME = "$(TARGET_NAME)"; | 550 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 551 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 551 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -608,7 +608,7 @@ | |||
| 608 | APPLICATION_EXTENSION_API_ONLY = YES; | 608 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 609 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 609 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 610 | CODE_SIGN_STYLE = Automatic; | 610 | CODE_SIGN_STYLE = Automatic; |
| 611 | CURRENT_PROJECT_VERSION = 14; | 611 | CURRENT_PROJECT_VERSION = 15; |
| 612 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 612 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 613 | GENERATE_INFOPLIST_FILE = NO; | 613 | GENERATE_INFOPLIST_FILE = NO; |
| 614 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 614 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -618,7 +618,7 @@ | |||
| 618 | "@executable_path/Frameworks", | 618 | "@executable_path/Frameworks", |
| 619 | "@executable_path/../../Frameworks", | 619 | "@executable_path/../../Frameworks", |
| 620 | ); | 620 | ); |
| 621 | MARKETING_VERSION = 2.5.2; | 621 | MARKETING_VERSION = 2.6.0; |
| 622 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 622 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 623 | PRODUCT_NAME = "$(TARGET_NAME)"; | 623 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 624 | SKIP_INSTALL = YES; | 624 | SKIP_INSTALL = YES; |
| @@ -637,7 +637,7 @@ | |||
| 637 | APPLICATION_EXTENSION_API_ONLY = YES; | 637 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 638 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 638 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 639 | CODE_SIGN_STYLE = Automatic; | 639 | CODE_SIGN_STYLE = Automatic; |
| 640 | CURRENT_PROJECT_VERSION = 14; | 640 | CURRENT_PROJECT_VERSION = 15; |
| 641 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 641 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 642 | GENERATE_INFOPLIST_FILE = NO; | 642 | GENERATE_INFOPLIST_FILE = NO; |
| 643 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 643 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -647,7 +647,7 @@ | |||
| 647 | "@executable_path/Frameworks", | 647 | "@executable_path/Frameworks", |
| 648 | "@executable_path/../../Frameworks", | 648 | "@executable_path/../../Frameworks", |
| 649 | ); | 649 | ); |
| 650 | MARKETING_VERSION = 2.5.2; | 650 | MARKETING_VERSION = 2.6.0; |
| 651 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 651 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 652 | PRODUCT_NAME = "$(TARGET_NAME)"; | 652 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 653 | SKIP_INSTALL = YES; | 653 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +93 −7
| @@ -39,6 +39,14 @@ final class AppState { | |||
| 39 | authPhase == .authenticated && currentUser != nil | 39 | authPhase == .authenticated && currentUser != nil |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | // MARK: - Multi-account | ||
| 43 | |||
| 44 | /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch. | ||
| 45 | private(set) var accounts: [AccountEntry] = [] | ||
| 46 | |||
| 47 | /// The ID of the account currently in use. Persisted in UserDefaults. | ||
| 48 | private(set) var activeAccountID: String = "" | ||
| 49 | |||
| 42 | var selectedTab: Tab = .home | 50 | var selectedTab: Tab = .home |
| 43 | 51 | ||
| 44 | // MARK: - Current user (populated after successful validation) | 52 | // MARK: - Current user (populated after successful validation) |
| @@ -68,18 +76,40 @@ final class AppState { | |||
| 68 | /// Called once at app launch. If a token exists in Keychain, validates it | 76 | /// Called once at app launch. If a token exists in Keychain, validates it |
| 69 | /// silently. On failure, clears the token and falls through to unauthenticated. | 77 | /// silently. On failure, clears the token and falls through to unauthenticated. |
| 70 | func validateOnLaunch() async { | 78 | func validateOnLaunch() async { |
| 71 | guard client.hasToken else { | 79 | var storedAccounts = KeychainHelper.loadAccounts() |
| 80 | |||
| 81 | if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { | ||
| 82 | client.setToken(legacyToken) | ||
| 83 | if let user = try? await fetchMe() { | ||
| 84 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) | ||
| 85 | storedAccounts = [entry] | ||
| 86 | try? KeychainHelper.saveAccounts(storedAccounts) | ||
| 87 | try? KeychainHelper.deleteToken() | ||
| 88 | } else { | ||
| 89 | try? KeychainHelper.deleteToken() | ||
| 90 | client.setToken(nil) | ||
| 91 | authPhase = .unauthenticated | ||
| 92 | return | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | guard !storedAccounts.isEmpty else { | ||
| 72 | authPhase = .unauthenticated | 97 | authPhase = .unauthenticated |
| 73 | return | 98 | return |
| 74 | } | 99 | } |
| 75 | 100 | ||
| 101 | let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" | ||
| 102 | let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0] | ||
| 103 | |||
| 104 | client.setToken(target.token) | ||
| 76 | do { | 105 | do { |
| 77 | let user = try await fetchMe() | 106 | let user = try await fetchMe() |
| 107 | accounts = storedAccounts | ||
| 108 | activeAccountID = target.id | ||
| 78 | currentUser = user | 109 | currentUser = user |
| 79 | authPhase = .authenticated | 110 | authPhase = .authenticated |
| 80 | await refreshNeedsAttentionSnapshot() | 111 | await refreshNeedsAttentionSnapshot() |
| 81 | } catch { | 112 | } catch { |
| 82 | try? KeychainHelper.deleteToken() | ||
| 83 | client.setToken(nil) | 113 | client.setToken(nil) |
| 84 | currentUser = nil | 114 | currentUser = nil |
| 85 | authPhase = .unauthenticated | 115 | authPhase = .unauthenticated |
| @@ -92,22 +122,71 @@ final class AppState { | |||
| 92 | /// Validate a new token by querying meta.sr.ht, then persist it. | 122 | /// Validate a new token by querying meta.sr.ht, then persist it. |
| 93 | /// Throws on network/GraphQL errors so the caller can display the message. | 123 | /// Throws on network/GraphQL errors so the caller can display the message. |
| 94 | func connect(with token: String) async throws { | 124 | func connect(with token: String) async throws { |
| 95 | // Temporarily set the token so the client can use it for the request. | ||
| 96 | client.setToken(token) | 125 | client.setToken(token) |
| 97 | |||
| 98 | do { | 126 | do { |
| 99 | let user = try await fetchMe() | 127 | let user = try await fetchMe() |
| 100 | try KeychainHelper.saveToken(token) | 128 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) |
| 129 | accounts.append(entry) | ||
| 130 | activeAccountID = entry.id | ||
| 131 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | ||
| 132 | try KeychainHelper.saveAccounts(accounts) | ||
| 101 | currentUser = user | 133 | currentUser = user |
| 102 | authPhase = .authenticated | 134 | authPhase = .authenticated |
| 103 | await refreshNeedsAttentionSnapshot() | 135 | await refreshNeedsAttentionSnapshot() |
| 104 | } catch { | 136 | } catch { |
| 105 | // Roll back — don't leave an invalid token in the client. | ||
| 106 | client.setToken(nil) | 137 | client.setToken(nil) |
| 107 | throw error | 138 | throw error |
| 108 | } | 139 | } |
| 109 | } | 140 | } |
| 110 | 141 | ||
| 142 | /// Validate a new token, add it as an account, and switch to it immediately. | ||
| 143 | func addAccount(token: String) async throws { | ||
| 144 | let tempClient = SRHTClient(token: token) | ||
| 145 | let user = try await fetchMe(using: tempClient) | ||
| 146 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) | ||
| 147 | accounts.append(entry) | ||
| 148 | try KeychainHelper.saveAccounts(accounts) | ||
| 149 | try await switchAccount(to: entry.id) | ||
| 150 | } | ||
| 151 | |||
| 152 | /// Switch the active account and fully refresh the app. | ||
| 153 | func switchAccount(to id: String) async throws { | ||
| 154 | guard let entry = accounts.first(where: { $0.id == id }) else { return } | ||
| 155 | |||
| 156 | client.responseCache.clear() | ||
| 157 | currentUser = nil | ||
| 158 | pendingDeepLink = nil | ||
| 159 | pendingTabNavigation = nil | ||
| 160 | deepLinkError = nil | ||
| 161 | selectedTab = .home | ||
| 162 | |||
| 163 | authPhase = .unauthenticated | ||
| 164 | |||
| 165 | client.setToken(entry.token) | ||
| 166 | activeAccountID = entry.id | ||
| 167 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | ||
| 168 | |||
| 169 | let user = try await fetchMe() | ||
| 170 | currentUser = user | ||
| 171 | authPhase = .authenticated | ||
| 172 | await refreshNeedsAttentionSnapshot() | ||
| 173 | } | ||
| 174 | |||
| 175 | /// Remove a stored account. Switches to another account if the removed account | ||
| 176 | /// was active; signs out fully if it was the last account. | ||
| 177 | func removeAccount(id: String) async { | ||
| 178 | accounts.removeAll { $0.id == id } | ||
| 179 | try? KeychainHelper.saveAccounts(accounts) | ||
| 180 | |||
| 181 | guard id == activeAccountID else { return } | ||
| 182 | |||
| 183 | if let next = accounts.first { | ||
| 184 | try? await switchAccount(to: next.id) | ||
| 185 | } else { | ||
| 186 | await signOut() | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 111 | func signOut() async { | 190 | func signOut() async { |
| 112 | clearSessionState() | 191 | clearSessionState() |
| 113 | URLCache.shared.removeAllCachedResponses() | 192 | URLCache.shared.removeAllCachedResponses() |
| @@ -215,7 +294,11 @@ final class AppState { | |||
| 215 | } | 294 | } |
| 216 | 295 | ||
| 217 | private func fetchMe() async throws -> User { | 296 | private func fetchMe() async throws -> User { |
| 218 | let result = try await client.execute( | 297 | try await fetchMe(using: client) |
| 298 | } | ||
| 299 | |||
| 300 | private func fetchMe(using srhtClient: SRHTClient) async throws -> User { | ||
| 301 | let result = try await srhtClient.execute( | ||
| 219 | service: .meta, | 302 | service: .meta, |
| 220 | query: Self.meQuery, | 303 | query: Self.meQuery, |
| 221 | responseType: MeResponse.self | 304 | responseType: MeResponse.self |
| @@ -268,6 +351,9 @@ final class AppState { | |||
| 268 | try? KeychainHelper.deleteAll() | 351 | try? KeychainHelper.deleteAll() |
| 269 | client.setToken(nil) | 352 | client.setToken(nil) |
| 270 | client.responseCache.clear() | 353 | client.responseCache.clear() |
| 354 | accounts = [] | ||
| 355 | activeAccountID = "" | ||
| 356 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) | ||
| 271 | currentUser = nil | 357 | currentUser = nil |
| 272 | pendingDeepLink = nil | 358 | pendingDeepLink = nil |
| 273 | pendingTabNavigation = nil | 359 | pendingTabNavigation = nil |
Hutch/App/AppStorageKeys.swift +1
| @@ -1,4 +1,5 @@ | |||
| 1 | // Shared UserDefaults key constants | 1 | // Shared UserDefaults key constants |
| 2 | enum AppStorageKeys { | 2 | enum AppStorageKeys { |
| 3 | static let swipeActionsEnabled = "swipeActionsEnabled" | 3 | static let swipeActionsEnabled = "swipeActionsEnabled" |
| 4 | static let activeAccountID = "activeAccountID" | ||
| 4 | } | 5 | } |
Hutch/Extensions/KeychainHelper.swift +40
| @@ -5,6 +5,7 @@ enum KeychainHelper: Sendable { | |||
| 5 | 5 | ||
| 6 | private static let service = "net.cleberg.Hutch" | 6 | private static let service = "net.cleberg.Hutch" |
| 7 | private static let tokenAccount = "srht-access-token" | 7 | private static let tokenAccount = "srht-access-token" |
| 8 | private static let accountsAccount = "srht-accounts" | ||
| 8 | 9 | ||
| 9 | // MARK: - Save | 10 | // MARK: - Save |
| 10 | 11 | ||
| @@ -57,6 +58,45 @@ enum KeychainHelper: Sendable { | |||
| 57 | return token | 58 | return token |
| 58 | } | 59 | } |
| 59 | 60 | ||
| 61 | // MARK: - Multi-account list | ||
| 62 | |||
| 63 | static func saveAccounts(_ accounts: [AccountEntry]) throws { | ||
| 64 | let data = try JSONEncoder().encode(accounts) | ||
| 65 | |||
| 66 | let deleteQuery: [String: Any] = [ | ||
| 67 | kSecClass as String: kSecClassGenericPassword, | ||
| 68 | kSecAttrService as String: service, | ||
| 69 | kSecAttrAccount as String: accountsAccount | ||
| 70 | ] | ||
| 71 | SecItemDelete(deleteQuery as CFDictionary) | ||
| 72 | |||
| 73 | let addQuery: [String: Any] = [ | ||
| 74 | kSecClass as String: kSecClassGenericPassword, | ||
| 75 | kSecAttrService as String: service, | ||
| 76 | kSecAttrAccount as String: accountsAccount, | ||
| 77 | kSecValueData as String: data, | ||
| 78 | kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly | ||
| 79 | ] | ||
| 80 | let status = SecItemAdd(addQuery as CFDictionary, nil) | ||
| 81 | guard status == errSecSuccess else { | ||
| 82 | throw KeychainError.saveFailed(status) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | static func loadAccounts() -> [AccountEntry] { | ||
| 87 | let query: [String: Any] = [ | ||
| 88 | kSecClass as String: kSecClassGenericPassword, | ||
| 89 | kSecAttrService as String: service, | ||
| 90 | kSecAttrAccount as String: accountsAccount, | ||
| 91 | kSecReturnData as String: true, | ||
| 92 | kSecMatchLimit as String: kSecMatchLimitOne | ||
| 93 | ] | ||
| 94 | var result: AnyObject? | ||
| 95 | guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, | ||
| 96 | let data = result as? Data else { return [] } | ||
| 97 | return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? [] | ||
| 98 | } | ||
| 99 | |||
| 60 | // MARK: - Delete | 100 | // MARK: - Delete |
| 61 | 101 | ||
| 62 | static func deleteToken() throws { | 102 | static func deleteToken() throws { |
Hutch/Models/AccountEntry.swift added +8
| @@ -0,0 +1,8 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A stored sr.ht account (token + resolved username). | ||
| 4 | struct AccountEntry: Codable, Identifiable, Equatable, Sendable { | ||
| 5 | let id: String | ||
| 6 | var username: String | ||
| 7 | var token: String | ||
| 8 | } | ||
Hutch/Views/More/AccountSwitcherView.swift added +92
| @@ -0,0 +1,92 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct AccountSwitcherView: View { | ||
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @Environment(\.dismiss) private var dismiss | ||
| 6 | |||
| 7 | @State private var showAddAccount = false | ||
| 8 | @State private var isSwitching = false | ||
| 9 | @State private var switchError: String? | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | NavigationStack { | ||
| 13 | List { | ||
| 14 | Section { | ||
| 15 | ForEach(appState.accounts) { account in | ||
| 16 | Button { | ||
| 17 | guard account.id != appState.activeAccountID else { return } | ||
| 18 | switchTo(account) | ||
| 19 | } label: { | ||
| 20 | HStack { | ||
| 21 | Text(account.username) | ||
| 22 | .foregroundStyle(.primary) | ||
| 23 | Spacer() | ||
| 24 | if account.id == appState.activeAccountID { | ||
| 25 | Image(systemName: "checkmark") | ||
| 26 | .foregroundStyle(.tint) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | } | ||
| 30 | .disabled(isSwitching) | ||
| 31 | } | ||
| 32 | .onDelete { indexSet in | ||
| 33 | for index in indexSet { | ||
| 34 | let account = appState.accounts[index] | ||
| 35 | Task { await appState.removeAccount(id: account.id) } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | Section { | ||
| 41 | Button { | ||
| 42 | showAddAccount = true | ||
| 43 | } label: { | ||
| 44 | Label("Add Account", systemImage: "plus.circle") | ||
| 45 | } | ||
| 46 | .disabled(isSwitching) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | .navigationTitle("Accounts") | ||
| 50 | .navigationBarTitleDisplayMode(.inline) | ||
| 51 | .toolbar { | ||
| 52 | ToolbarItem(placement: .confirmationAction) { | ||
| 53 | Button("Done") { dismiss() } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | .overlay { | ||
| 57 | if isSwitching { | ||
| 58 | ZStack { | ||
| 59 | Color.black.opacity(0.25).ignoresSafeArea() | ||
| 60 | ProgressView("Switching…") | ||
| 61 | .padding() | ||
| 62 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | .alert("Switch Failed", isPresented: Binding( | ||
| 67 | get: { switchError != nil }, | ||
| 68 | set: { if !$0 { switchError = nil } } | ||
| 69 | )) { | ||
| 70 | Button("OK") { switchError = nil } | ||
| 71 | } message: { | ||
| 72 | Text(switchError ?? "") | ||
| 73 | } | ||
| 74 | .sheet(isPresented: $showAddAccount) { | ||
| 75 | AddAccountView() | ||
| 76 | } | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | private func switchTo(_ account: AccountEntry) { | ||
| 81 | isSwitching = true | ||
| 82 | Task { | ||
| 83 | do { | ||
| 84 | try await appState.switchAccount(to: account.id) | ||
| 85 | dismiss() | ||
| 86 | } catch { | ||
| 87 | switchError = error.localizedDescription | ||
| 88 | } | ||
| 89 | isSwitching = false | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
Hutch/Views/More/AddAccountView.swift added +59
| @@ -0,0 +1,59 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct AddAccountView: View { | ||
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @Environment(\.dismiss) private var dismiss | ||
| 6 | |||
| 7 | @State private var token = "" | ||
| 8 | @State private var isConnecting = false | ||
| 9 | @State private var errorMessage: String? | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | NavigationStack { | ||
| 13 | Form { | ||
| 14 | Section { | ||
| 15 | SecureField("Personal Access Token", text: $token) | ||
| 16 | .autocorrectionDisabled() | ||
| 17 | .textInputAutocapitalization(.never) | ||
| 18 | } footer: { | ||
| 19 | Text("Generate a token at meta.sr.ht → OAuth2 clients.") | ||
| 20 | } | ||
| 21 | |||
| 22 | if let errorMessage { | ||
| 23 | Section { | ||
| 24 | Text(errorMessage) | ||
| 25 | .foregroundStyle(.red) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | .navigationTitle("Add Account") | ||
| 30 | .navigationBarTitleDisplayMode(.inline) | ||
| 31 | .toolbar { | ||
| 32 | ToolbarItem(placement: .cancellationAction) { | ||
| 33 | Button("Cancel") { dismiss() } | ||
| 34 | .disabled(isConnecting) | ||
| 35 | } | ||
| 36 | ToolbarItem(placement: .confirmationAction) { | ||
| 37 | Button("Connect") { connect() } | ||
| 38 | .disabled(token.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | .interactiveDismissDisabled(isConnecting) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | private func connect() { | ||
| 46 | isConnecting = true | ||
| 47 | errorMessage = nil | ||
| 48 | let trimmed = token.trimmingCharacters(in: .whitespaces) | ||
| 49 | Task { | ||
| 50 | do { | ||
| 51 | try await appState.addAccount(token: trimmed) | ||
| 52 | dismiss() | ||
| 53 | } catch { | ||
| 54 | errorMessage = error.localizedDescription | ||
| 55 | } | ||
| 56 | isConnecting = false | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
Hutch/Views/More/MoreView.swift +16
| @@ -1,12 +1,16 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct MoreView: View { | 3 | struct MoreView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | |||
| 4 | private let unsupportedLinks: [(title: String, url: URL)] = [ | 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ |
| 5 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), | 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), |
| 6 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), | 8 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), |
| 7 | ("srht.site", URL(string: "https://srht.site")!) | 9 | ("srht.site", URL(string: "https://srht.site")!) |
| 8 | ] | 10 | ] |
| 9 | 11 | ||
| 12 | @State private var showAccountSwitcher = false | ||
| 13 | |||
| 10 | var body: some View { | 14 | var body: some View { |
| 11 | List { | 15 | List { |
| 12 | Section { | 16 | Section { |
| @@ -36,5 +40,17 @@ struct MoreView: View { | |||
| 36 | } | 40 | } |
| 37 | } | 41 | } |
| 38 | .navigationTitle("More") | 42 | .navigationTitle("More") |
| 43 | .toolbar { | ||
| 44 | ToolbarItem(placement: .topBarTrailing) { | ||
| 45 | Button { | ||
| 46 | showAccountSwitcher = true | ||
| 47 | } label: { | ||
| 48 | Image(systemName: "person.crop.circle") | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | .sheet(isPresented: $showAccountSwitcher) { | ||
| 53 | AccountSwitcherView() | ||
| 54 | } | ||
| 39 | } | 55 | } |
| 40 | } | 56 | } |