krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.4.0: 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}