Commit 899abef569

899abef569721a4f1b931c2ffdd3caad204a649a

parent: d18aa66063

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 04:19 UTC

feat: add multi-account support

Implements: https://todo.sr.ht/~ccleberg/hutch/49
Implements: https://todo.sr.ht/~ccleberg/hutch/50
Implements: https://todo.sr.ht/~ccleberg/hutch/51

Layout: unified · split

Hutch/App/AppState.swift +179 −76
@@ -35,6 +35,7 @@ final class AppState {
35 // MARK: - Authentication 35 // MARK: - Authentication
36 36
37 private(set) var authPhase: AuthPhase = .launching 37 private(set) var authPhase: AuthPhase = .launching
38 private(set) var authStatusMessage = "Connecting…"
38 39
39 /// Convenience for views that need a simple bool. 40 /// Convenience for views that need a simple bool.
40 var isAuthenticated: Bool { 41 var isAuthenticated: Bool {
@@ -57,9 +58,15 @@ final class AppState {
57 58
58 // MARK: - Networking 59 // MARK: - Networking
59 60
60 let client: SRHTClient 61 private(set) var client: SRHTClient
61 let configuration: AppConfiguration 62 let configuration: AppConfiguration
62 let systemStatusRepository: SystemStatusRepository 63 private(set) var systemStatusRepository: SystemStatusRepository
64 private var activeSession: AccountSession?
65 private(set) var sessionIdentity = UUID()
66
67 var accountDefaults: UserDefaults {
68 activeSession?.defaults ?? .standard
69 }
63 70
64 // MARK: - Deep link pending navigation 71 // MARK: - Deep link pending navigation
65 72
@@ -72,8 +79,7 @@ final class AppState {
72 79
73 init() { 80 init() {
74 self.configuration = AppConfiguration() 81 self.configuration = AppConfiguration()
75 let token = KeychainHelper.loadToken() 82 self.client = SRHTClient()
76 self.client = SRHTClient(token: token)
77 self.systemStatusRepository = SystemStatusRepository() 83 self.systemStatusRepository = SystemStatusRepository()
78 } 84 }
79 85
@@ -82,18 +88,18 @@ final class AppState {
82 /// Called once at app launch. If a token exists in Keychain, validates it 88 /// Called once at app launch. If a token exists in Keychain, validates it
83 /// silently. On failure, clears the token and falls through to unauthenticated. 89 /// silently. On failure, clears the token and falls through to unauthenticated.
84 func validateOnLaunch() async { 90 func validateOnLaunch() async {
91 authStatusMessage = "Connecting…"
85 var storedAccounts = KeychainHelper.loadAccounts() 92 var storedAccounts = KeychainHelper.loadAccounts()
86 93
87 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { 94 if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
88 client.setToken(legacyToken) 95 let legacyClient = SRHTClient(token: legacyToken)
89 if let user = try? await fetchMe() { 96 if let user = try? await fetchMe(using: legacyClient) {
90 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) 97 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
91 storedAccounts = [entry] 98 storedAccounts = [entry]
92 try? KeychainHelper.saveAccounts(storedAccounts) 99 try? KeychainHelper.saveAccounts(storedAccounts)
93 try? KeychainHelper.deleteToken() 100 try? KeychainHelper.deleteToken()
94 } else { 101 } else {
95 try? KeychainHelper.deleteToken() 102 try? KeychainHelper.deleteToken()
96 client.setToken(nil)
97 authPhase = .unauthenticated 103 authPhase = .unauthenticated
98 return 104 return
99 } 105 }
@@ -105,24 +111,29 @@ final class AppState {
105 } 111 }
106 112
107 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" 113 let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
108 let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0] 114 let orderedAccounts = prioritizedAccounts(storedAccounts, preferredID: savedID)
109 115 var invalidIDs = Set<String>()
110 client.setToken(target.token) 116
111 do { 117 for account in orderedAccounts {
112 let user = try await fetchMe() 118 do {
113 accounts = storedAccounts 119 let session = try await makeSession(for: account)
114 activeAccountID = target.id 120 let filteredAccounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
115 currentUser = user 121 accounts = filteredAccounts
116 ContributionWidgetContextStore.saveActor(user.canonicalName) 122 try? KeychainHelper.saveAccounts(filteredAccounts)
117 authPhase = .authenticated 123 activate(session)
118 await refreshNeedsAttentionSnapshot() 124 authPhase = .authenticated
119 } catch { 125 await refreshNeedsAttentionSnapshot()
120 client.setToken(nil) 126 return
121 currentUser = nil 127 } catch {
122 ContributionWidgetContextStore.clear() 128 invalidIDs.insert(account.id)
123 authPhase = .unauthenticated 129 clearAccountArtifacts(for: account.id)
124 NeedsAttentionSnapshotStore.clear() 130 }
125 } 131 }
132
133 accounts = storedAccounts.filter { !invalidIDs.contains($0.id) }
134 try? KeychainHelper.saveAccounts(accounts)
135 clearActiveSessionState()
136 authPhase = .unauthenticated
126 } 137 }
127 138
128 // MARK: - Token management 139 // MARK: - Token management
@@ -130,54 +141,40 @@ final class AppState {
130 /// Validate a new token by querying meta.sr.ht, then persist it. 141 /// Validate a new token by querying meta.sr.ht, then persist it.
131 /// Throws on network/GraphQL errors so the caller can display the message. 142 /// Throws on network/GraphQL errors so the caller can display the message.
132 func connect(with token: String) async throws { 143 func connect(with token: String) async throws {
133 client.setToken(token) 144 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
134 do { 145 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
135 let user = try await fetchMe()
136 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
137 accounts.append(entry)
138 activeAccountID = entry.id
139 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
140 try KeychainHelper.saveAccounts(accounts)
141 currentUser = user
142 ContributionWidgetContextStore.saveActor(user.canonicalName)
143 authPhase = .authenticated
144 await refreshNeedsAttentionSnapshot()
145 } catch {
146 client.setToken(nil)
147 throw error
148 }
149 } 146 }
150 147
151 /// Validate a new token, add it as an account, and switch to it immediately. 148 /// Validate a new token, add it as an account, and switch to it immediately.
152 func addAccount(token: String) async throws { 149 func addAccount(token: String) async throws {
153 let tempClient = SRHTClient(token: token) 150 let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
154 let user = try await fetchMe(using: tempClient) 151 try await addValidatedAccount(token: normalizedToken, activateNewAccount: true)
155 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
156 accounts.append(entry)
157 try KeychainHelper.saveAccounts(accounts)
158 try await switchAccount(to: entry.id)
159 } 152 }
160 153
161 /// Switch the active account and fully refresh the app. 154 /// Switch the active account and fully refresh the app.
162 func switchAccount(to id: String) async throws { 155 func switchAccount(to id: String) async throws {
163 guard let entry = accounts.first(where: { $0.id == id }) else { return } 156 guard let entry = accounts.first(where: { $0.id == id }) else { return }
157 let previousSession = activeSession
164 158
165 client.responseCache.clear() 159 authStatusMessage = "Switching Accounts…"
166 currentUser = nil 160 authPhase = .launching
167 pendingDeepLink = nil 161 sessionIdentity = UUID()
168 pendingTabNavigation = nil
169 deepLinkError = nil
170 selectedTab = .home
171
172 authPhase = .unauthenticated
173 162
174 client.setToken(entry.token) 163 do {
175 activeAccountID = entry.id 164 let session = try await makeSession(for: entry)
176 UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) 165 activate(session)
166 resetNavigationState()
167 } catch {
168 if let previousSession {
169 activate(previousSession)
170 authPhase = .authenticated
171 } else {
172 clearActiveSessionState()
173 authPhase = .unauthenticated
174 }
175 throw error
176 }
177 177
178 let user = try await fetchMe()
179 currentUser = user
180 ContributionWidgetContextStore.saveActor(user.canonicalName)
181 authPhase = .authenticated 178 authPhase = .authenticated
182 await refreshNeedsAttentionSnapshot() 179 await refreshNeedsAttentionSnapshot()
183 } 180 }
@@ -185,42 +182,51 @@ final class AppState {
185 /// Remove a stored account. Switches to another account if the removed account 182 /// Remove a stored account. Switches to another account if the removed account
186 /// was active; signs out fully if it was the last account. 183 /// was active; signs out fully if it was the last account.
187 func removeAccount(id: String) async { 184 func removeAccount(id: String) async {
185 let removedWasActive = id == activeAccountID
188 accounts.removeAll { $0.id == id } 186 accounts.removeAll { $0.id == id }
189 try? KeychainHelper.saveAccounts(accounts) 187 try? KeychainHelper.saveAccounts(accounts)
188 clearAccountArtifacts(for: id)
190 189
191 guard id == activeAccountID else { return } 190 guard removedWasActive else { return }
192 191
193 if let next = accounts.first { 192 if let next = accounts.first {
194 try? await switchAccount(to: next.id) 193 do {
194 try await switchAccount(to: next.id)
195 } catch {
196 await removeAccount(id: next.id)
197 }
195 } else { 198 } else {
196 await signOut() 199 await signOut()
197 } 200 }
198 } 201 }
199 202
200 func signOut() async { 203 func signOut() async {
201 clearSessionState() 204 clearActiveSessionState()
205 try? KeychainHelper.deleteAll()
202 URLCache.shared.removeAllCachedResponses() 206 URLCache.shared.removeAllCachedResponses()
203 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } 207 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
204 await clearWebData() 208 await clearWebData()
205 clearWebContentRenderCaches() 209 clearWebContentRenderCaches()
206 NeedsAttentionSnapshotStore.clear() 210 clearAllAccountArtifacts()
207 SystemStatusWidgetSnapshotStore.clear()
208 authPhase = .unauthenticated 211 authPhase = .unauthenticated
209 selectedTab = .home 212 selectedTab = .home
210 } 213 }
211 214
212 func resetAppData() async { 215 func resetAppData() async {
213 clearSessionState() 216 clearActiveSessionState()
214 217
215 if let bundleIdentifier = Bundle.main.bundleIdentifier { 218 if let bundleIdentifier = Bundle.main.bundleIdentifier {
216 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) 219 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
217 } 220 }
221 for account in accounts {
222 AccountDefaultsStore.clear(accountID: account.id)
223 }
224 try? KeychainHelper.deleteAll()
218 URLCache.shared.removeAllCachedResponses() 225 URLCache.shared.removeAllCachedResponses()
219 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } 226 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
220 await clearWebData() 227 await clearWebData()
221 clearWebContentRenderCaches() 228 clearWebContentRenderCaches()
222 NeedsAttentionSnapshotStore.clear() 229 clearAllAccountArtifacts()
223 SystemStatusWidgetSnapshotStore.clear()
224 230
225 authPhase = .unauthenticated 231 authPhase = .unauthenticated
226 selectedTab = .home 232 selectedTab = .home
@@ -395,31 +401,117 @@ final class AppState {
395 let tracker: TrackerSummary 401 let tracker: TrackerSummary
396 } 402 }
397 403
398 private func clearSessionState() { 404 private func addValidatedAccount(token: String, activateNewAccount: Bool) async throws {
399 try? KeychainHelper.deleteAll() 405 let tempClient = SRHTClient(token: token)
400 client.setToken(nil) 406 let user = try await fetchMe(using: tempClient)
401 client.responseCache.clear() 407
402 accounts = [] 408 if let existing = accounts.first(where: {
409 $0.username.caseInsensitiveCompare(user.username) == .orderedSame || $0.token == token
410 }) {
411 _ = existing
412 throw AppStateError.duplicateAccount(username: user.username)
413 }
414
415 let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
416 accounts.append(entry)
417 try KeychainHelper.saveAccounts(accounts)
418
419 guard activateNewAccount else { return }
420 let session = try await makeSession(for: entry, knownUser: user)
421 authStatusMessage = "Switching Accounts…"
422 authPhase = .launching
423 sessionIdentity = UUID()
424 activate(session)
425 resetNavigationState()
426 authPhase = .authenticated
427 await refreshNeedsAttentionSnapshot()
428 }
429
430 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
431 let sessionClient = SRHTClient(token: account.token)
432 let user: User
433 if let knownUser {
434 user = knownUser
435 } else {
436 user = try await fetchMe(using: sessionClient)
437 }
438 let defaults = AccountDefaultsStore.userDefaults(for: account.id)
439 let repository = SystemStatusRepository(cacheStore: SystemStatusCacheStore(defaults: defaults))
440 return AccountSession(
441 account: account,
442 user: user,
443 client: sessionClient,
444 defaults: defaults,
445 systemStatusRepository: repository
446 )
447 }
448
449 private func activate(_ session: AccountSession) {
450 activeSession = session
451 client = session.client
452 systemStatusRepository = session.systemStatusRepository
453 currentUser = session.user
454 activeAccountID = session.account.id
455 UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
456 ActiveAccountContextStore.save(session.account.id)
457 ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
458 authStatusMessage = "Connecting…"
459 }
460
461 private func clearActiveSessionState() {
462 client = SRHTClient()
463 systemStatusRepository = SystemStatusRepository()
464 activeSession = nil
403 activeAccountID = "" 465 activeAccountID = ""
404 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) 466 UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
467 ActiveAccountContextStore.clear()
405 currentUser = nil 468 currentUser = nil
406 ContributionWidgetContextStore.clear() 469 sessionIdentity = UUID()
470 resetNavigationState()
471 }
472
473 private func resetNavigationState() {
407 pendingDeepLink = nil 474 pendingDeepLink = nil
408 pendingTabNavigation = nil 475 pendingTabNavigation = nil
409 deepLinkError = nil 476 deepLinkError = nil
410 selectedTab = .home 477 selectedTab = .home
411 } 478 }
412 479
480 private func clearAccountArtifacts(for accountID: String) {
481 AccountDefaultsStore.clear(accountID: accountID)
482 ContributionWidgetContextStore.clear(accountID: accountID)
483 NeedsAttentionSnapshotStore.clear(accountID: accountID)
484 SystemStatusWidgetSnapshotStore.clear(accountID: accountID)
485 }
486
487 private func clearAllAccountArtifacts() {
488 for account in accounts {
489 clearAccountArtifacts(for: account.id)
490 }
491 ContributionWidgetContextStore.clear(accountID: nil)
492 NeedsAttentionSnapshotStore.clear(accountID: nil)
493 SystemStatusWidgetSnapshotStore.clear(accountID: nil)
494 ActiveAccountContextStore.clear()
495 accounts = []
496 }
497
498 private func prioritizedAccounts(_ accounts: [AccountEntry], preferredID: String) -> [AccountEntry] {
499 guard let preferred = accounts.first(where: { $0.id == preferredID }) else { return accounts }
500 return [preferred] + accounts.filter { $0.id != preferredID }
501 }
502
413 private func refreshNeedsAttentionSnapshot() async { 503 private func refreshNeedsAttentionSnapshot() async {
414 guard let currentUser else { 504 guard let currentUser else {
415 NeedsAttentionSnapshotStore.clear() 505 NeedsAttentionSnapshotStore.clear(accountID: activeAccountID)
416 return 506 return
417 } 507 }
418 508
419 let viewModel = HomeViewModel( 509 let viewModel = HomeViewModel(
420 currentUser: currentUser, 510 currentUser: currentUser,
421 client: client, 511 client: client,
422 systemStatusRepository: systemStatusRepository 512 systemStatusRepository: systemStatusRepository,
513 defaults: accountDefaults,
514 accountID: activeAccountID
423 ) 515 )
424 await viewModel.loadDashboard() 516 await viewModel.loadDashboard()
425 } 517 }
@@ -434,3 +526,14 @@ final class AppState {
434 } 526 }
435 } 527 }
436} 528}
529
530enum AppStateError: LocalizedError {
531 case duplicateAccount(username: String)
532
533 var errorDescription: String? {
534 switch self {
535 case .duplicateAccount(let username):
536 "The account ~\(username) is already saved."
537 }
538 }
539}
Hutch/App/HutchApp.swift +2 −2
@@ -4,8 +4,8 @@ import SwiftUI
4struct HutchApp: App { 4struct HutchApp: App {
5 @State private var appState = AppState() 5 @State private var appState = AppState()
6 @State private var networkMonitor = NetworkMonitor() 6 @State private var networkMonitor = NetworkMonitor()
7 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system 7 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
8 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard 8 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
9 9
10 var body: some Scene { 10 var body: some Scene {
11 WindowGroup { 11 WindowGroup {
Hutch/App/RootView.swift +14 −7
@@ -10,6 +10,7 @@ struct RootView: View {
10 @State private var buildsPath = NavigationPath() 10 @State private var buildsPath = NavigationPath()
11 @State private var ticketsPath = NavigationPath() 11 @State private var ticketsPath = NavigationPath()
12 @State private var isResolvingDeepLink = false 12 @State private var isResolvingDeepLink = false
13 @State private var hasValidatedLaunch = false
13 14
14 var body: some View { 15 var body: some View {
15 @Bindable var appState = appState 16 @Bindable var appState = appState
@@ -17,8 +18,10 @@ struct RootView: View {
17 Group { 18 Group {
18 switch appState.authPhase { 19 switch appState.authPhase {
19 case .launching: 20 case .launching:
20 ProgressView("Connecting…") 21 ProgressView(appState.authStatusMessage)
21 .task { 22 .task {
23 guard !hasValidatedLaunch else { return }
24 hasValidatedLaunch = true
22 await appState.validateOnLaunch() 25 await appState.validateOnLaunch()
23 } 26 }
24 27
@@ -113,6 +116,8 @@ struct RootView: View {
113 Label("More", systemImage: "ellipsis.circle") 116 Label("More", systemImage: "ellipsis.circle")
114 } 117 }
115 } 118 }
119 .id(appState.sessionIdentity)
120 .defaultAppStorage(appState.accountDefaults)
116 .modifier(SidebarAdaptableTabStyle()) 121 .modifier(SidebarAdaptableTabStyle())
117 .modifier(TabKeyboardShortcuts(selectedTab: Binding( 122 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
118 get: { appState.selectedTab }, 123 get: { appState.selectedTab },
@@ -315,6 +320,8 @@ enum MoreRoute: Hashable {
315} 320}
316 321
317private struct MoreNavigationRoot: View { 322private struct MoreNavigationRoot: View {
323 @Environment(AppState.self) private var appState
324
318 var body: some View { 325 var body: some View {
319 MoreView() 326 MoreView()
320 .navigationDestination(for: MoreRoute.self) { route in 327 .navigationDestination(for: MoreRoute.self) { route in
@@ -339,16 +346,16 @@ private struct MoreNavigationRoot: View {
339 ThreadDetailView( 346 ThreadDetailView(
340 thread: thread, 347 thread: thread,
341 onViewed: { 348 onViewed: {
342 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 349 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
343 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 350 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
344 }, 351 },
345 onMarkRead: { 352 onMarkRead: {
346 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 353 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
347 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 354 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
348 }, 355 },
349 onMarkUnread: { 356 onMarkUnread: {
350 InboxReadStateStore.markUnread(for: thread.id) 357 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
351 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 358 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
352 } 359 }
353 ) 360 )
354 case .manPageBrowser: 361 case .manPageBrowser:
Hutch/Hutch/App/AccountSession.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3struct AccountSession: Sendable {
4 let account: AccountEntry
5 let user: User
6 let client: SRHTClient
7 let defaults: UserDefaults
8 let systemStatusRepository: SystemStatusRepository
9
10 var id: String {
11 account.id
12 }
13}
14
15enum AccountDefaultsStore {
16 private static let suitePrefix = "net.cleberg.Hutch.account"
17
18 static func userDefaults(for accountID: String) -> UserDefaults {
19 let suiteName = suiteName(for: accountID)
20 return UserDefaults(suiteName: suiteName) ?? .standard
21 }
22
23 static func clear(accountID: String) {
24 let suiteName = suiteName(for: accountID)
25 guard let defaults = UserDefaults(suiteName: suiteName) else { return }
26 defaults.removePersistentDomain(forName: suiteName)
27 }
28
29 private static func suiteName(for accountID: String) -> String {
30 "\(suitePrefix).\(accountID)"
31 }
32}
Hutch/Views/Builds/BuildListView.swift +2 −2
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3struct BuildListView: View { 3struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" 6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
7 @Environment(AppState.self) private var appState 7 @Environment(AppState.self) private var appState
@@ -104,7 +104,7 @@ struct BuildListView: View {
104 } 104 }
105 .task { 105 .task {
106 if viewModel == nil { 106 if viewModel == nil {
107 let vm = BuildListViewModel(client: appState.client) 107 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
108 vm.repoFilter = savedRepoFilter 108 vm.repoFilter = savedRepoFilter
109 viewModel = vm 109 viewModel = vm
110 await vm.loadJobs() 110 await vm.loadJobs()
Hutch/Views/Home/HomeView.swift +5 −3
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3struct HomeView: View { 3struct HomeView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true 5 @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
6 @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true 6 @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
7 @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true 7 @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@@ -39,7 +39,9 @@ struct HomeView: View {
39 let newViewModel = HomeViewModel( 39 let newViewModel = HomeViewModel(
40 currentUser: currentUser, 40 currentUser: currentUser,
41 client: appState.client, 41 client: appState.client,
42 systemStatusRepository: appState.systemStatusRepository 42 systemStatusRepository: appState.systemStatusRepository,
43 defaults: appState.accountDefaults,
44 accountID: appState.activeAccountID
43 ) 45 )
44 viewModel = newViewModel 46 viewModel = newViewModel
45 vm = newViewModel 47 vm = newViewModel
@@ -834,7 +836,7 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
834 836
835private struct HomeAssignedTicketsListView: View { 837private struct HomeAssignedTicketsListView: View {
836 let viewModel: HomeViewModel 838 let viewModel: HomeViewModel
837 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 839 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
838 840
839 var body: some View { 841 var body: some View {
840 List { 842 List {
Hutch/Views/Home/HomeViewModel.swift +21 −8
@@ -321,11 +321,22 @@ final class HomeViewModel {
321 } 321 }
322 """ 322 """
323 323
324 init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) { 324 private let defaults: UserDefaults
325 private let accountID: String
326
327 init(
328 currentUser: User,
329 client: SRHTClient,
330 systemStatusRepository: SystemStatusRepository,
331 defaults: UserDefaults,
332 accountID: String
333 ) {
325 self.currentUser = currentUser 334 self.currentUser = currentUser
326 self.client = client 335 self.client = client
327 self.systemStatusRepository = systemStatusRepository 336 self.systemStatusRepository = systemStatusRepository
328 self.projectService = ProjectService(client: client) 337 self.projectService = ProjectService(client: client)
338 self.defaults = defaults
339 self.accountID = accountID
329 } 340 }
330 341
331 func loadDashboard() async { 342 func loadDashboard() async {
@@ -403,7 +414,7 @@ final class HomeViewModel {
403 } 414 }
404 415
405 var pinnedProjects: [Project] { 416 var pinnedProjects: [Project] {
406 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey) 417 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults)
407 guard !pinnedIDs.isEmpty else { return [] } 418 guard !pinnedIDs.isEmpty else { return [] }
408 419
409 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) 420 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -411,7 +422,7 @@ final class HomeViewModel {
411 } 422 }
412 423
413 var hasPinnedProjects: Bool { 424 var hasPinnedProjects: Bool {
414 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty 425 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty
415 } 426 }
416 427
417 var failedBuildCount: Int { 428 var failedBuildCount: Int {
@@ -592,7 +603,7 @@ final class HomeViewModel {
592 } 603 }
593 604
594 func markInboxThreadRead(_ thread: InboxThreadSummary) { 605 func markInboxThreadRead(_ thread: InboxThreadSummary) {
595 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 606 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
596 unreadInboxThreads.removeAll { $0.id == thread.id } 607 unreadInboxThreads.removeAll { $0.id == thread.id }
597 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) 608 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
598 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 609 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
@@ -600,7 +611,7 @@ final class HomeViewModel {
600 } 611 }
601 612
602 func markInboxThreadUnread(_ thread: InboxThreadSummary) { 613 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
603 InboxReadStateStore.markUnread(for: thread.id) 614 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
604 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { 615 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
605 unreadInboxThreads.append( 616 unreadInboxThreads.append(
606 InboxThreadSummary( 617 InboxThreadSummary(
@@ -786,7 +797,8 @@ final class HomeViewModel {
786 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), 797 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
787 isUnread: InboxReadStateStore.isUnread( 798 isUnread: InboxReadStateStore.isUnread(
788 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", 799 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
789 lastActivityAt: thread.updated 800 lastActivityAt: thread.updated,
801 defaults: defaults
790 ) 802 )
791 ) 803 )
792 return summary.isUnread ? summary : nil 804 return summary.isUnread ? summary : nil
@@ -944,7 +956,8 @@ final class HomeViewModel {
944 }.count 956 }.count
945 : nil, 957 : nil,
946 updatedAt: .now 958 updatedAt: .now
947 ) 959 ),
960 accountID: accountID
948 ) 961 )
949 } 962 }
950 963
@@ -966,7 +979,7 @@ final class HomeViewModel {
966 bannerSummary: snapshot.bannerSummary, 979 bannerSummary: snapshot.bannerSummary,
967 updatedAt: .now 980 updatedAt: .now
968 ) 981 )
969 SystemStatusWidgetSnapshotStore.save(widgetSnapshot) 982 SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
970 } 983 }
971 984
972 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { 985 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
Hutch/Views/Inbox/InboxView.swift +5 −1
@@ -22,7 +22,11 @@ struct InboxView: View {
22 if let viewModel { 22 if let viewModel {
23 vm = viewModel 23 vm = viewModel
24 } else { 24 } else {
25 let newViewModel = InboxViewModel(client: appState.client) 25 let newViewModel = InboxViewModel(
26 client: appState.client,
27 defaults: appState.accountDefaults,
28 accountID: appState.activeAccountID
29 )
26 viewModel = newViewModel 30 viewModel = newViewModel
27 vm = newViewModel 31 vm = newViewModel
28 } 32 }
Hutch/Views/Inbox/InboxViewModel.swift +13 −9
@@ -72,6 +72,8 @@ final class InboxViewModel {
72 var searchText = "" 72 var searchText = ""
73 73
74 private let client: SRHTClient 74 private let client: SRHTClient
75 private let defaults: UserDefaults
76 private let accountID: String
75 private let listThreadFetchLimit = 10 77 private let listThreadFetchLimit = 10
76 private let listFetchConcurrencyLimit = 4 78 private let listFetchConcurrencyLimit = 4
77 79
@@ -121,8 +123,10 @@ final class InboxViewModel {
121 } 123 }
122 """ 124 """
123 125
124 init(client: SRHTClient) { 126 init(client: SRHTClient, defaults: UserDefaults, accountID: String) {
125 self.client = client 127 self.client = client
128 self.defaults = defaults
129 self.accountID = accountID
126 } 130 }
127 131
128 func loadThreads() async { 132 func loadThreads() async {
@@ -143,7 +147,7 @@ final class InboxViewModel {
143 } 147 }
144 return lhs.lastActivityAt > rhs.lastActivityAt 148 return lhs.lastActivityAt > rhs.lastActivityAt
145 } 149 }
146 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) 150 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
147 } catch { 151 } catch {
148 inboxListLogger.error("Inbox request failed") 152 inboxListLogger.error("Inbox request failed")
149 self.error = "Failed to load inbox" 153 self.error = "Failed to load inbox"
@@ -152,9 +156,9 @@ final class InboxViewModel {
152 156
153 func markThreadRead(_ thread: InboxThreadSummary) { 157 func markThreadRead(_ thread: InboxThreadSummary) {
154 let viewedAt = max(Date(), thread.lastActivityAt) 158 let viewedAt = max(Date(), thread.lastActivityAt)
155 InboxReadStateStore.markViewed(viewedAt, for: thread.id) 159 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
156 threads.removeAll { $0.id == thread.id } 160 threads.removeAll { $0.id == thread.id }
157 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 161 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
158 } 162 }
159 163
160 func markAllThreadsRead() { 164 func markAllThreadsRead() {
@@ -162,17 +166,17 @@ final class InboxViewModel {
162 166
163 let viewedAt = Date() 167 let viewedAt = Date()
164 for thread in threads where thread.isUnread { 168 for thread in threads where thread.isUnread {
165 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id) 169 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
166 } 170 }
167 171
168 threads.removeAll { $0.isUnread } 172 threads.removeAll { $0.isUnread }
169 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) 173 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
170 } 174 }
171 175
172 func markThreadUnread(_ thread: InboxThreadSummary) { 176 func markThreadUnread(_ thread: InboxThreadSummary) {
173 InboxReadStateStore.markUnread(for: thread.id) 177 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
174 updateThread(thread, isUnread: true) 178 updateThread(thread, isUnread: true)
175 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 179 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
176 } 180 }
177 181
178 func toggleThreadReadState(_ thread: InboxThreadSummary) { 182 func toggleThreadReadState(_ thread: InboxThreadSummary) {
@@ -291,7 +295,7 @@ final class InboxViewModel {
291 295
292 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in 296 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
293 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())" 297 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
294 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated) 298 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults)
295 return InboxThreadSummary( 299 return InboxThreadSummary(
296 rootEmailID: thread.root.id, 300 rootEmailID: thread.root.id,
297 rootMessageID: thread.root.messageID, 301 rootMessageID: thread.root.messageID,
Hutch/Views/Lookup/LookupView.swift +7 −7
@@ -339,7 +339,7 @@ struct LookupView: View {
339 .navigationTitle("Look Up") 339 .navigationTitle("Look Up")
340 .task { 340 .task {
341 if viewModel == nil { 341 if viewModel == nil {
342 viewModel = LookupViewModel(client: appState.client, appState: appState) 342 viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults)
343 } 343 }
344 } 344 }
345 } 345 }
@@ -440,16 +440,16 @@ struct LookupView: View {
440 ThreadDetailView( 440 ThreadDetailView(
441 thread: thread, 441 thread: thread,
442 onViewed: { 442 onViewed: {
443 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 443 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
444 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 444 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
445 }, 445 },
446 onMarkRead: { 446 onMarkRead: {
447 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 447 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
448 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 448 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
449 }, 449 },
450 onMarkUnread: { 450 onMarkUnread: {
451 InboxReadStateStore.markUnread(for: thread.id) 451 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
452 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 452 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
453 } 453 }
454 ) 454 )
455 case .manPageBrowser: 455 case .manPageBrowser:
Hutch/Views/Lookup/UserProfileView.swift +1 −1
@@ -2,7 +2,7 @@ import SwiftUI
2 2
3struct UserProfileView: View { 3struct UserProfileView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true 5 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
6 6
7 let user: User 7 let user: User
8 @State private var profileViewModel: UserProfileViewModel? 8 @State private var profileViewModel: UserProfileViewModel?
Hutch/Views/More/AccountSwitcherView.swift +42 −7
@@ -7,21 +7,35 @@ struct AccountSwitcherView: View {
7 @State private var showAddAccount = false 7 @State private var showAddAccount = false
8 @State private var isSwitching = false 8 @State private var isSwitching = false
9 @State private var switchError: String? 9 @State private var switchError: String?
10 @State private var pendingRemoval: AccountEntry?
10 11
11 var body: some View { 12 var body: some View {
12 NavigationStack { 13 NavigationStack {
13 List { 14 List {
14 Section { 15 Section {
15 ForEach(appState.accounts) { account in 16 ForEach(appState.accounts) { account in
17 let isActive = account.id == appState.activeAccountID
16 Button { 18 Button {
17 guard account.id != appState.activeAccountID else { return } 19 guard !isActive else { return }
18 switchTo(account) 20 switchTo(account)
19 } label: { 21 } label: {
20 HStack { 22 HStack(spacing: 12) {
21 Text("~\(account.username)") 23 if isActive {
22 .foregroundStyle(.primary) 24 Image(systemName: "person.crop.circle.fill")
25 .foregroundStyle(.tint)
26 } else {
27 Image(systemName: "person.crop.circle")
28 .foregroundStyle(.secondary)
29 }
30 VStack(alignment: .leading, spacing: 2) {
31 Text("~\(account.username)")
32 .foregroundStyle(.primary)
33 Text(isActive ? "Active Account" : "Tap to switch")
34 .font(.caption)
35 .foregroundStyle(.secondary)
36 }
23 Spacer() 37 Spacer()
24 if account.id == appState.activeAccountID { 38 if isActive {
25 Image(systemName: "checkmark") 39 Image(systemName: "checkmark")
26 .foregroundStyle(.tint) 40 .foregroundStyle(.tint)
27 } 41 }
@@ -31,8 +45,7 @@ struct AccountSwitcherView: View {
31 } 45 }
32 .onDelete { indexSet in 46 .onDelete { indexSet in
33 for index in indexSet { 47 for index in indexSet {
34 let account = appState.accounts[index] 48 pendingRemoval = appState.accounts[index]
35 Task { await appState.removeAccount(id: account.id) }
36 } 49 }
37 } 50 }
38 } 51 }
@@ -71,6 +84,28 @@ struct AccountSwitcherView: View {
71 } message: { 84 } message: {
72 Text(switchError ?? "") 85 Text(switchError ?? "")
73 } 86 }
87 .alert(
88 "Remove Account?",
89 isPresented: Binding(
90 get: { pendingRemoval != nil },
91 set: { isPresented in
92 if !isPresented {
93 pendingRemoval = nil
94 }
95 }
96 )
97 ) {
98 Button("Cancel", role: .cancel) {}
99 Button("Remove", role: .destructive) {
100 guard let pendingRemoval else { return }
101 Task { await appState.removeAccount(id: pendingRemoval.id) }
102 self.pendingRemoval = nil
103 }
104 } message: {
105 if let pendingRemoval {
106 Text("~\(pendingRemoval.username) and its isolated local cache will be removed from this device.")
107 }
108 }
74 .sheet(isPresented: $showAddAccount) { 109 .sheet(isPresented: $showAddAccount) {
75 AddAccountView() 110 AddAccountView()
76 } 111 }
Hutch/Views/More/ProfileView.swift +1 −1
@@ -7,7 +7,7 @@ private let profileBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
7 7
8struct ProfileView: View { 8struct ProfileView: View {
9 @Environment(AppState.self) private var appState 9 @Environment(AppState.self) private var appState
10 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true 10 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
11 @State private var viewModel: SettingsViewModel? 11 @State private var viewModel: SettingsViewModel?
12 @State private var contributionViewModel: UserProfileViewModel? 12 @State private var contributionViewModel: UserProfileViewModel?
13 @State private var pendingDestructiveAction: ProfileDestructiveAction? 13 @State private var pendingDestructiveAction: ProfileDestructiveAction?
Hutch/Views/Pastes/PasteListView.swift +1 −1
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3struct PasteListView: View { 3struct PasteListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @Environment(AppState.self) private var appState 5 @Environment(AppState.self) private var appState
6 @State private var viewModel: PasteListViewModel? 6 @State private var viewModel: PasteListViewModel?
7 @State private var showCreatePasteSheet = false 7 @State private var showCreatePasteSheet = false
Hutch/Views/Projects/ProjectDetailView.swift +2 −2
@@ -22,7 +22,7 @@ struct ProjectDetailView: View {
22 private var isPinnedToHome: Bool { 22 private var isPinnedToHome: Bool {
23 _ = pinChangeCount 23 _ = pinChangeCount
24 guard let currentUserKey else { return false } 24 guard let currentUserKey else { return false }
25 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey) 25 return ProjectPinStore.isPinned(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
26 } 26 }
27 27
28 var body: some View { 28 var body: some View {
@@ -243,7 +243,7 @@ struct ProjectDetailView: View {
243 243
244 private func togglePinnedState() { 244 private func togglePinnedState() {
245 guard let currentUserKey else { return } 245 guard let currentUserKey else { return }
246 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey) 246 ProjectPinStore.togglePin(projectID: displayedProject.id, for: currentUserKey, defaults: appState.accountDefaults)
247 pinChangeCount += 1 247 pinChangeCount += 1
248 } 248 }
249 249
Hutch/Views/Projects/ProjectMailingListView.swift +22 −13
@@ -36,6 +36,8 @@ final class MailingListDetailViewModel {
36 36
37 private let mailingList: InboxMailingListReference 37 private let mailingList: InboxMailingListReference
38 private let client: SRHTClient 38 private let client: SRHTClient
39 private let defaults: UserDefaults
40 private let accountID: String
39 41
40 private static let listThreadsQuery = """ 42 private static let listThreadsQuery = """
41 query projectMailingListThreads($rid: ID!) { 43 query projectMailingListThreads($rid: ID!) {
@@ -57,9 +59,11 @@ final class MailingListDetailViewModel {
57 } 59 }
58 """ 60 """
59 61
60 init(mailingList: InboxMailingListReference, client: SRHTClient) { 62 init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
61 self.mailingList = mailingList 63 self.mailingList = mailingList
62 self.client = client 64 self.client = client
65 self.defaults = defaults
66 self.accountID = accountID
63 } 67 }
64 68
65 var filteredThreads: [InboxThreadSummary] { 69 var filteredThreads: [InboxThreadSummary] {
@@ -90,15 +94,15 @@ final class MailingListDetailViewModel {
90 94
91 func markThreadRead(_ thread: InboxThreadSummary) { 95 func markThreadRead(_ thread: InboxThreadSummary) {
92 let viewedAt = max(Date(), thread.lastActivityAt) 96 let viewedAt = max(Date(), thread.lastActivityAt)
93 InboxReadStateStore.markViewed(viewedAt, for: thread.id) 97 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
94 updateThread(thread, isUnread: false) 98 updateThread(thread, isUnread: false)
95 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 99 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
96 } 100 }
97 101
98 func markThreadUnread(_ thread: InboxThreadSummary) { 102 func markThreadUnread(_ thread: InboxThreadSummary) {
99 InboxReadStateStore.markUnread(for: thread.id) 103 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
100 updateThread(thread, isUnread: true) 104 updateThread(thread, isUnread: true)
101 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
102 } 106 }
103 107
104 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { 108 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
@@ -124,7 +128,7 @@ final class MailingListDetailViewModel {
124 messageCount: thread.replies + 1, 128 messageCount: thread.replies + 1,
125 repo: nil, 129 repo: nil,
126 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), 130 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
127 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) 131 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
128 ) 132 )
129 } 133 }
130 134
@@ -238,7 +242,12 @@ struct MailingListDetailView: View {
238 .navigationBarTitleDisplayMode(.inline) 242 .navigationBarTitleDisplayMode(.inline)
239 .task { 243 .task {
240 if viewModel == nil { 244 if viewModel == nil {
241 let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client) 245 let viewModel = MailingListDetailViewModel(
246 mailingList: mailingList,
247 client: appState.client,
248 defaults: appState.accountDefaults,
249 accountID: appState.activeAccountID
250 )
242 self.viewModel = viewModel 251 self.viewModel = viewModel
243 await viewModel.loadThreads() 252 await viewModel.loadThreads()
244 } 253 }
@@ -261,16 +270,16 @@ struct MailingListDetailView: View {
261 ThreadDetailView( 270 ThreadDetailView(
262 thread: thread, 271 thread: thread,
263 onViewed: { 272 onViewed: {
264 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 273 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
265 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 274 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
266 }, 275 },
267 onMarkRead: { 276 onMarkRead: {
268 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) 277 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
269 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) 278 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
270 }, 279 },
271 onMarkUnread: { 280 onMarkUnread: {
272 InboxReadStateStore.markUnread(for: thread.id) 281 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
273 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) 282 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
274 } 283 }
275 ) 284 )
276 } label: { 285 } label: {
Hutch/Views/Repositories/RepositoryListView.swift +1 −1
@@ -59,7 +59,7 @@ struct RepositoryListView: View {
59 } 59 }
60 .task { 60 .task {
61 if viewModel == nil { 61 if viewModel == nil {
62 viewModel = RepositoryListViewModel(client: appState.client) 62 viewModel = RepositoryListViewModel(client: appState.client, defaults: appState.accountDefaults)
63 } 63 }
64 } 64 }
65 } 65 }
Hutch/Views/Settings/SettingsView.swift +24 −6
@@ -2,10 +2,10 @@ import SwiftUI
2 2
3struct SettingsView: View { 3struct SettingsView: View {
4 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.appTheme) private var appTheme: AppTheme = .system 5 @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
6 @AppStorage(AppStorageKeys.displayDensity) private var displayDensity: DisplayDensity = .standard 6 @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
8 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true 8 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
9 @State private var pendingDestructiveAction: SettingsDestructiveAction? 9 @State private var pendingDestructiveAction: SettingsDestructiveAction?
10 10
11 var body: some View { 11 var body: some View {
@@ -89,7 +89,25 @@ struct SettingsView: View {
89 HStack { 89 HStack {
90 Image(systemName: "key.fill") 90 Image(systemName: "key.fill")
91 .foregroundStyle(.secondary) 91 .foregroundStyle(.secondary)
92 Text("Personal access token in use") 92 VStack(alignment: .leading, spacing: 2) {
93 Text(appState.currentUser?.canonicalName ?? "No active account")
94 Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
95 .font(.caption)
96 .foregroundStyle(.secondary)
97 }
98 }
99 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
100
101 NavigationLink {
102 AccountSwitcherView()
103 } label: {
104 Label("Manage Accounts", systemImage: "person.2")
105 }
106
107 HStack {
108 Image(systemName: "lock.shield")
109 .foregroundStyle(.secondary)
110 Text("Tokens are stored separately per account in the iOS keychain")
93 .font(.subheadline) 111 .font(.subheadline)
94 .foregroundStyle(.secondary) 112 .foregroundStyle(.secondary)
95 } 113 }
@@ -105,7 +123,7 @@ struct SettingsView: View {
105 } header: { 123 } header: {
106 Text("Authentication") 124 Text("Authentication")
107 } footer: { 125 } footer: {
108 Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.") 126 Text("Account switching keeps local caches and saved state isolated per account. Sign Out removes all saved accounts from this device. Reset App Data also clears local settings, cached responses, cookies, and embedded web data.")
109 } 127 }
110 } 128 }
111 129
Hutch/Views/Tickets/TicketListView.swift +3 −2
@@ -4,7 +4,7 @@ struct TicketListView: View {
4 let onTrackerUpdated: (TrackerSummary) -> Void 4 let onTrackerUpdated: (TrackerSummary) -> Void
5 let onTrackerDeleted: (TrackerSummary) -> Void 5 let onTrackerDeleted: (TrackerSummary) -> Void
6 6
7 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true 7 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
8 @Environment(AppState.self) private var appState 8 @Environment(AppState.self) private var appState
9 @Environment(\.dismiss) private var dismiss 9 @Environment(\.dismiss) private var dismiss
10 @State private var tracker: TrackerSummary 10 @State private var tracker: TrackerSummary
@@ -232,7 +232,8 @@ struct TicketListView: View {
232 trackerName: tracker.name, 232 trackerName: tracker.name,
233 trackerId: tracker.id, 233 trackerId: tracker.id,
234 trackerRid: tracker.rid, 234 trackerRid: tracker.rid,
235 client: appState.client 235 client: appState.client,
236 defaults: appState.accountDefaults
236 ) 237 )
237 viewModel = vm 238 viewModel = vm
238 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) 239 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
HutchTests/HutchIntentsTests.swift +29
@@ -72,4 +72,33 @@ struct HutchIntentsTests {
72 #expect(loaded?.unreadInboxThreads == 5) 72 #expect(loaded?.unreadInboxThreads == 5)
73 #expect(loaded?.assignedOpenTickets == 3) 73 #expect(loaded?.assignedOpenTickets == 3)
74 } 74 }
75
76 @Test
77 func needsAttentionSnapshotsAreIsolatedPerAccount() {
78 let defaultsName = "HutchIntentsTests-builds-isolation-\(UUID().uuidString)"
79 let defaults = UserDefaults(suiteName: defaultsName)!
80 defer { defaults.removePersistentDomain(forName: defaultsName) }
81
82 let firstSnapshot = NeedsAttentionSnapshot(
83 unreadInboxThreads: 1,
84 assignedOpenTickets: 2,
85 failedBuilds: 3,
86 updatedAt: Date(timeIntervalSince1970: 100)
87 )
88 let secondSnapshot = NeedsAttentionSnapshot(
89 unreadInboxThreads: 8,
90 assignedOpenTickets: 5,
91 failedBuilds: 1,
92 updatedAt: Date(timeIntervalSince1970: 200)
93 )
94
95 NeedsAttentionSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
96 NeedsAttentionSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
97
98 #expect(NeedsAttentionSnapshotStore.load(accountID: "account-a", defaults: defaults)?.unreadInboxThreads == 1)
99 #expect(NeedsAttentionSnapshotStore.load(accountID: "account-b", defaults: defaults)?.unreadInboxThreads == 8)
100
101 ActiveAccountContextStore.save("account-a", defaults: defaults)
102 #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
103 }
75} 104}
HutchTests/SystemStatusWidgetSnapshotTests.swift +31
@@ -64,6 +64,37 @@ struct SystemStatusWidgetSnapshotTests {
64 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) 64 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil)
65 } 65 }
66 66
67 @Test
68 func snapshotsAreIsolatedPerAccount() {
69 let defaultsName = "SystemStatusWidgetSnapshotTests-isolation-\(UUID().uuidString)"
70 let defaults = UserDefaults(suiteName: defaultsName)!
71 defer { defaults.removePersistentDomain(forName: defaultsName) }
72
73 let firstSnapshot = SystemStatusWidgetSnapshot(
74 services: [.init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false)],
75 hasDisruption: false,
76 overallStatusText: "All monitored services operational",
77 bannerSummary: "",
78 updatedAt: Date(timeIntervalSince1970: 100)
79 )
80 let secondSnapshot = SystemStatusWidgetSnapshot(
81 services: [.init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true)],
82 hasDisruption: true,
83 overallStatusText: "Experiencing disruptions",
84 bannerSummary: "builds.sr.ht disrupted",
85 updatedAt: Date(timeIntervalSince1970: 200)
86 )
87
88 SystemStatusWidgetSnapshotStore.save(firstSnapshot, accountID: "account-a", defaults: defaults)
89 SystemStatusWidgetSnapshotStore.save(secondSnapshot, accountID: "account-b", defaults: defaults)
90
91 #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-a", defaults: defaults)?.services.first?.name == "git.sr.ht")
92 #expect(SystemStatusWidgetSnapshotStore.load(accountID: "account-b", defaults: defaults)?.services.first?.name == "builds.sr.ht")
93
94 ActiveAccountContextStore.save("account-b", defaults: defaults)
95 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults)?.bannerSummary == "builds.sr.ht disrupted")
96 }
97
67 @Test 98 @Test
68 func unavailableSnapshotHasEmptyServices() { 99 func unavailableSnapshotHasEmptyServices() {
69 let snapshot = SystemStatusWidgetSnapshot.unavailable 100 let snapshot = SystemStatusWidgetSnapshot.unavailable
Shared/ContributionWidgetContext.swift +21 −6
@@ -11,8 +11,11 @@ enum ContributionWidgetContextStore {
11 private static let actorKey = "contributionWidget.actor" 11 private static let actorKey = "contributionWidget.actor"
12 private static let enabledKey = "contributionWidget.enabled" 12 private static let enabledKey = "contributionWidget.enabled"
13 13
14 static func loadActor(defaults: UserDefaults? = sharedDefaults()) -> String? { 14 static func loadActor(
15 defaults?.string(forKey: actorKey) 15 accountID: String? = ActiveAccountContextStore.load(),
16 defaults: UserDefaults? = sharedDefaults()
17 ) -> String? {
18 defaults?.string(forKey: scopedActorKey(for: accountID))
16 } 19 }
17 20
18 static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool { 21 static func isEnabled(defaults: UserDefaults? = sharedDefaults()) -> Bool {
@@ -24,13 +27,20 @@ enum ContributionWidgetContextStore {
24 reloadWidgetTimelines() 27 reloadWidgetTimelines()
25 } 28 }
26 29
27 static func saveActor(_ actor: String, defaults: UserDefaults? = sharedDefaults()) { 30 static func saveActor(
28 defaults?.set(actor, forKey: actorKey) 31 _ actor: String,
32 accountID: String? = ActiveAccountContextStore.load(),
33 defaults: UserDefaults? = sharedDefaults()
34 ) {
35 defaults?.set(actor, forKey: scopedActorKey(for: accountID))
29 reloadWidgetTimelines() 36 reloadWidgetTimelines()
30 } 37 }
31 38
32 static func clear(defaults: UserDefaults? = sharedDefaults()) { 39 static func clear(
33 defaults?.removeObject(forKey: actorKey) 40 accountID: String? = ActiveAccountContextStore.load(),
41 defaults: UserDefaults? = sharedDefaults()
42 ) {
43 defaults?.removeObject(forKey: scopedActorKey(for: accountID))
34 reloadWidgetTimelines() 44 reloadWidgetTimelines()
35 } 45 }
36 46
@@ -38,6 +48,11 @@ enum ContributionWidgetContextStore {
38 UserDefaults(suiteName: HutchAppGroup.identifier) 48 UserDefaults(suiteName: HutchAppGroup.identifier)
39 } 49 }
40 50
51 private static func scopedActorKey(for accountID: String?) -> String {
52 guard let accountID, !accountID.isEmpty else { return actorKey }
53 return "\(actorKey).\(accountID)"
54 }
55
41 private static func reloadWidgetTimelines() { 56 private static func reloadWidgetTimelines() {
42 #if canImport(WidgetKit) 57 #if canImport(WidgetKit)
43 WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind) 58 WidgetCenter.shared.reloadTimelines(ofKind: ContributionGraphWidgetConfiguration.kind)
Shared/NeedsAttentionSnapshot.swift +47 −9
@@ -7,6 +7,26 @@ enum HutchAppGroup {
7 static let identifier = "group.net.cleberg.Hutch" 7 static let identifier = "group.net.cleberg.Hutch"
8} 8}
9 9
10enum ActiveAccountContextStore {
11 private static let activeAccountIDKey = "activeAccount.id"
12
13 static func load(defaults: UserDefaults? = sharedDefaults()) -> String? {
14 defaults?.string(forKey: activeAccountIDKey)
15 }
16
17 static func save(_ accountID: String, defaults: UserDefaults? = sharedDefaults()) {
18 defaults?.set(accountID, forKey: activeAccountIDKey)
19 }
20
21 static func clear(defaults: UserDefaults? = sharedDefaults()) {
22 defaults?.removeObject(forKey: activeAccountIDKey)
23 }
24
25 private static func sharedDefaults() -> UserDefaults? {
26 UserDefaults(suiteName: HutchAppGroup.identifier)
27 }
28}
29
10enum NeedsAttentionWidgetConfiguration { 30enum NeedsAttentionWidgetConfiguration {
11 static let kind = "NeedsAttentionWidget" 31 static let kind = "NeedsAttentionWidget"
12} 32}
@@ -37,22 +57,29 @@ struct NeedsAttentionSnapshot: Codable, Sendable {
37enum NeedsAttentionSnapshotStore { 57enum NeedsAttentionSnapshotStore {
38 private static let snapshotKey = "needsAttention.snapshot" 58 private static let snapshotKey = "needsAttention.snapshot"
39 59
40 static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? { 60 static func load(
61 accountID: String? = ActiveAccountContextStore.load(),
62 defaults: UserDefaults? = sharedDefaults()
63 ) -> NeedsAttentionSnapshot? {
41 guard let defaults, 64 guard let defaults,
42 let data = defaults.data(forKey: snapshotKey) else { 65 let data = defaults.data(forKey: scopedKey(for: accountID)) else {
43 return nil 66 return nil
44 } 67 }
45 68
46 return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data) 69 return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
47 } 70 }
48 71
49 static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) { 72 static func save(
73 _ snapshot: NeedsAttentionSnapshot,
74 accountID: String? = ActiveAccountContextStore.load(),
75 defaults: UserDefaults? = sharedDefaults()
76 ) {
50 guard let defaults, 77 guard let defaults,
51 let data = try? JSONEncoder().encode(snapshot) else { 78 let data = try? JSONEncoder().encode(snapshot) else {
52 return 79 return
53 } 80 }
54 81
55 defaults.set(data, forKey: snapshotKey) 82 defaults.set(data, forKey: scopedKey(for: accountID))
56 reloadWidgetTimelines() 83 reloadWidgetTimelines()
57 } 84 }
58 85
@@ -60,23 +87,25 @@ enum NeedsAttentionSnapshotStore {
60 unreadInboxThreads: Int? = nil, 87 unreadInboxThreads: Int? = nil,
61 assignedOpenTickets: Int? = nil, 88 assignedOpenTickets: Int? = nil,
62 failedBuilds: Int? = nil, 89 failedBuilds: Int? = nil,
90 accountID: String? = ActiveAccountContextStore.load(),
63 defaults: UserDefaults? = sharedDefaults() 91 defaults: UserDefaults? = sharedDefaults()
64 ) { 92 ) {
65 let existing = load(defaults: defaults) 93 let existing = load(accountID: accountID, defaults: defaults)
66 let snapshot = NeedsAttentionSnapshot( 94 let snapshot = NeedsAttentionSnapshot(
67 unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads, 95 unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
68 assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets, 96 assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
69 failedBuilds: failedBuilds ?? existing?.failedBuilds, 97 failedBuilds: failedBuilds ?? existing?.failedBuilds,
70 updatedAt: .now 98 updatedAt: .now
71 ) 99 )
72 save(snapshot, defaults: defaults) 100 save(snapshot, accountID: accountID, defaults: defaults)
73 } 101 }
74 102
75 static func adjustUnreadInboxThreads( 103 static func adjustUnreadInboxThreads(
76 by delta: Int, 104 by delta: Int,
105 accountID: String? = ActiveAccountContextStore.load(),
77 defaults: UserDefaults? = sharedDefaults() 106 defaults: UserDefaults? = sharedDefaults()
78 ) { 107 ) {
79 guard let existing = load(defaults: defaults), 108 guard let existing = load(accountID: accountID, defaults: defaults),
80 let unreadInboxThreads = existing.unreadInboxThreads else { 109 let unreadInboxThreads = existing.unreadInboxThreads else {
81 return 110 return
82 } 111 }
@@ -88,12 +117,16 @@ enum NeedsAttentionSnapshotStore {
88 failedBuilds: existing.failedBuilds, 117 failedBuilds: existing.failedBuilds,
89 updatedAt: .now 118 updatedAt: .now
90 ), 119 ),
120 accountID: accountID,
91 defaults: defaults 121 defaults: defaults
92 ) 122 )
93 } 123 }
94 124
95 static func clear(defaults: UserDefaults? = sharedDefaults()) { 125 static func clear(
96 defaults?.removeObject(forKey: snapshotKey) 126 accountID: String? = ActiveAccountContextStore.load(),
127 defaults: UserDefaults? = sharedDefaults()
128 ) {
129 defaults?.removeObject(forKey: scopedKey(for: accountID))
97 reloadWidgetTimelines() 130 reloadWidgetTimelines()
98 } 131 }
99 132
@@ -101,6 +134,11 @@ enum NeedsAttentionSnapshotStore {
101 UserDefaults(suiteName: HutchAppGroup.identifier) 134 UserDefaults(suiteName: HutchAppGroup.identifier)
102 } 135 }
103 136
137 private static func scopedKey(for accountID: String?) -> String {
138 guard let accountID, !accountID.isEmpty else { return snapshotKey }
139 return "\(snapshotKey).\(accountID)"
140 }
141
104 private static func reloadWidgetTimelines() { 142 private static func reloadWidgetTimelines() {
105 #if canImport(WidgetKit) 143 #if canImport(WidgetKit)
106 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) 144 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
Shared/SystemStatusWidgetSnapshot.swift +21 −6
@@ -33,25 +33,35 @@ struct SystemStatusWidgetSnapshot: Codable, Sendable {
33enum SystemStatusWidgetSnapshotStore { 33enum SystemStatusWidgetSnapshotStore {
34 private static let snapshotKey = "systemStatus.widgetSnapshot" 34 private static let snapshotKey = "systemStatus.widgetSnapshot"
35 35
36 static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? { 36 static func load(
37 accountID: String? = ActiveAccountContextStore.load(),
38 defaults: UserDefaults? = sharedDefaults()
39 ) -> SystemStatusWidgetSnapshot? {
37 guard let defaults, 40 guard let defaults,
38 let data = defaults.data(forKey: snapshotKey) else { 41 let data = defaults.data(forKey: scopedKey(for: accountID)) else {
39 return nil 42 return nil
40 } 43 }
41 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) 44 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
42 } 45 }
43 46
44 static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) { 47 static func save(
48 _ snapshot: SystemStatusWidgetSnapshot,
49 accountID: String? = ActiveAccountContextStore.load(),
50 defaults: UserDefaults? = sharedDefaults()
51 ) {
45 guard let defaults, 52 guard let defaults,
46 let data = try? JSONEncoder().encode(snapshot) else { 53 let data = try? JSONEncoder().encode(snapshot) else {
47 return 54 return
48 } 55 }
49 defaults.set(data, forKey: snapshotKey) 56 defaults.set(data, forKey: scopedKey(for: accountID))
50 reloadWidgetTimelines() 57 reloadWidgetTimelines()
51 } 58 }
52 59
53 static func clear(defaults: UserDefaults? = sharedDefaults()) { 60 static func clear(
54 defaults?.removeObject(forKey: snapshotKey) 61 accountID: String? = ActiveAccountContextStore.load(),
62 defaults: UserDefaults? = sharedDefaults()
63 ) {
64 defaults?.removeObject(forKey: scopedKey(for: accountID))
55 reloadWidgetTimelines() 65 reloadWidgetTimelines()
56 } 66 }
57 67
@@ -59,6 +69,11 @@ enum SystemStatusWidgetSnapshotStore {
59 UserDefaults(suiteName: HutchAppGroup.identifier) 69 UserDefaults(suiteName: HutchAppGroup.identifier)
60 } 70 }
61 71
72 private static func scopedKey(for accountID: String?) -> String {
73 guard let accountID, !accountID.isEmpty else { return snapshotKey }
74 return "\(snapshotKey).\(accountID)"
75 }
76
62 private static func reloadWidgetTimelines() { 77 private static func reloadWidgetTimelines() {
63 #if canImport(WidgetKit) 78 #if canImport(WidgetKit)
64 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) 79 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)