krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.7: Shared/NeedsAttentionSnapshot.swift · raw
1import Foundation
2#if canImport(WidgetKit)
3import WidgetKit
4#endif
5
6enum HutchAppGroup {
7 static let identifier = "group.net.cleberg.Hutch"
8}
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
30enum NeedsAttentionWidgetConfiguration {
31 static let kind = "NeedsAttentionWidget"
32}
33
34struct NeedsAttentionSnapshot: Codable, Sendable {
35 let unreadInboxThreads: Int?
36 let assignedOpenTickets: Int?
37 let failedBuilds: Int?
38 let updatedAt: Date
39
40 static let unavailable = NeedsAttentionSnapshot(
41 unreadInboxThreads: nil,
42 assignedOpenTickets: nil,
43 failedBuilds: nil,
44 updatedAt: .now
45 )
46
47 var hasAnyData: Bool {
48 unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil
49 }
50
51 var allCountsAreZero: Bool {
52 let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 }
53 return !counts.isEmpty && counts.allSatisfy { $0 == 0 }
54 }
55}
56
57enum NeedsAttentionSnapshotStore {
58 private static let snapshotKey = "needsAttention.snapshot"
59
60 static func load(
61 accountID: String? = ActiveAccountContextStore.load(),
62 defaults: UserDefaults? = sharedDefaults()
63 ) -> NeedsAttentionSnapshot? {
64 guard let defaults,
65 let data = defaults.data(forKey: scopedKey(for: accountID)) else {
66 return nil
67 }
68
69 return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
70 }
71
72 static func save(
73 _ snapshot: NeedsAttentionSnapshot,
74 accountID: String? = ActiveAccountContextStore.load(),
75 defaults: UserDefaults? = sharedDefaults()
76 ) {
77 guard let defaults,
78 let data = try? JSONEncoder().encode(snapshot) else {
79 return
80 }
81
82 defaults.set(data, forKey: scopedKey(for: accountID))
83 reloadWidgetTimelines()
84 }
85
86 static func update(
87 unreadInboxThreads: Int? = nil,
88 assignedOpenTickets: Int? = nil,
89 failedBuilds: Int? = nil,
90 accountID: String? = ActiveAccountContextStore.load(),
91 defaults: UserDefaults? = sharedDefaults()
92 ) {
93 let existing = load(accountID: accountID, defaults: defaults)
94 let snapshot = NeedsAttentionSnapshot(
95 unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
96 assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
97 failedBuilds: failedBuilds ?? existing?.failedBuilds,
98 updatedAt: .now
99 )
100 save(snapshot, accountID: accountID, defaults: defaults)
101 }
102
103 static func adjustUnreadInboxThreads(
104 by delta: Int,
105 accountID: String? = ActiveAccountContextStore.load(),
106 defaults: UserDefaults? = sharedDefaults()
107 ) {
108 guard let existing = load(accountID: accountID, defaults: defaults),
109 let unreadInboxThreads = existing.unreadInboxThreads else {
110 return
111 }
112
113 save(
114 NeedsAttentionSnapshot(
115 unreadInboxThreads: max(0, unreadInboxThreads + delta),
116 assignedOpenTickets: existing.assignedOpenTickets,
117 failedBuilds: existing.failedBuilds,
118 updatedAt: .now
119 ),
120 accountID: accountID,
121 defaults: defaults
122 )
123 }
124
125 static func clear(
126 accountID: String? = ActiveAccountContextStore.load(),
127 defaults: UserDefaults? = sharedDefaults()
128 ) {
129 defaults?.removeObject(forKey: scopedKey(for: accountID))
130 reloadWidgetTimelines()
131 }
132
133 private static func sharedDefaults() -> UserDefaults? {
134 UserDefaults(suiteName: HutchAppGroup.identifier)
135 }
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
142 private static func reloadWidgetTimelines() {
143 #if canImport(WidgetKit)
144 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
145 #endif
146 }
147}