diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 13:09:57 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 13:09:57 -0500 |
| commit | f5f5757d0e1b78470429ae7cb9f742c95662849b (patch) | |
| tree | 758f67965a985f4f1f52a456d9b672f8256851de /Hutch/Views/Home/HomePinStore.swift | |
| parent | 0e461a382257979037e8927e5f6b468635b0a7fe (diff) | |
| download | hutch-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.swift | 207 |
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) + } +} |
