summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home/HomePinStore.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
committerChristian Cleberg <[email protected]>2026-04-13 13:09:57 -0500
commitf5f5757d0e1b78470429ae7cb9f742c95662849b (patch)
tree758f67965a985f4f1f52a456d9b672f8256851de /Hutch/Views/Home/HomePinStore.swift
parent0e461a382257979037e8927e5f6b468635b0a7fe (diff)
downloadhutch-3.0.0.tar.gz
hutch-3.0.0.tar.bz2
hutch-3.0.0.zip
release: v3.0.0 — navigation overhaul, Work view, and multi-pin supportv3.0.0
Reworks the app's core navigation and home screen for v3.0.0: - Replace Inbox with Work view: unified dashboard showing unread threads and assigned tickets, with All/Unread/Assigned scope picker - Overhaul Home screen: redesigned with pinned items grid (trackers, repos, mailing lists, users), recent activity section, and system status banner; backed by HomePinStore supporting all resource types - Simplify navigation bars across list screens: default toolbar shows only the primary action (+) and an overflow menu (…); pin, share, and select moved into the overflow menu; selection mode gets its own nav bar with Cancel / "N Selected" / All - Consolidate repo detail toolbars: pin and share moved into the existing actions menu for both Git and Mercurial detail views - Add recent activity tracking via RecentActivityStore - Add work and inbox deep link aliases (hutch://work, hutch://inbox) Implements: https://todo.sr.ht/~ccleberg/hutch/55
Diffstat (limited to 'Hutch/Views/Home/HomePinStore.swift')
-rw-r--r--Hutch/Views/Home/HomePinStore.swift207
1 files changed, 207 insertions, 0 deletions
diff --git a/Hutch/Views/Home/HomePinStore.swift b/Hutch/Views/Home/HomePinStore.swift
new file mode 100644
index 0000000..e9bffd1
--- /dev/null
+++ b/Hutch/Views/Home/HomePinStore.swift
@@ -0,0 +1,207 @@
+import Foundation
+
+enum HomePinKind: String, Codable, Sendable {
+ case project
+ case repository
+ case tracker
+ case mailingList
+ case user
+}
+
+struct HomePinRecord: Codable, Hashable, Identifiable, Sendable {
+ let kind: HomePinKind
+ let value: String
+ let title: String
+ let subtitle: String
+ let ownerUsername: String?
+ let service: SRHTService?
+
+ var id: String {
+ switch kind {
+ case .project:
+ return "\(kind.rawValue):\(value)"
+ case .repository:
+ return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)"
+ case .tracker, .mailingList:
+ return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)"
+ case .user:
+ return "\(kind.rawValue):\(ownerUsername ?? value)"
+ }
+ }
+
+ static func project(_ project: Project) -> HomePinRecord {
+ HomePinRecord(
+ kind: .project,
+ value: project.id,
+ title: project.displayName,
+ subtitle: "Project",
+ ownerUsername: nil,
+ service: nil
+ )
+ }
+
+ static func repository(_ repository: RepositorySummary) -> HomePinRecord {
+ HomePinRecord(
+ kind: .repository,
+ value: repository.name,
+ title: repository.name,
+ subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo",
+ ownerUsername: repository.owner.canonicalName.srhtUsername,
+ service: repository.service
+ )
+ }
+
+ static func tracker(_ tracker: TrackerSummary) -> HomePinRecord {
+ HomePinRecord(
+ kind: .tracker,
+ value: tracker.name,
+ title: tracker.name,
+ subtitle: "Tracker",
+ ownerUsername: tracker.owner.canonicalName.srhtUsername,
+ service: nil
+ )
+ }
+
+ static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord {
+ HomePinRecord(
+ kind: .mailingList,
+ value: mailingList.rid,
+ title: mailingList.name,
+ subtitle: "Mailing List",
+ ownerUsername: mailingList.owner.canonicalName.srhtUsername,
+ service: nil
+ )
+ }
+
+ static func user(_ user: User) -> HomePinRecord {
+ HomePinRecord(
+ kind: .user,
+ value: user.username,
+ title: user.canonicalName,
+ subtitle: "User",
+ ownerUsername: user.username,
+ service: nil
+ )
+ }
+}
+
+enum HomePinStore {
+ static func loadPins(
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> [HomePinRecord] {
+ let normalizedUserKey = normalizedUserKey(userKey)
+ guard !normalizedUserKey.isEmpty else { return [] }
+
+ let storedPins = loadAll(defaults: defaults)
+ if let storedPinsForUser = storedPins[normalizedUserKey] {
+ return normalizedPins(storedPinsForUser)
+ }
+
+ let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults)
+ guard !legacyProjectIDs.isEmpty else { return [] }
+
+ let migratedPins = legacyProjectIDs.map {
+ HomePinRecord(
+ kind: .project,
+ value: $0,
+ title: "Pinned Project",
+ subtitle: "Project",
+ ownerUsername: nil,
+ service: nil
+ )
+ }
+ savePins(migratedPins, for: normalizedUserKey, defaults: defaults)
+ return migratedPins
+ }
+
+ static func isPinned(
+ _ pin: HomePinRecord,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> Bool {
+ loadPins(for: userKey, defaults: defaults).contains(pin)
+ }
+
+ static func togglePin(
+ _ pin: HomePinRecord,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) {
+ let normalizedUserKey = normalizedUserKey(userKey)
+ guard !normalizedUserKey.isEmpty else { return }
+
+ var pinsByUser = loadAll(defaults: defaults)
+ var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
+
+ if let index = pins.firstIndex(of: pin) {
+ pins.remove(at: index)
+ } else {
+ pins.append(pin)
+ }
+
+ pinsByUser[normalizedUserKey] = pins
+ saveAll(pinsByUser, defaults: defaults)
+ }
+
+ static func pinnedProjectIDs(
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) -> [String] {
+ loadPins(for: userKey, defaults: defaults)
+ .filter { $0.kind == .project }
+ .map(\.value)
+ }
+
+ private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] {
+ guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else {
+ return [:]
+ }
+
+ let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:]
+ return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in
+ let key = normalizedUserKey(entry.key)
+ guard !key.isEmpty else { return }
+ let pins = normalizedPins(entry.value)
+ if !pins.isEmpty {
+ result[key] = pins
+ }
+ }
+ }
+
+ private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) {
+ var allPins = loadAll(defaults: defaults)
+ allPins[userKey] = normalizedPins(pins)
+ saveAll(allPins, defaults: defaults)
+ }
+
+ private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(pinsByUser) else { return }
+ defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems)
+ }
+
+ private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] {
+ var seen = Set<String>()
+ return pins.compactMap { pin in
+ let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
+ let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil }
+
+ let normalized = HomePinRecord(
+ kind: pin.kind,
+ value: value,
+ title: title,
+ subtitle: subtitle,
+ ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines),
+ service: pin.service
+ )
+ guard seen.insert(normalized.id).inserted else { return nil }
+ return normalized
+ }
+ }
+
+ private static func normalizedUserKey(_ userKey: String) -> String {
+ userKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}