Commit 8fc97b98ae

8fc97b98aeac7ae0c8922dbdb0f4208fd66ef3cd

parent: 707a3efe1e

Unsigned

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

release: v3.0.0 — navigation overhaul, Work view, and multi-pin support

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

Layout: unified · split

Hutch/App/AppStorageKeys.swift +2
@@ -5,12 +5,14 @@ enum AppStorageKeys {
5 static let activeAccountID = "activeAccountID" 5 static let activeAccountID = "activeAccountID"
6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines" 6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
7 static let lookupHistory = "lookupHistory" 7 static let lookupHistory = "lookupHistory"
8 static let recentActivity = "recentActivity"
8 static let scopedSearchHistory = "scopedSearchHistory" 9 static let scopedSearchHistory = "scopedSearchHistory"
9 static let hutchStatsBaseURL = "hutchStatsBaseURL" 10 static let hutchStatsBaseURL = "hutchStatsBaseURL"
10 static let systemStatusSnapshotCache = "systemStatusSnapshotCache" 11 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
11 static let systemStatusIncidentCache = "systemStatusIncidentCache" 12 static let systemStatusIncidentCache = "systemStatusIncidentCache"
12 static let homeProjectsExpanded = "homeProjectsExpanded" 13 static let homeProjectsExpanded = "homeProjectsExpanded"
13 static let pinnedHomeProjects = "pinnedHomeProjects" 14 static let pinnedHomeProjects = "pinnedHomeProjects"
15 static let pinnedHomeItems = "pinnedHomeItems"
14 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" 16 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
15 static let homeBuildsExpanded = "homeBuildsExpanded" 17 static let homeBuildsExpanded = "homeBuildsExpanded"
16 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" 18 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
Hutch/App/DeepLink.swift +4
@@ -3,6 +3,7 @@ import Foundation
3/// Represents a parsed `hutch://` deep link. 3/// Represents a parsed `hutch://` deep link.
4enum DeepLink: Equatable { 4enum DeepLink: Equatable {
5 case home 5 case home
6 case work
6 /// hutch://git/<owner>/<repo> 7 /// hutch://git/<owner>/<repo>
7 case repository(owner: String, repo: String) 8 case repository(owner: String, repo: String)
8 /// hutch://todo/<owner>/<tracker>/<ticketId> 9 /// hutch://todo/<owner>/<tracker>/<ticketId>
@@ -33,6 +34,9 @@ enum DeepLink: Equatable {
33 case "home", nil: 34 case "home", nil:
34 self = .home 35 self = .home
35 36
37 case "work", "inbox":
38 self = .work
39
36 case "git" where components.count >= 3: 40 case "git" where components.count >= 3:
37 let owner = components[1] 41 let owner = components[1]
38 let repo = components[2] 42 let repo = components[2]
Hutch/App/HutchApp.swift +1 −1
@@ -26,7 +26,7 @@ struct HutchApp: App {
26 let link: DeepLink 26 let link: DeepLink
27 switch destination { 27 switch destination {
28 case .home: link = .home 28 case .home: link = .home
29 case .inbox: link = .home 29 case .work: link = .work
30 case .builds: link = .buildsTab 30 case .builds: link = .buildsTab
31 case .repositories: link = .repositoriesTab 31 case .repositories: link = .repositoriesTab
32 case .trackers: link = .trackersTab 32 case .trackers: link = .trackersTab
Hutch/App/HutchIntents.swift +2 −2
@@ -5,7 +5,7 @@ import Foundation
5 5
6enum HutchDestination: String, AppEnum { 6enum HutchDestination: String, AppEnum {
7 case home 7 case home
8 case inbox 8 case work
9 case builds 9 case builds
10 case repositories 10 case repositories
11 case trackers 11 case trackers
@@ -16,7 +16,7 @@ enum HutchDestination: String, AppEnum {
16 16
17 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ 17 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
18 .home: "Home", 18 .home: "Home",
19 .inbox: "Inbox", 19 .work: "Work",
20 .builds: "Builds", 20 .builds: "Builds",
21 .repositories: "Repositories", 21 .repositories: "Repositories",
22 .trackers: "Trackers", 22 .trackers: "Trackers",
Hutch/App/RootView.swift +14
@@ -69,6 +69,12 @@ struct RootView: View {
69 return TabView(selection: $appState.selectedTab) { 69 return TabView(selection: $appState.selectedTab) {
70 NavigationStack(path: $homePath) { 70 NavigationStack(path: $homePath) {
71 HomeView() 71 HomeView()
72 .navigationDestination(for: HomeRoute.self) { route in
73 switch route {
74 case .work:
75 WorkView()
76 }
77 }
72 } 78 }
73 .tag(AppState.Tab.home) 79 .tag(AppState.Tab.home)
74 .tabItem { 80 .tabItem {
@@ -196,6 +202,14 @@ struct RootView: View {
196 case .ticket(let owner, let tracker, let ticketId): 202 case .ticket(let owner, let tracker, let ticketId):
197 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) 203 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
198 204
205 case .work:
206 homePath = NavigationPath()
207 appState.selectedTab = .home
208 Task {
209 await settleNavigationTransition()
210 homePath.append(HomeRoute.work)
211 }
212
199 case .buildsTab: 213 case .buildsTab:
200 buildsPath = NavigationPath() 214 buildsPath = NavigationPath()
201 appState.selectedTab = .builds 215 appState.selectedTab = .builds
Hutch/Views/Builds/BuildDetailView.swift +17
@@ -331,6 +331,13 @@ struct BuildDetailView: View {
331 } 331 }
332 } 332 }
333 } 333 }
334 .task(id: job.id) {
335 RecentActivityStore.recordBuild(
336 jobId: job.id,
337 title: recentActivityTitle(for: job),
338 defaults: appState.accountDefaults
339 )
340 }
334 .refreshable { 341 .refreshable {
335 await reloadDetail(viewModel) 342 await reloadDetail(viewModel)
336 } 343 }
@@ -367,6 +374,16 @@ struct BuildDetailView: View {
367 await viewModel.loadJob() 374 await viewModel.loadJob()
368 } 375 }
369 } 376 }
377
378 private func recentActivityTitle(for job: JobDetail) -> String {
379 if let note = job.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
380 return note
381 }
382 if !job.tags.isEmpty {
383 return job.tags.joined(separator: ", ")
384 }
385 return "Job #\(job.id)"
386 }
370} 387}
371 388
372private struct BuildArtifactRow: View { 389private struct BuildArtifactRow: View {
Hutch/Views/Home/HomePinStore.swift added +207
@@ -0,0 +1,207 @@
1import Foundation
2
3enum HomePinKind: String, Codable, Sendable {
4 case project
5 case repository
6 case tracker
7 case mailingList
8 case user
9}
10
11struct HomePinRecord: Codable, Hashable, Identifiable, Sendable {
12 let kind: HomePinKind
13 let value: String
14 let title: String
15 let subtitle: String
16 let ownerUsername: String?
17 let service: SRHTService?
18
19 var id: String {
20 switch kind {
21 case .project:
22 return "\(kind.rawValue):\(value)"
23 case .repository:
24 return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)"
25 case .tracker, .mailingList:
26 return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)"
27 case .user:
28 return "\(kind.rawValue):\(ownerUsername ?? value)"
29 }
30 }
31
32 static func project(_ project: Project) -> HomePinRecord {
33 HomePinRecord(
34 kind: .project,
35 value: project.id,
36 title: project.displayName,
37 subtitle: "Project",
38 ownerUsername: nil,
39 service: nil
40 )
41 }
42
43 static func repository(_ repository: RepositorySummary) -> HomePinRecord {
44 HomePinRecord(
45 kind: .repository,
46 value: repository.name,
47 title: repository.name,
48 subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo",
49 ownerUsername: repository.owner.canonicalName.srhtUsername,
50 service: repository.service
51 )
52 }
53
54 static func tracker(_ tracker: TrackerSummary) -> HomePinRecord {
55 HomePinRecord(
56 kind: .tracker,
57 value: tracker.name,
58 title: tracker.name,
59 subtitle: "Tracker",
60 ownerUsername: tracker.owner.canonicalName.srhtUsername,
61 service: nil
62 )
63 }
64
65 static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord {
66 HomePinRecord(
67 kind: .mailingList,
68 value: mailingList.rid,
69 title: mailingList.name,
70 subtitle: "Mailing List",
71 ownerUsername: mailingList.owner.canonicalName.srhtUsername,
72 service: nil
73 )
74 }
75
76 static func user(_ user: User) -> HomePinRecord {
77 HomePinRecord(
78 kind: .user,
79 value: user.username,
80 title: user.canonicalName,
81 subtitle: "User",
82 ownerUsername: user.username,
83 service: nil
84 )
85 }
86}
87
88enum HomePinStore {
89 static func loadPins(
90 for userKey: String,
91 defaults: UserDefaults = .standard
92 ) -> [HomePinRecord] {
93 let normalizedUserKey = normalizedUserKey(userKey)
94 guard !normalizedUserKey.isEmpty else { return [] }
95
96 let storedPins = loadAll(defaults: defaults)
97 if let storedPinsForUser = storedPins[normalizedUserKey] {
98 return normalizedPins(storedPinsForUser)
99 }
100
101 let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults)
102 guard !legacyProjectIDs.isEmpty else { return [] }
103
104 let migratedPins = legacyProjectIDs.map {
105 HomePinRecord(
106 kind: .project,
107 value: $0,
108 title: "Pinned Project",
109 subtitle: "Project",
110 ownerUsername: nil,
111 service: nil
112 )
113 }
114 savePins(migratedPins, for: normalizedUserKey, defaults: defaults)
115 return migratedPins
116 }
117
118 static func isPinned(
119 _ pin: HomePinRecord,
120 for userKey: String,
121 defaults: UserDefaults = .standard
122 ) -> Bool {
123 loadPins(for: userKey, defaults: defaults).contains(pin)
124 }
125
126 static func togglePin(
127 _ pin: HomePinRecord,
128 for userKey: String,
129 defaults: UserDefaults = .standard
130 ) {
131 let normalizedUserKey = normalizedUserKey(userKey)
132 guard !normalizedUserKey.isEmpty else { return }
133
134 var pinsByUser = loadAll(defaults: defaults)
135 var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
136
137 if let index = pins.firstIndex(of: pin) {
138 pins.remove(at: index)
139 } else {
140 pins.append(pin)
141 }
142
143 pinsByUser[normalizedUserKey] = pins
144 saveAll(pinsByUser, defaults: defaults)
145 }
146
147 static func pinnedProjectIDs(
148 for userKey: String,
149 defaults: UserDefaults = .standard
150 ) -> [String] {
151 loadPins(for: userKey, defaults: defaults)
152 .filter { $0.kind == .project }
153 .map(\.value)
154 }
155
156 private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] {
157 guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else {
158 return [:]
159 }
160
161 let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:]
162 return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in
163 let key = normalizedUserKey(entry.key)
164 guard !key.isEmpty else { return }
165 let pins = normalizedPins(entry.value)
166 if !pins.isEmpty {
167 result[key] = pins
168 }
169 }
170 }
171
172 private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) {
173 var allPins = loadAll(defaults: defaults)
174 allPins[userKey] = normalizedPins(pins)
175 saveAll(allPins, defaults: defaults)
176 }
177
178 private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) {
179 guard let data = try? JSONEncoder().encode(pinsByUser) else { return }
180 defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems)
181 }
182
183 private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] {
184 var seen = Set<String>()
185 return pins.compactMap { pin in
186 let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines)
187 let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
188 let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines)
189 guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil }
190
191 let normalized = HomePinRecord(
192 kind: pin.kind,
193 value: value,
194 title: title,
195 subtitle: subtitle,
196 ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines),
197 service: pin.service
198 )
199 guard seen.insert(normalized.id).inserted else { return nil }
200 return normalized
201 }
202 }
203
204 private static func normalizedUserKey(_ userKey: String) -> String {
205 userKey.trimmingCharacters(in: .whitespacesAndNewlines)
206 }
207}
Hutch/Views/Home/HomePrototypeView.swift added +7
@@ -0,0 +1,7 @@
1import SwiftUI
2
3struct HomePrototypeView: View {
4 var body: some View {
5 HomeView()
6 }
7}
Hutch/Views/Home/HomeView.swift +381 −735
@@ -1,15 +1,13 @@
1import SwiftUI 1import SwiftUI
2 2
3struct HomeView: View { 3struct HomeView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
6 @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
7 @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
8 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
9 @Environment(\.scenePhase) private var scenePhase 5 @Environment(\.scenePhase) private var scenePhase
10 @State private var viewModel: HomeViewModel? 6 @State private var viewModel: HomeViewModel?
11 private let previewLimit = 4 7 @State private var recentItems: [RecentActivityEntry] = []
12 private let projectPreviewLimit = 3 8 @State private var isOpeningRecentItem = false
9 @State private var selectedPinnedProject: Project?
10 @State private var selectedPinnedUser: User?
13 11
14 var body: some View { 12 var body: some View {
15 Group { 13 Group {
@@ -20,39 +18,40 @@ struct HomeView: View {
20 } 18 }
21 } 19 }
22 .navigationTitle("Home") 20 .navigationTitle("Home")
23 .toolbar { 21 .navigationDestination(isPresented: Binding(
24 ToolbarItem(placement: .topBarTrailing) { 22 get: { selectedPinnedProject != nil },
25 NavigationLink { 23 set: { isPresented in
26 InboxView() 24 if !isPresented {
27 } label: { 25 selectedPinnedProject = nil
28 HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
29 } 26 }
30 } 27 }
28 )) {
29 if let selectedPinnedProject {
30 ProjectDetailView(project: selectedPinnedProject)
31 }
32 }
33 .navigationDestination(isPresented: Binding(
34 get: { selectedPinnedUser != nil },
35 set: { isPresented in
36 if !isPresented {
37 selectedPinnedUser = nil
38 }
39 }
40 )) {
41 if let selectedPinnedUser {
42 UserProfileView(user: selectedPinnedUser)
43 }
31 } 44 }
32 .task { 45 .task {
33 guard let currentUser = appState.currentUser else { return } 46 guard let currentUser = appState.currentUser else { return }
34 47 await ensureViewModel(currentUser: currentUser).loadDashboard()
35 let vm: HomeViewModel 48 loadRecentActivity()
36 if let viewModel {
37 vm = viewModel
38 } else {
39 let newViewModel = HomeViewModel(
40 currentUser: currentUser,
41 client: appState.client,
42 systemStatusRepository: appState.systemStatusRepository,
43 defaults: appState.accountDefaults,
44 accountID: appState.activeAccountID
45 )
46 viewModel = newViewModel
47 vm = newViewModel
48 }
49
50 await vm.loadDashboard()
51 } 49 }
52 .onChange(of: scenePhase) { _, newPhase in 50 .onChange(of: scenePhase) { _, newPhase in
53 guard newPhase == .active, let viewModel else { return } 51 guard newPhase == .active, let viewModel else { return }
54 Task { 52 Task {
55 await viewModel.loadDashboard() 53 await viewModel.loadDashboard()
54 loadRecentActivity()
56 } 55 }
57 } 56 }
58 } 57 }
@@ -60,850 +59,497 @@ struct HomeView: View {
60 @ViewBuilder 59 @ViewBuilder
61 private func content(_ viewModel: HomeViewModel) -> some View { 60 private func content(_ viewModel: HomeViewModel) -> some View {
62 List { 61 List {
63 attentionSection(viewModel) 62 systemStatusSection(viewModel)
64 systemStatusBannerSection(viewModel) 63 workSection(viewModel)
65 inboxSection(viewModel) 64 recentSection
66 projectsSection(viewModel) 65 buildsSection(viewModel)
67 assignedTicketsSection(viewModel) 66 pinnedSection(viewModel)
68 recentBuildsSection(viewModel)
69 } 67 }
70 .themedList() 68 .themedList()
71 .listStyle(.insetGrouped) 69 .listStyle(.insetGrouped)
72 .overlay { 70 .listSectionSpacing(.compact)
73 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
74 viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
75 viewModel.unreadInboxThreads.isEmpty {
76 SRHTLoadingStateView(message: "Loading Home…")
77 }
78 }
79 .refreshable { 71 .refreshable {
80 await viewModel.loadDashboard() 72 await viewModel.loadDashboard()
81 } 73 }
82 .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { 74 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
83 await viewModel.loadDashboard() 75 await viewModel.loadDashboard()
84 } 76 }
85 } 77 .onAppear {
86 78 loadRecentActivity()
87 @ViewBuilder
88 private func attentionSection(_ viewModel: HomeViewModel) -> some View {
89 Section("Needs Attention") {
90 HomeAttentionSummaryRow(
91 title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention",
92 summary: viewModel.attentionSummaryText
93 )
94
95 HomeAttentionLinkRow(
96 title: "Inbox",
97 summary: viewModel.inboxSummaryText,
98 countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?"
99 ) {
100 InboxView()
101 }
102
103 HomeAttentionLinkRow(
104 title: "Assigned Tickets",
105 summary: viewModel.ticketsSummaryText,
106 countText: String(viewModel.assignedTickets.count)
107 ) {
108 HomeAssignedTicketsListView(viewModel: viewModel)
109 }
110
111 HomeAttentionLinkRow(
112 title: "Builds",
113 summary: viewModel.buildsSummaryText,
114 countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
115 action: {
116 appState.navigateToBuildsList()
117 }
118 )
119 } 79 }
120 } 80 }
121 81
122 @ViewBuilder 82 @ViewBuilder
123 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { 83 private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
124 Section { 84 if viewModel.systemStatusSnapshot?.hasDisruption == true {
125 NavigationLink { 85 Section {
126 SystemStatusView() 86 NavigationLink {
127 } label: { 87 SystemStatusView()
128 SystemStatusSummaryRow( 88 } label: {
129 snapshot: viewModel.systemStatusSnapshot, 89 SystemStatusSummaryRow(
130 isLoading: viewModel.isLoadingSystemStatus, 90 snapshot: viewModel.systemStatusSnapshot,
131 errorMessage: viewModel.systemStatusErrorMessage, 91 isLoading: viewModel.isLoadingSystemStatus,
132 isShowingStaleData: viewModel.isShowingStaleSystemStatus 92 errorMessage: viewModel.systemStatusErrorMessage,
133 ) 93 isShowingStaleData: viewModel.isShowingStaleSystemStatus
94 )
95 }
96 .buttonStyle(.plain)
134 } 97 }
135 .buttonStyle(.plain)
136 } 98 }
137 } 99 }
138 100
139 @ViewBuilder 101 private func workSection(_ viewModel: HomeViewModel) -> some View {
140 private func inboxSection(_ viewModel: HomeViewModel) -> some View { 102 Section("Work") {
141 Section { 103 NavigationLink(value: HomeRoute.work) {
142 if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { 104 HomeSummaryRow(
143 HomeSectionMessageRow( 105 title: workTitle(viewModel),
144 text: "No unread inbox threads.", 106 summary: workSummary(viewModel),
145 systemImage: "tray" 107 systemImage: "tray.full",
146 ) 108 tint: workCount(viewModel) > 0 ? .blue : .secondary,
147 } else if viewModel.unreadInboxThreads.isEmpty { 109 emphasis: .action
148 HomeSectionMessageRow(
149 text: viewModel.inboxSummaryText,
150 systemImage: "tray"
151 ) 110 )
152 } else {
153 ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in
154 NavigationLink {
155 ThreadDetailView(
156 thread: thread,
157 onViewed: { viewModel.markInboxThreadRead(thread) },
158 onMarkRead: { viewModel.markInboxThreadRead(thread) },
159 onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
160 )
161 } label: {
162 HomeInboxThreadRow(thread: thread)
163 }
164 }
165 }
166 } header: {
167 HomeSectionHeader("Inbox") {
168 InboxView()
169 } 111 }
170 } 112 }
171 } 113 }
172 114
173 @ViewBuilder 115 @ViewBuilder
174 private func projectsSection(_ viewModel: HomeViewModel) -> some View { 116 private var recentSection: some View {
175 if viewModel.hasPinnedProjects { 117 if !recentItems.isEmpty {
176 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { 118 Section("Recent") {
177 NavigationLink { 119 ForEach(recentItems) { item in
178 ProjectsListView() 120 Button {
179 } label: { 121 openRecentItem(item)
180 Text("See All") 122 } label: {
181 .font(.caption.weight(.medium)) 123 HomeRecentRow(item: item)
182 }
183 .buttonStyle(.plain)
184 } content: {
185 if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
186 HomeSectionLoadingRow(label: "Loading pinned projects")
187 } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
188 HomeSectionMessageRow(
189 text: "Couldn’t load pinned projects.",
190 systemImage: "exclamationmark.triangle",
191 emphasized: true,
192 accessibilityHint: error
193 )
194 } else if viewModel.pinnedProjects.isEmpty {
195 HomeSectionMessageRow(
196 text: "Pinned projects will appear here when they’re available.",
197 systemImage: "pin"
198 )
199 } else {
200 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
201 NavigationLink {
202 ProjectDetailView(project: project)
203 } label: {
204 HomeProjectRow(project: project)
205 }
206 } 124 }
125 .buttonStyle(.plain)
126 .disabled(isOpeningRecentItem)
127 .listRowSeparator(.hidden)
207 } 128 }
208 } 129 }
209 } 130 }
210 } 131 }
211 132
212 @ViewBuilder 133 private func buildsSection(_ viewModel: HomeViewModel) -> some View {
213 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { 134 Section("Builds") {
214 HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
215 NavigationLink { 135 NavigationLink {
216 HomeAssignedTicketsListView(viewModel: viewModel) 136 BuildListView()
217 } label: { 137 } label: {
218 Text("See All") 138 HomeSummaryRow(
219 .font(.caption.weight(.medium)) 139 title: buildsTitle(viewModel),
220 } 140 summary: buildsSummary(viewModel),
221 .buttonStyle(.plain) 141 systemImage: "hammer",
222 } content: { 142 tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
223 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { 143 emphasis: .monitoring
224 HomeSectionLoadingRow(label: "Loading assigned tickets")
225 } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
226 HomeSectionMessageRow(
227 text: "Couldn’t load assigned tickets.",
228 systemImage: "exclamationmark.triangle",
229 emphasized: true,
230 accessibilityHint: error
231 )
232 } else if viewModel.assignedTickets.isEmpty {
233 HomeSectionMessageRow(
234 text: "No open tickets assigned to you.",
235 systemImage: "person.crop.circle.badge.checkmark"
236 ) 144 )
237 } else {
238 ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
239 NavigationLink {
240 TicketDetailView(
241 ownerUsername: ticket.ownerUsername,
242 trackerName: ticket.trackerName,
243 trackerId: ticket.trackerId,
244 trackerRid: ticket.trackerRid,
245 ticketId: ticket.ticket.id
246 )
247 } label: {
248 HomeAssignedTicketRow(ticket: ticket)
249 }
250 .swipeActions(edge: .leading, allowsFullSwipe: true) {
251 if swipeActionsEnabled {
252 ticketLeadingSwipeAction(ticket, viewModel: viewModel)
253 }
254 }
255 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
256 if swipeActionsEnabled {
257 Button {
258 Task {
259 await viewModel.unassignFromMe(ticket)
260 }
261 } label: {
262 Label("Unassign Me", systemImage: "person.badge.minus")
263 }
264 .tint(.orange)
265 }
266 }
267 }
268 } 145 }
269 } 146 }
270 } 147 }
271 148
272 @ViewBuilder 149 private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
273 private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { 150 let items = pinnedItems(viewModel)
274 HomeSectionView("Builds", isExpanded: $buildsExpanded) {
275 Button("See All") {
276 appState.navigateToBuildsList()
277 }
278 .font(.caption.weight(.medium))
279 .buttonStyle(.plain)
280 } content: {
281 if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
282 HomeSectionLoadingRow(label: "Loading recent builds")
283 } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
284 HomeSectionMessageRow(
285 text: "Couldn’t load recent builds.",
286 systemImage: "exclamationmark.triangle",
287 emphasized: true,
288 accessibilityHint: error
289 )
290 } else if viewModel.recentBuilds.isEmpty {
291 HomeSectionMessageRow(
292 text: "No recent builds.",
293 systemImage: "clock"
294 )
295 } else {
296 ForEach(buildGroups(for: viewModel.recentBuilds)) { group in
297 if let repositoryDisplayName = group.repositoryDisplayName {
298 HomeBuildGroupHeader(
299 repositoryDisplayName: repositoryDisplayName,
300 buildCount: group.builds.count,
301 latestStatus: group.latestStatus
302 )
303 }
304 151
305 ForEach(group.builds) { build in 152 return Section("Pinned") {
306 NavigationLink { 153 if items.isEmpty {
307 BuildDetailView(jobId: build.job.id) 154 NavigationLink {
155 ProjectsListView()
156 } label: {
157 HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin")
158 }
159 } else {
160 LazyVGrid(
161 columns: [
162 GridItem(.flexible(), spacing: 10),
163 GridItem(.flexible(), spacing: 10),
164 ],
165 spacing: 10
166 ) {
167 ForEach(items) { item in
168 Button {
169 openPinnedItem(item, viewModel: viewModel)
308 } label: { 170 } label: {
309 HomeBuildRow( 171 HomePinnedCard(item: item)
310 build: build,
311 showsRepositoryLink: group.repositoryDisplayName == nil
312 )
313 }
314 .swipeActions(edge: .leading, allowsFullSwipe: true) {
315 if swipeActionsEnabled, build.job.status.isCancellable {
316 Button {
317 Task {
318 await viewModel.cancelBuild(build)
319 }
320 }
321 label: {
322 Label("Cancel", systemImage: "xmark.circle")
323 }
324 .tint(.red)
325 }
326 } 172 }
173 .buttonStyle(.plain)
327 } 174 }
328 } 175 }
176 .padding(.vertical, 2)
329 } 177 }
330 } 178 }
331 } 179 }
332 180
333 private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] { 181 private func workCount(_ viewModel: HomeViewModel) -> Int {
334 let previewBuilds = Array(builds.prefix(previewLimit)) 182 unreadCount(viewModel) + viewModel.assignedTickets.count
335 guard let firstBuild = previewBuilds.first else { return [] } 183 }
336
337 var groups: [HomeBuildGroup] = []
338 var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
339 var currentBuilds: [HomeBuildItem] = []
340
341 for build in previewBuilds {
342 let identity = HomeBuildGroup.Identity(build: build)
343 if identity == currentIdentity {
344 currentBuilds.append(build)
345 } else {
346 groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
347 currentIdentity = identity
348 currentBuilds = [build]
349 }
350 }
351
352 if !currentBuilds.isEmpty {
353 groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
354 }
355 184
356 return groups 185 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
186 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
357 } 187 }
358 188
359 @ViewBuilder 189 private func workTitle(_ viewModel: HomeViewModel) -> String {
360 private func ticketLeadingSwipeAction( 190 let count = workCount(viewModel)
361 _ ticket: HomeAssignedTicket, 191 if count == 0 {
362 viewModel: HomeViewModel 192 return "Queue clear"
363 ) -> some View {
364 if ticket.ticket.status.isOpen {
365 Button {
366 Task {
367 await viewModel.resolveTicket(ticket)
368 }
369 } label: {
370 Label("Resolve", systemImage: "checkmark.circle")
371 }
372 .tint(.green)
373 } else {
374 Button {
375 Task {
376 await viewModel.reopenTicket(ticket)
377 }
378 } label: {
379 Label("Reopen", systemImage: "arrow.uturn.backward")
380 }
381 .tint(.blue)
382 } 193 }
194 return "\(count) item\(count == 1 ? "" : "s") need attention"
383 } 195 }
384 196
385} 197 private func workSummary(_ viewModel: HomeViewModel) -> String {
386 198 let unread = unreadCount(viewModel)
387private struct HomeInboxToolbarIcon: View { 199 let assigned = viewModel.assignedTickets.count
388 let hasUnreadThreads: Bool 200 return "\(unread) unread • \(assigned) assigned"
389
390 var body: some View {
391 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
392 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
393 } 201 }
394}
395
396private struct HomeProjectRow: View {
397 let project: Project
398
399 var body: some View {
400 VStack(alignment: .leading, spacing: 8) {
401 HStack(alignment: .top, spacing: 10) {
402 VStack(alignment: .leading, spacing: 4) {
403 Text(project.displayName)
404 .font(.subheadline.weight(.medium))
405 .foregroundStyle(.primary)
406 .lineLimit(1)
407
408 if let description = project.displayDescription {
409 Text(description)
410 .font(.caption)
411 .foregroundStyle(.secondary)
412 .lineLimit(2)
413 }
414 }
415 202
416 Spacer(minLength: 8) 203 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
204 let failed = viewModel.failedBuildCount
205 let running = viewModel.activeBuildCount
417 206
418 VisibilityBadge(visibility: project.visibility) 207 if failed == 0 && running == 0 {
419 } 208 return "Build monitoring clear"
420
421 Text(project.metadataLine)
422 .font(.caption)
423 .foregroundStyle(.secondary)
424 .lineLimit(1)
425 } 209 }
426 .contentShape(Rectangle()) 210 if failed > 0 {
427 .padding(.vertical, 4) 211 return "\(failed) failed build\(failed == 1 ? "" : "s")"
428 }
429}
430
431private struct HomeBuildRow: View {
432 @Environment(AppState.self) private var appState
433 let build: HomeBuildItem
434 var showsRepositoryLink = true
435
436 var body: some View {
437 VStack(alignment: .leading, spacing: 6) {
438 HStack(spacing: 12) {
439 JobStatusIcon(status: build.job.status)
440 .frame(width: 20)
441
442 VStack(alignment: .leading, spacing: 4) {
443 Text(build.job.displayLabel)
444 .font(.subheadline.weight(.medium))
445 .lineLimit(1)
446
447 HStack(spacing: 8) {
448 Text("Job #\(build.job.id)")
449 .font(.caption)
450 .foregroundStyle(.secondary)
451
452 Text("•")
453 .font(.caption)
454 .foregroundStyle(.tertiary)
455
456 Text(build.job.status.displayTitle)
457 .font(.caption)
458 .foregroundStyle(.secondary)
459
460 Text("•")
461 .font(.caption)
462 .foregroundStyle(.tertiary)
463
464 Text(build.job.created.relativeDescription)
465 .font(.caption)
466 .foregroundStyle(.tertiary)
467
468 Spacer()
469 }
470 }
471 }
472
473 if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
474 Button {
475 openRepository()
476 } label: {
477 Label(repositoryDisplayName, systemImage: "book.closed")
478 .font(.caption)
479 .foregroundStyle(.secondary)
480 }
481 .buttonStyle(.plain)
482 }
483 } 212 }
484 .padding(.vertical, 2) 213 return "\(running) running build\(running == 1 ? "" : "s")"
485 } 214 }
486 215
487 private func openRepository() { 216 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
488 guard let repositoryName = build.repositoryName, 217 let failed = viewModel.failedBuildCount
489 let repositoryOwner = build.repositoryOwner else { return } 218 let running = viewModel.activeBuildCount
490 Task { 219 if failed == 0 && running == 0 {
491 do { 220 return "No failures • \(buildTimeframeLabel(viewModel))"
492 let repository = try await appState.resolveRepository( 221 }
493 owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, 222 if failed > 0 && running > 0 {
494 name: repositoryName 223 return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
495 )
496 appState.navigateToRepository(repository)
497 } catch {
498 appState.presentRepositoryDeepLinkError()
499 }
500 } 224 }
225 if failed > 0 {
226 return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
227 }
228 return "\(running) running • \(buildTimeframeLabel(viewModel))"
501 } 229 }
502}
503 230
504private struct HomeBuildGroup: Identifiable { 231 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
505 enum Identity: Hashable { 232 let currentUserKey = appState.currentUser?.canonicalName ?? ""
506 case repository(owner: String?, name: String) 233 let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
507 case standalone(Int) 234 let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
508 235
509 init(build: HomeBuildItem) { 236 return pins.compactMap { pin in
510 if let repositoryName = build.repositoryName { 237 switch pin.kind {
511 self = .repository(owner: build.repositoryOwner, name: repositoryName) 238 case .project:
512 } else { 239 guard let project = projectsByID[pin.value] else { return nil }
513 self = .standalone(build.id) 240 return HomePinnedItem(pin: pin, project: project)
241 case .repository, .tracker, .mailingList, .user:
242 return HomePinnedItem(pin: pin, project: nil)
514 } 243 }
515 } 244 }
516 } 245 }
517 246
518 let identity: Identity 247 private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
519 let builds: [HomeBuildItem] 248 let calendar = Calendar.current
249 let buildDates = viewModel.recentBuilds.map(\.job.updated)
520 250
521 var id: String { 251 guard !buildDates.isEmpty else {
522 switch identity { 252 return "today"
523 case .repository(let owner, let name):
524 return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
525 case .standalone(let jobId):
526 return "job-\(jobId)"
527 } 253 }
528 }
529 254
530 var repositoryDisplayName: String? { 255 return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
531 builds.first?.repositoryDisplayName
532 } 256 }
533 257
534 var latestStatus: JobStatus { 258 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
535 builds.first?.job.status ?? .pending 259 viewModel.systemStatusSnapshot?.hasDisruption == true ||
260 workCount(viewModel) > 0 ||
261 !recentItems.isEmpty ||
262 !pinnedItems(viewModel).isEmpty
536 } 263 }
537}
538
539private struct HomeBuildGroupHeader: View {
540 let repositoryDisplayName: String
541 let buildCount: Int
542 let latestStatus: JobStatus
543 264
544 var body: some View { 265 private func loadRecentActivity() {
545 HStack(spacing: 12) { 266 recentItems = RecentActivityStore.load(defaults: appState.accountDefaults)
546 Label(repositoryDisplayName, systemImage: "book.closed")
547 .font(.caption.weight(.medium))
548 .foregroundStyle(.secondary)
549 .lineLimit(1)
550
551 Spacer(minLength: 8)
552
553 Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
554 .font(.caption2.weight(.medium))
555 .foregroundStyle(.tertiary)
556
557 JobStatusBadge(status: latestStatus)
558 }
559 .padding(.top, 4)
560 .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
561 .listRowSeparator(.hidden)
562 .accessibilityElement(children: .combine)
563 } 267 }
564}
565 268
566private struct HomeAssignedTicketRow: View { 269 private func openRecentItem(_ item: RecentActivityEntry) {
567 @Environment(AppState.self) private var appState 270 guard !isOpeningRecentItem else { return }
568 let ticket: HomeAssignedTicket
569 271
570 var body: some View { 272 switch item.kind {
571 VStack(alignment: .leading, spacing: 6) { 273 case .build:
572 HStack(alignment: .top, spacing: 12) { 274 guard let jobId = item.buildJobId else { return }
573 TicketStatusIcon(status: ticket.ticket.status) 275 appState.navigateToBuild(jobId: jobId)
574 .frame(width: 20) 276 case .ticket:
575 277 guard
576 VStack(alignment: .leading, spacing: 4) { 278 let ownerUsername = item.ticketOwnerUsername,
577 Text(ticket.ticket.title) 279 let trackerName = item.ticketTrackerName,
578 .font(.subheadline.weight(.medium)) 280 let ticketId = item.ticketId
579 .lineLimit(2) 281 else {
580 282 return
581 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
582 .font(.caption)
583 .foregroundStyle(.secondary)
584 .lineLimit(1)
585 .truncationMode(.tail)
586 }
587
588 Spacer(minLength: 8)
589
590 Text(ticket.ticket.status.displayName)
591 .font(.caption2.weight(.medium))
592 .foregroundStyle(.secondary)
593 .lineLimit(1)
594 .fixedSize()
595 } 283 }
596 284 appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId)
597 Button { 285 case .repository:
598 openTracker() 286 guard
599 } label: { 287 let owner = item.repositoryOwner,
600 Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") 288 let name = item.repositoryName
601 .font(.caption) 289 else {
602 .foregroundStyle(.secondary) 290 return
603 } 291 }
604 .buttonStyle(.plain)
605 }
606 .padding(.vertical, 2)
607 }
608 292
609 private func openTracker() { 293 isOpeningRecentItem = true
610 Task { 294 Task {
611 do { 295 defer { isOpeningRecentItem = false }
612 let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) 296 do {
613 appState.navigateToTracker(tracker) 297 let repository = try await appState.resolveRepository(
614 } catch { 298 owner: owner,
615 appState.presentTicketDeepLinkError() 299 name: name,
300 service: item.repositoryService ?? .git
301 )
302 appState.navigateToRepository(repository)
303 } catch {
304 appState.presentRepositoryDeepLinkError()
305 }
616 } 306 }
617 } 307 }
618 } 308 }
619}
620 309
621private struct HomeInboxThreadRow: View { 310 private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
622 @Environment(AppState.self) private var appState 311 switch item.pin.kind {
623 let thread: InboxThreadSummary 312 case .project:
624 313 guard let project = item.project else { return }
625 var body: some View { 314 selectedPinnedProject = project
626 VStack(alignment: .leading, spacing: 6) { 315 case .repository:
627 HStack(alignment: .top, spacing: 10) { 316 guard
628 Circle() 317 let owner = item.pin.ownerUsername,
629 .fill(.blue) 318 let service = item.pin.service
630 .frame(width: 8, height: 8) 319 else {
631 .padding(.top, 6) 320 return
632 321 }
633 VStack(alignment: .leading, spacing: 4) { 322 isOpeningRecentItem = true
634 Text(thread.displaySubject) 323 Task {
635 .font(.subheadline.weight(.medium)) 324 defer { isOpeningRecentItem = false }
636 .lineLimit(2) 325 do {
637 326 let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service)
638 Text(thread.metadataLine) 327 appState.navigateToRepository(repository)
639 .font(.caption) 328 } catch {
640 .foregroundStyle(.secondary) 329 appState.presentRepositoryDeepLinkError()
641 .lineLimit(1)
642 } 330 }
643 } 331 }
644 332 case .tracker:
645 HStack(spacing: 10) { 333 guard let owner = item.pin.ownerUsername else { return }
646 Button { 334 isOpeningRecentItem = true
647 appState.navigateToMailingList( 335 Task {
648 InboxMailingListReference( 336 defer { isOpeningRecentItem = false }
649 id: thread.listID, 337 do {
650 rid: thread.listRID, 338 let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value)
651 name: thread.listName, 339 appState.navigateToTracker(tracker)
652 owner: thread.listOwner 340 } catch {
653 ) 341 appState.presentTicketDeepLinkError()
654 )
655 } label: {
656 Label(thread.listName, systemImage: "list.bullet")
657 .font(.caption)
658 .foregroundStyle(.secondary)
659 } 342 }
660 .buttonStyle(.plain) 343 }
661 344 case .mailingList:
662 if let repo = thread.repo { 345 guard let ownerUsername = item.pin.ownerUsername else { return }
663 Button { 346 appState.openMailingList(
664 openRepository(named: repo) 347 InboxMailingListReference(
665 } label: { 348 id: 0,
666 Label(repo, systemImage: "book.closed") 349 rid: item.pin.value,
667 .font(.caption) 350 name: item.pin.title,
668 .foregroundStyle(.secondary) 351 owner: Entity(canonicalName: "~\(ownerUsername)")
669 } 352 )
670 .buttonStyle(.plain) 353 )
354 case .user:
355 guard let ownerUsername = item.pin.ownerUsername else { return }
356 isOpeningRecentItem = true
357 Task {
358 defer { isOpeningRecentItem = false }
359 if let user = try? await resolvePinnedUser(username: ownerUsername) {
360 selectedPinnedUser = user
671 } 361 }
672 } 362 }
673 } 363 }
674 .padding(.vertical, 2)
675 } 364 }
676 365
677 private func openRepository(named repositoryName: String) { 366 private func resolvePinnedUser(username: String) async throws -> User {
678 Task { 367 struct Response: Decodable, Sendable {
679 do { 368 let user: User
680 let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") 369 }
681 ? String(thread.listOwner.canonicalName.dropFirst()) 370
682 : thread.listOwner.canonicalName 371 let query = """
683 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) 372 query userLookup($username: String!) {
684 appState.navigateToRepository(repository) 373 user: userByName(username: $username) {
685 } catch { 374 id
686 appState.presentRepositoryDeepLinkError() 375 created
376 updated
377 canonicalName
378 username
379 email
380 url
381 location
382 bio
383 avatar
384 pronouns
385 userType
687 } 386 }
688 } 387 }
689 } 388 """
690}
691 389
692private struct HomeSectionLoadingRow: View { 390 let result = try await appState.client.execute(
693 let label: String 391 service: .meta,
392 query: query,
393 variables: ["username": username],
394 responseType: Response.self
395 )
396 return result.user
397 }
694 398
695 var body: some View { 399 @MainActor
696 HStack(spacing: 10) { 400 private func ensureViewModel(currentUser: User) -> HomeViewModel {
697 ProgressView() 401 if let viewModel {
698 .controlSize(.small) 402 return viewModel
699 Text(label)
700 .foregroundStyle(.secondary)
701 } 403 }
702 .frame(maxWidth: .infinity, alignment: .leading) 404
405 let newViewModel = HomeViewModel(
406 currentUser: currentUser,
407 client: appState.client,
408 systemStatusRepository: appState.systemStatusRepository,
409 defaults: appState.accountDefaults,
410 accountID: appState.activeAccountID
411 )
412 viewModel = newViewModel
413 return newViewModel
703 } 414 }
704} 415}
705 416
706private struct HomeSectionHeader<Destination: View>: View { 417enum HomeRoute: Hashable {
707 let title: String 418 case work
708 let destination: Destination 419}
709 420
710 init(_ title: String, @ViewBuilder destination: () -> Destination) { 421private enum HomeSummaryEmphasis {
711 self.title = title 422 case action
712 self.destination = destination() 423 case monitoring
713 } 424}
714 425
715 var body: some View { 426private struct HomePinnedItem: Identifiable {
716 HStack { 427 let pin: HomePinRecord
717 Text(title) 428 let project: Project?
718 Spacer() 429
719 NavigationLink { 430 var id: String { pin.id }
720 destination 431 var title: String { project?.displayName ?? pin.title }
721 } label: { 432 var detail: String { pin.subtitle }
722 Text("See All")
723 .font(.caption.weight(.medium))
724 }
725 .buttonStyle(.plain)
726 }
727 .textCase(nil)
728 }
729} 433}
730 434
731private struct HomeAttentionSummaryRow: View { 435private struct HomeSummaryRow: View {
732 let title: String 436 let title: String
733 let summary: String 437 let summary: String
438 let systemImage: String
439 let tint: Color
440 let emphasis: HomeSummaryEmphasis
734 441
735 var body: some View { 442 var body: some View {
736 VStack(alignment: .leading, spacing: 4) { 443 HStack(spacing: 10) {
737 Text(title) 444 Image(systemName: systemImage)
738 .font(.subheadline.weight(.semibold)) 445 .font(.subheadline.weight(.semibold))
739 Text(summary) 446 .foregroundStyle(iconColor)
740 .font(.caption) 447 .frame(width: 18)
741 .foregroundStyle(.secondary) 448
742 .lineLimit(2) 449 VStack(alignment: .leading, spacing: 2) {
450 Text(title)
451 .font(.subheadline.weight(.semibold))
452 Text(summary)
453 .font(.caption)
454 .foregroundStyle(.secondary)
455 .lineLimit(1)
456 }
457
458 Spacer(minLength: 8)
743 } 459 }
744 .padding(.vertical, 2) 460 .padding(.vertical, verticalPadding)
745 } 461 }
746}
747 462
748private struct HomeAttentionLinkRow<Destination: View>: View { 463 private var iconColor: Color {
749 let title: String 464 switch emphasis {
750 let summary: String 465 case .action:
751 let countText: String 466 return tint
752 let destination: Destination? 467 case .monitoring:
753 let action: (() -> Void)? 468 return tint.opacity(0.9)
754 469 }
755 init(
756 title: String,
757 summary: String,
758 countText: String,
759 @ViewBuilder destination: () -> Destination
760 ) {
761 self.title = title
762 self.summary = summary
763 self.countText = countText
764 self.destination = destination()
765 self.action = nil
766 } 470 }
767 471
768 init( 472 private var verticalPadding: CGFloat {
769 title: String, 473 switch emphasis {
770 summary: String, 474 case .action:
771 countText: String, 475 return 3
772 action: @escaping () -> Void 476 case .monitoring:
773 ) where Destination == EmptyView { 477 return 2
774 self.title = title 478 }
775 self.summary = summary
776 self.countText = countText
777 self.destination = nil
778 self.action = action
779 } 479 }
480}
481
482private struct HomeRecentRow: View {
483 let item: RecentActivityEntry
780 484
781 var body: some View { 485 var body: some View {
782 Group { 486 HStack(spacing: 10) {
783 if let destination { 487 Image(systemName: iconName)
784 NavigationLink { 488 .font(.caption.weight(.semibold))
785 destination 489 .foregroundStyle(.secondary)
786 } label: { 490 .frame(width: 16)
787 content
788 }
789 } else if let action {
790 Button(action: action) {
791 content
792 }
793 .buttonStyle(.plain)
794 }
795 }
796 }
797 491
798 private var content: some View { 492 VStack(alignment: .leading, spacing: 1) {
799 HStack(spacing: 12) { 493 Text(item.title)
800 VStack(alignment: .leading, spacing: 4) {
801 Text(title)
802 .font(.subheadline.weight(.medium)) 494 .font(.subheadline.weight(.medium))
803 Text(summary) 495 .lineLimit(1)
496 Text(item.detailText)
804 .font(.caption) 497 .font(.caption)
805 .foregroundStyle(.secondary) 498 .foregroundStyle(.secondary)
806 .lineLimit(1) 499 .lineLimit(1)
807 } 500 }
808 Spacer()
809 Text(countText)
810 .font(.caption.weight(.semibold))
811 .foregroundStyle(.secondary)
812 .padding(.horizontal, 8)
813 .padding(.vertical, 4)
814 .background(Color(.secondarySystemFill), in: Capsule())
815 501
816 if action != nil { 502 Spacer(minLength: 8)
817 Image(systemName: "chevron.right") 503 }
818 .font(.caption.weight(.semibold)) 504 .padding(.vertical, 1)
819 .foregroundStyle(.tertiary) 505 }
820 } 506
507 private var iconName: String {
508 switch item.kind {
509 case .repository:
510 return "book.closed"
511 case .ticket:
512 return "number"
513 case .build:
514 return "hammer"
821 } 515 }
822 .frame(maxWidth: .infinity, alignment: .leading)
823 .contentShape(Rectangle())
824 .padding(.vertical, 2)
825 } 516 }
826} 517}
827 518
828private struct HomeAssignedTicketsListView: View { 519private struct HomePinnedCard: View {
829 let viewModel: HomeViewModel 520 let item: HomePinnedItem
830 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
831 521
832 var body: some View { 522 var body: some View {
833 List { 523 VStack(alignment: .leading, spacing: 6) {
834 ForEach(viewModel.assignedTickets) { ticket in 524 HStack(spacing: 6) {
835 NavigationLink { 525 Image(systemName: "square.stack.3d.up")
836 TicketDetailView( 526 .font(.caption.weight(.semibold))
837 ownerUsername: ticket.ownerUsername, 527 .foregroundStyle(.secondary)
838 trackerName: ticket.trackerName, 528 Text(item.detail)
839 trackerId: ticket.trackerId, 529 .font(.caption2.weight(.semibold))
840 trackerRid: ticket.trackerRid, 530 .foregroundStyle(.secondary)
841 ticketId: ticket.ticket.id
842 )
843 } label: {
844 HomeAssignedTicketRow(ticket: ticket)
845 }
846 .swipeActions(edge: .leading, allowsFullSwipe: true) {
847 if swipeActionsEnabled {
848 if ticket.ticket.status.isOpen {
849 Button {
850 Task { await viewModel.resolveTicket(ticket) }
851 } label: {
852 Label("Resolve", systemImage: "checkmark.circle")
853 }
854 .tint(.green)
855 } else {
856 Button {
857 Task { await viewModel.reopenTicket(ticket) }
858 } label: {
859 Label("Reopen", systemImage: "arrow.uturn.backward")
860 }
861 .tint(.blue)
862 }
863 }
864 }
865 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
866 if swipeActionsEnabled {
867 Button {
868 Task { await viewModel.unassignFromMe(ticket) }
869 } label: {
870 Label("Unassign Me", systemImage: "person.badge.minus")
871 }
872 .tint(.orange)
873 }
874 }
875 } 531 }
876 532
877 if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { 533 Text(item.title)
878 HomeSectionMessageRow( 534 .font(.subheadline.weight(.semibold))
879 text: "No open tickets assigned to you.", 535 .lineLimit(2)
880 systemImage: "person.crop.circle.badge.checkmark" 536
881 ) 537 Spacer(minLength: 0)
882 }
883 }
884 .navigationTitle("Assigned Tickets")
885 .navigationBarTitleDisplayMode(.inline)
886 .refreshable {
887 await viewModel.loadDashboard()
888 }
889 .overlay {
890 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
891 SRHTLoadingStateView(message: "Loading assigned tickets…")
892 }
893 } 538 }
539 .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
540 .padding(10)
541 .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
894 } 542 }
895} 543}
896 544
897private struct HomeSectionMessageRow: View { 545private struct HomeCompactMessageRow: View {
898 let text: String 546 let text: String
899 let systemImage: String 547 let systemImage: String
900 var emphasized = false
901 var accessibilityHint: String? = nil
902 548
903 var body: some View { 549 var body: some View {
904 Label(text, systemImage: systemImage) 550 Label(text, systemImage: systemImage)
905 .font(.subheadline) 551 .font(.caption)
906 .foregroundStyle(emphasized ? .secondary : .tertiary) 552 .foregroundStyle(.secondary)
907 .accessibilityHint(accessibilityHint ?? "") 553 .padding(.vertical, 2)
908 } 554 }
909} 555}
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -393,7 +393,7 @@ final class HomeViewModel {
393 393
394 let inboxUnreadSnapshot = await inboxUnreadTask 394 let inboxUnreadSnapshot = await inboxUnreadTask
395 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount 395 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
396 unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) 396 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
397 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 397 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
398 let systemStatusResult = await systemStatusTask 398 let systemStatusResult = await systemStatusTask
399 switch systemStatusResult { 399 switch systemStatusResult {
@@ -414,7 +414,7 @@ final class HomeViewModel {
414 } 414 }
415 415
416 var pinnedProjects: [Project] { 416 var pinnedProjects: [Project] {
417 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults) 417 let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults)
418 guard !pinnedIDs.isEmpty else { return [] } 418 guard !pinnedIDs.isEmpty else { return [] }
419 419
420 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) 420 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -422,7 +422,7 @@ final class HomeViewModel {
422 } 422 }
423 423
424 var hasPinnedProjects: Bool { 424 var hasPinnedProjects: Bool {
425 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty 425 !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty
426 } 426 }
427 427
428 var failedBuildCount: Int { 428 var failedBuildCount: Int {
Hutch/Views/Home/RecentActivityStore.swift added +148
@@ -0,0 +1,148 @@
1import Foundation
2
3enum RecentActivityKind: String, Codable, Sendable {
4 case repository
5 case ticket
6 case build
7}
8
9struct RecentActivityEntry: Codable, Hashable, Identifiable, Sendable {
10 let kind: RecentActivityKind
11 let title: String
12 let viewedAt: Date
13 let repositoryOwner: String?
14 let repositoryName: String?
15 let repositoryService: SRHTService?
16 let ticketOwnerUsername: String?
17 let ticketTrackerName: String?
18 let ticketId: Int?
19 let buildJobId: Int?
20
21 var id: String {
22 switch kind {
23 case .repository:
24 let service = repositoryService?.rawValue ?? SRHTService.git.rawValue
25 return "repo:\(service):\(repositoryOwner ?? "")/\(repositoryName ?? "")"
26 case .ticket:
27 return "ticket:\(ticketOwnerUsername ?? "")/\(ticketTrackerName ?? "")#\(ticketId ?? 0)"
28 case .build:
29 return "build:\(buildJobId ?? 0)"
30 }
31 }
32
33 var detailText: String {
34 switch kind {
35 case .repository:
36 let serviceName = repositoryService?.displayName ?? "Repository"
37 return "\(serviceName) • Viewed \(viewedAt.relativeDescription)"
38 case .ticket:
39 return "Ticket • Viewed \(viewedAt.relativeDescription)"
40 case .build:
41 return "Build • Viewed \(viewedAt.relativeDescription)"
42 }
43 }
44}
45
46enum RecentActivityStore {
47 private static let maximumEntries = 5
48
49 static func load(defaults: UserDefaults) -> [RecentActivityEntry] {
50 guard let data = defaults.data(forKey: AppStorageKeys.recentActivity) else {
51 return []
52 }
53
54 do {
55 return try JSONDecoder().decode([RecentActivityEntry].self, from: data)
56 } catch {
57 defaults.removeObject(forKey: AppStorageKeys.recentActivity)
58 return []
59 }
60 }
61
62 static func recordRepository(
63 _ repository: RepositorySummary,
64 defaults: UserDefaults,
65 now: Date = .now
66 ) {
67 record(
68 RecentActivityEntry(
69 kind: .repository,
70 title: "\(repository.owner.canonicalName)/\(repository.name)",
71 viewedAt: now,
72 repositoryOwner: repository.owner.canonicalName.srhtUsername,
73 repositoryName: repository.name,
74 repositoryService: repository.service,
75 ticketOwnerUsername: nil,
76 ticketTrackerName: nil,
77 ticketId: nil,
78 buildJobId: nil
79 ),
80 defaults: defaults
81 )
82 }
83
84 static func recordTicket(
85 ownerUsername: String,
86 trackerName: String,
87 ticketId: Int,
88 title: String,
89 defaults: UserDefaults,
90 now: Date = .now
91 ) {
92 record(
93 RecentActivityEntry(
94 kind: .ticket,
95 title: "#\(ticketId) \(title)",
96 viewedAt: now,
97 repositoryOwner: nil,
98 repositoryName: nil,
99 repositoryService: nil,
100 ticketOwnerUsername: ownerUsername,
101 ticketTrackerName: trackerName,
102 ticketId: ticketId,
103 buildJobId: nil
104 ),
105 defaults: defaults
106 )
107 }
108
109 static func recordBuild(
110 jobId: Int,
111 title: String,
112 defaults: UserDefaults,
113 now: Date = .now
114 ) {
115 record(
116 RecentActivityEntry(
117 kind: .build,
118 title: title,
119 viewedAt: now,
120 repositoryOwner: nil,
121 repositoryName: nil,
122 repositoryService: nil,
123 ticketOwnerUsername: nil,
124 ticketTrackerName: nil,
125 ticketId: nil,
126 buildJobId: jobId
127 ),
128 defaults: defaults
129 )
130 }
131
132 private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) {
133 var entries = load(defaults: defaults)
134 entries.removeAll { $0.id == entry.id }
135 entries.insert(entry, at: 0)
136
137 if entries.count > maximumEntries {
138 entries = Array(entries.prefix(maximumEntries))
139 }
140
141 save(entries, defaults: defaults)
142 }
143
144 private static func save(_ entries: [RecentActivityEntry], defaults: UserDefaults) {
145 guard let data = try? JSONEncoder().encode(entries) else { return }
146 defaults.set(data, forKey: AppStorageKeys.recentActivity)
147 }
148}
Hutch/Views/Inbox/InboxView.swift +3 −189
@@ -1,196 +1,10 @@
1import SwiftUI 1import SwiftUI
2 2
3// Legacy wrapper kept temporarily so stale references continue to compile while
4// the app transitions from Inbox to Work.
3struct InboxView: View { 5struct InboxView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.scenePhase) private var scenePhase
6 @State private var viewModel: InboxViewModel?
7 @State private var selectedThreadID: InboxThreadSummary.ID?
8 @State private var selectedThreadSnapshot: InboxThreadSummary?
9 @State private var isShowingThreadDetail = false
10
11 var body: some View { 6 var body: some View {
12 Group { 7 WorkView()
13 if let viewModel {
14 listContent(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading inbox…")
17 }
18 }
19 .navigationTitle("Inbox")
20 .task {
21 let vm: InboxViewModel
22 if let viewModel {
23 vm = viewModel
24 } else {
25 let newViewModel = InboxViewModel(
26 client: appState.client,
27 defaults: appState.accountDefaults,
28 accountID: appState.activeAccountID
29 )
30 viewModel = newViewModel
31 vm = newViewModel
32 }
33
34 await vm.loadThreads()
35 }
36 .onChange(of: scenePhase) { _, newPhase in
37 guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return }
38 Task {
39 await viewModel.loadThreads()
40 }
41 }
42 }
43
44 @ViewBuilder
45 private func listContent(_ viewModel: InboxViewModel) -> some View {
46 @Bindable var vm = viewModel
47
48 List {
49 Section {
50 Picker("Filter", selection: $vm.filter) {
51 ForEach(InboxThreadFilter.allCases, id: \.self) { filter in
52 Text(filter.rawValue).tag(filter)
53 }
54 }
55 .pickerStyle(.segmented)
56 .listRowBackground(Color.clear)
57 .listRowInsets(EdgeInsets())
58 }
59
60 ForEach(viewModel.filteredThreads) { thread in
61 Button {
62 selectThread(thread)
63 } label: {
64 InboxThreadRow(thread: thread)
65 }
66 .buttonStyle(.plain)
67 .swipeActions(edge: .leading, allowsFullSwipe: true) {
68 readStateAction(for: thread, in: viewModel)
69 }
70 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
71 readStateAction(for: thread, in: viewModel)
72 }
73 }
74 }
75 .themedList()
76 .searchable(
77 text: $vm.searchText,
78 placement: .navigationBarDrawer(displayMode: .always),
79 prompt: "Search inbox"
80 )
81 .listStyle(.plain)
82 .toolbar {
83 ToolbarItem(placement: .topBarTrailing) {
84 if viewModel.hasUnreadThreads {
85 Button("Mark All Read") {
86 withAnimation(.easeInOut(duration: 0.2)) {
87 viewModel.markAllThreadsRead()
88 }
89 }
90 }
91 }
92 }
93 .overlay {
94 if viewModel.isLoading, viewModel.threads.isEmpty {
95 SRHTLoadingStateView(message: "Loading inbox…")
96 } else if let error = viewModel.error, viewModel.threads.isEmpty {
97 SRHTErrorStateView(
98 title: "Failed to load inbox",
99 message: error,
100 retryAction: { await viewModel.loadThreads() }
101 )
102 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
103 ContentUnavailableView.search(text: viewModel.searchText)
104 } else if viewModel.threads.isEmpty, viewModel.error == nil {
105 ContentUnavailableView(
106 "Inbox Zero",
107 systemImage: "tray",
108 description: Text("You're up to date.")
109 )
110 }
111 }
112 .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
113 await viewModel.loadThreads()
114 }
115 .srhtErrorBanner(error: $vm.error)
116 .refreshable {
117 await viewModel.loadThreads()
118 }
119 .onChange(of: viewModel.threads) { _, threads in
120 syncSelectedThreadSnapshot(with: threads)
121 }
122 .navigationDestination(isPresented: Binding(
123 get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil },
124 set: { isPresented in
125 if !isPresented {
126 clearSelection()
127 }
128 isShowingThreadDetail = isPresented
129 }
130 )) {
131 if let thread = selectedThread(for: viewModel) {
132 ThreadDetailView(thread: thread) {
133 viewModel.markThreadRead(thread)
134 }
135 .onAppear {
136 cacheSelectedThread(thread)
137 }
138 .onDisappear {
139 handleThreadDetailDisappear(for: thread.id)
140 }
141 } else {
142 ContentUnavailableView(
143 "Thread Unavailable",
144 systemImage: "tray",
145 description: Text("This thread could not be restored.")
146 )
147 }
148 }
149 }
150
151 @ViewBuilder
152 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
153 Button {
154 withAnimation(.easeInOut(duration: 0.2)) {
155 viewModel.markThreadRead(thread)
156 }
157 } label: {
158 Label("Mark as Read", systemImage: "envelope.open")
159 }
160 .tint(.blue)
161 }
162
163 private func selectThread(_ thread: InboxThreadSummary) {
164 cacheSelectedThread(thread)
165 isShowingThreadDetail = true
166 }
167
168 private func cacheSelectedThread(_ thread: InboxThreadSummary) {
169 selectedThreadID = thread.id
170 selectedThreadSnapshot = thread
171 }
172
173 private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? {
174 guard let selectedThreadID else { return selectedThreadSnapshot }
175 return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil)
176 }
177
178 private func handleThreadDetailDisappear(for threadID: String) {
179 let isActiveSelection = selectedThreadID == threadID
180 guard isActiveSelection else { return }
181 clearSelection()
182 }
183
184 private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) {
185 guard let selectedThreadID else { return }
186 guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return }
187 selectedThreadSnapshot = updatedThread
188 }
189
190 private func clearSelection() {
191 selectedThreadID = nil
192 selectedThreadSnapshot = nil
193 isShowingThreadDetail = false
194 } 8 }
195} 9}
196 10
Hutch/Views/Lookup/UserProfileView.swift +29
@@ -6,6 +6,7 @@ struct UserProfileView: View {
6 6
7 let user: User 7 let user: User
8 @State private var profileViewModel: UserProfileViewModel? 8 @State private var profileViewModel: UserProfileViewModel?
9 @State private var pinChangeCount = 0
9 10
10 private static let iso8601Formatter: ISO8601DateFormatter = { 11 private static let iso8601Formatter: ISO8601DateFormatter = {
11 let formatter = ISO8601DateFormatter() 12 let formatter = ISO8601DateFormatter()
@@ -14,6 +15,16 @@ struct UserProfileView: View {
14 return formatter 15 return formatter
15 }() 16 }()
16 17
18 private var currentUserKey: String? {
19 appState.currentUser?.canonicalName
20 }
21
22 private var isPinnedToHome: Bool {
23 _ = pinChangeCount
24 guard let currentUserKey else { return false }
25 return HomePinStore.isPinned(.user(user), for: currentUserKey, defaults: appState.accountDefaults)
26 }
27
17 var body: some View { 28 var body: some View {
18 List { 29 List {
19 if let avatarURL = user.avatar.flatMap(URL.init(string:)) { 30 if let avatarURL = user.avatar.flatMap(URL.init(string:)) {
@@ -164,6 +175,18 @@ struct UserProfileView: View {
164 .listStyle(.insetGrouped) 175 .listStyle(.insetGrouped)
165 .navigationTitle(user.canonicalName) 176 .navigationTitle(user.canonicalName)
166 .navigationBarTitleDisplayMode(.inline) 177 .navigationBarTitleDisplayMode(.inline)
178 .toolbar {
179 if currentUserKey != nil {
180 ToolbarItem(placement: .topBarTrailing) {
181 Button {
182 togglePinnedState()
183 } label: {
184 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
185 }
186 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
187 }
188 }
189 }
167 .task(id: user.canonicalName) { 190 .task(id: user.canonicalName) {
168 let owner = user.canonicalName.hasPrefix("~") 191 let owner = user.canonicalName.hasPrefix("~")
169 ? String(user.canonicalName.dropFirst()) 192 ? String(user.canonicalName.dropFirst())
@@ -200,6 +223,12 @@ struct UserProfileView: View {
200 } 223 }
201 } 224 }
202 225
226 private func togglePinnedState() {
227 guard let currentUserKey else { return }
228 HomePinStore.togglePin(.user(user), for: currentUserKey, defaults: appState.accountDefaults)
229 pinChangeCount += 1
230 }
231
203 private func formattedTimestamp(_ value: String) -> String { 232 private func formattedTimestamp(_ value: String) -> String {
204 guard let date = Self.iso8601Formatter.date(from: value) else { 233 guard let date = Self.iso8601Formatter.date(from: value) else {
205 return value 234 return value
Hutch/Views/Projects/ProjectMailingListView.swift +29
@@ -229,6 +229,17 @@ struct MailingListDetailView: View {
229 229
230 @Environment(AppState.self) private var appState 230 @Environment(AppState.self) private var appState
231 @State private var viewModel: MailingListDetailViewModel? 231 @State private var viewModel: MailingListDetailViewModel?
232 @State private var pinChangeCount = 0
233
234 private var currentUserKey: String? {
235 appState.currentUser?.canonicalName
236 }
237
238 private var isPinnedToHome: Bool {
239 _ = pinChangeCount
240 guard let currentUserKey else { return false }
241 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
242 }
232 243
233 var body: some View { 244 var body: some View {
234 Group { 245 Group {
@@ -240,6 +251,18 @@ struct MailingListDetailView: View {
240 } 251 }
241 .navigationTitle(mailingList.name) 252 .navigationTitle(mailingList.name)
242 .navigationBarTitleDisplayMode(.inline) 253 .navigationBarTitleDisplayMode(.inline)
254 .toolbar {
255 if currentUserKey != nil {
256 ToolbarItem(placement: .topBarTrailing) {
257 Button {
258 togglePinnedState()
259 } label: {
260 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
261 }
262 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
263 }
264 }
265 }
243 .task { 266 .task {
244 if viewModel == nil { 267 if viewModel == nil {
245 let viewModel = MailingListDetailViewModel( 268 let viewModel = MailingListDetailViewModel(
@@ -260,6 +283,12 @@ struct MailingListDetailView: View {
260 } 283 }
261 } 284 }
262 285
286 private func togglePinnedState() {
287 guard let currentUserKey else { return }
288 HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
289 pinChangeCount += 1
290 }
291
263 @ViewBuilder 292 @ViewBuilder
264 private func content(_ viewModel: MailingListDetailViewModel) -> some View { 293 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
265 @Bindable var vm = viewModel 294 @Bindable var vm = viewModel
Hutch/Views/Projects/ProjectPinStore.swift +29
@@ -5,6 +5,11 @@ enum ProjectPinStore {
5 for userKey: String, 5 for userKey: String,
6 defaults: UserDefaults = .standard 6 defaults: UserDefaults = .standard
7 ) -> [String] { 7 ) -> [String] {
8 let generalizedPins = HomePinStore.pinnedProjectIDs(for: userKey, defaults: defaults)
9 if !generalizedPins.isEmpty {
10 return generalizedPins
11 }
12
8 let pinnedProjects = loadAll(defaults: defaults) 13 let pinnedProjects = loadAll(defaults: defaults)
9 return normalizedProjectIDs(pinnedProjects[userKey] ?? []) 14 return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
10 } 15 }
@@ -22,6 +27,22 @@ enum ProjectPinStore {
22 for userKey: String, 27 for userKey: String,
23 defaults: UserDefaults = .standard 28 defaults: UserDefaults = .standard
24 ) { 29 ) {
30 let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
31 guard !trimmed.isEmpty else { return }
32
33 HomePinStore.togglePin(
34 HomePinRecord(
35 kind: .project,
36 value: trimmed,
37 title: "Pinned Project",
38 subtitle: "Project",
39 ownerUsername: nil,
40 service: nil
41 ),
42 for: userKey,
43 defaults: defaults
44 )
45
25 var pinnedProjects = loadAll(defaults: defaults) 46 var pinnedProjects = loadAll(defaults: defaults)
26 var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? []) 47 var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
27 48
@@ -37,6 +58,14 @@ enum ProjectPinStore {
37 save(pinnedProjects, defaults: defaults) 58 save(pinnedProjects, defaults: defaults)
38 } 59 }
39 60
61 static func loadLegacyPinnedProjectIDs(
62 for userKey: String,
63 defaults: UserDefaults = .standard
64 ) -> [String] {
65 let pinnedProjects = loadAll(defaults: defaults)
66 return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
67 }
68
40 private static func loadAll(defaults: UserDefaults) -> [String: [String]] { 69 private static func loadAll(defaults: UserDefaults) -> [String: [String]] {
41 guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { 70 guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else {
42 return [:] 71 return [:]
Hutch/Views/Repositories/HgRepositoryDetailView.swift +52 −11
@@ -19,12 +19,23 @@ struct HgRepositoryDetailView: View {
19 @State private var showShareUnavailableAlert = false 19 @State private var showShareUnavailableAlert = false
20 @State private var didCopyFileContents = false 20 @State private var didCopyFileContents = false
21 @State private var copyResetTask: Task<Void, Never>? 21 @State private var copyResetTask: Task<Void, Never>?
22 @State private var pinChangeCount = 0
22 23
23 private var canManageRepository: Bool { 24 private var canManageRepository: Bool {
24 guard let currentUser = appState.currentUser else { return false } 25 guard let currentUser = appState.currentUser else { return false }
25 return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) 26 return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
26 } 27 }
27 28
29 private var currentUserKey: String? {
30 appState.currentUser?.canonicalName
31 }
32
33 private var isPinnedToHome: Bool {
34 _ = pinChangeCount
35 guard let currentUserKey else { return false }
36 return HomePinStore.isPinned(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults)
37 }
38
28 private var shareURL: URL? { 39 private var shareURL: URL? {
29 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } 40 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
30 return SRHTWebURL.file( 41 return SRHTWebURL.file(
@@ -58,17 +69,7 @@ struct HgRepositoryDetailView: View {
58 } 69 }
59 } 70 }
60 71
61 SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { 72 repositoryActionsMenu
62 Image(systemName: "square.and.arrow.up")
63 }
64
65 if canManageRepository {
66 Button {
67 showSettings = true
68 } label: {
69 Image(systemName: "gear")
70 }
71 }
72 } 73 }
73 } 74 }
74 .sheet(isPresented: $showSettings) { 75 .sheet(isPresented: $showSettings) {
@@ -106,6 +107,46 @@ struct HgRepositoryDetailView: View {
106 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed 107 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
107 } 108 }
108 109
110 private func togglePinnedState() {
111 guard let currentUserKey else { return }
112 HomePinStore.togglePin(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults)
113 pinChangeCount += 1
114 }
115
116 private var repositoryActionsMenu: some View {
117 Menu {
118 if currentUserKey != nil {
119 Button {
120 togglePinnedState()
121 } label: {
122 Label(
123 isPinnedToHome ? "Unpin from Home" : "Pin to Home",
124 systemImage: isPinnedToHome ? "pin.slash" : "pin"
125 )
126 }
127 }
128
129 if let shareURL = SRHTWebURL.repository(repository) {
130 ShareLink(item: shareURL) {
131 Label("Share", systemImage: "square.and.arrow.up")
132 }
133 }
134
135 if canManageRepository {
136 Divider()
137
138 Button {
139 showSettings = true
140 } label: {
141 Label("Repository Settings", systemImage: "gear")
142 }
143 }
144 } label: {
145 Image(systemName: "ellipsis.circle")
146 }
147 .accessibilityLabel("Repository actions")
148 }
149
109 @ViewBuilder 150 @ViewBuilder
110 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { 151 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
111 VStack(spacing: 0) { 152 VStack(spacing: 0) {
Hutch/Views/Repositories/RepositoryDetailView.swift +38 −5
@@ -13,12 +13,23 @@ struct RepositoryDetailView: View {
13 @State private var showSettings = false 13 @State private var showSettings = false
14 @State private var showACLs = false 14 @State private var showACLs = false
15 @State private var currentRepository: RepositorySummary 15 @State private var currentRepository: RepositorySummary
16 @State private var pinChangeCount = 0
16 17
17 private var canManageRepository: Bool { 18 private var canManageRepository: Bool {
18 guard let currentUser = appState.currentUser else { return false } 19 guard let currentUser = appState.currentUser else { return false }
19 return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) 20 return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName)
20 } 21 }
21 22
23 private var currentUserKey: String? {
24 appState.currentUser?.canonicalName
25 }
26
27 private var isPinnedToHome: Bool {
28 _ = pinChangeCount
29 guard let currentUserKey else { return false }
30 return HomePinStore.isPinned(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults)
31 }
32
22 init( 33 init(
23 repository: RepositorySummary, 34 repository: RepositorySummary,
24 onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, 35 onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
@@ -43,12 +54,8 @@ struct RepositoryDetailView: View {
43 .navigationTitle(currentRepository.name) 54 .navigationTitle(currentRepository.name)
44 .navigationBarTitleDisplayMode(.inline) 55 .navigationBarTitleDisplayMode(.inline)
45 .toolbar { 56 .toolbar {
46 ToolbarItemGroup(placement: .topBarTrailing) { 57 ToolbarItem(placement: .topBarTrailing) {
47 repositoryActionsMenu 58 repositoryActionsMenu
48
49 SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
50 Image(systemName: "square.and.arrow.up")
51 }
52 } 59 }
53 } 60 }
54 .sheet(isPresented: $showSettings) { 61 .sheet(isPresented: $showSettings) {
@@ -82,6 +89,7 @@ struct RepositoryDetailView: View {
82 client: appState.client 89 client: appState.client
83 ) 90 )
84 } 91 }
92 RecentActivityStore.recordRepository(currentRepository, defaults: appState.accountDefaults)
85 } 93 }
86 } 94 }
87 } 95 }
@@ -130,6 +138,25 @@ struct RepositoryDetailView: View {
130 138
131 private var repositoryActionsMenu: some View { 139 private var repositoryActionsMenu: some View {
132 Menu { 140 Menu {
141 if currentUserKey != nil {
142 Button {
143 togglePinnedState()
144 } label: {
145 Label(
146 isPinnedToHome ? "Unpin from Home" : "Pin to Home",
147 systemImage: isPinnedToHome ? "pin.slash" : "pin"
148 )
149 }
150 }
151
152 if let shareURL = SRHTWebURL.repository(currentRepository) {
153 ShareLink(item: shareURL) {
154 Label("Share", systemImage: "square.and.arrow.up")
155 }
156 }
157
158 Divider()
159
133 if let repositoryURL = SRHTWebURL.repository(currentRepository) { 160 if let repositoryURL = SRHTWebURL.repository(currentRepository) {
134 Button { 161 Button {
135 openURL(repositoryURL) 162 openURL(repositoryURL)
@@ -184,4 +211,10 @@ struct RepositoryDetailView: View {
184 } 211 }
185 .accessibilityLabel("Repository actions") 212 .accessibilityLabel("Repository actions")
186 } 213 }
214
215 private func togglePinnedState() {
216 guard let currentUserKey else { return }
217 HomePinStore.togglePin(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults)
218 pinChangeCount += 1
219 }
187} 220}
Hutch/Views/Settings/SettingsView.swift +13 −1
@@ -264,10 +264,22 @@ private struct AboutView: View {
264 get: { appState.isDebugModeEnabled }, 264 get: { appState.isDebugModeEnabled },
265 set: { appState.isDebugModeEnabled = $0 } 265 set: { appState.isDebugModeEnabled = $0 }
266 )) 266 ))
267
268 NavigationLink {
269 HomePrototypeView()
270 } label: {
271 SwiftUI.Label("Home Prototype", systemImage: "house")
272 }
273
274 NavigationLink {
275 WorkPrototypeView()
276 } label: {
277 SwiftUI.Label("Work Prototype", systemImage: "tray.full")
278 }
267 } header: { 279 } header: {
268 Text("Developer") 280 Text("Developer")
269 } footer: { 281 } footer: {
270 Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.") 282 Text("Shows raw API payloads and diagnostic details on builds and tickets screens. Home Prototype explores the dashboard structure, while Work Prototype evaluates the personal queue surface. This stays hidden until explicitly enabled.")
271 } 283 }
272 } 284 }
273 } 285 }
Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift +4 −6
@@ -63,7 +63,8 @@ struct SystemStatusSummaryRow: View {
63 63
64 private var primaryMessage: String { 64 private var primaryMessage: String {
65 if let snapshot { 65 if let snapshot {
66 return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText 66 let summary = snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText
67 return "\(summary) • Updated \(snapshot.lastUpdated.relativeDescription)"
67 } 68 }
68 if let errorMessage, !errorMessage.isEmpty { 69 if let errorMessage, !errorMessage.isEmpty {
69 return errorMessage 70 return errorMessage
@@ -75,11 +76,8 @@ struct SystemStatusSummaryRow: View {
75 } 76 }
76 77
77 private var metadataMessage: String? { 78 private var metadataMessage: String? {
78 if let snapshot { 79 if snapshot != nil, isShowingStaleData {
79 if isShowingStaleData { 80 return "Showing saved data"
80 return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data"
81 }
82 return "Updated \(snapshot.lastUpdated.relativeDescription)"
83 } 81 }
84 if errorMessage != nil { 82 if errorMessage != nil {
85 return "Open System Status to retry." 83 return "Open System Status to retry."
Hutch/Views/Tickets/TicketDetailView.swift +9
@@ -220,6 +220,15 @@ struct TicketDetailView: View {
220 commentInput(viewModel) 220 commentInput(viewModel)
221 } 221 }
222 } 222 }
223 .task(id: ticket.id) {
224 RecentActivityStore.recordTicket(
225 ownerUsername: ownerUsername,
226 trackerName: trackerName,
227 ticketId: ticket.id,
228 title: ticket.title,
229 defaults: appState.accountDefaults
230 )
231 }
223 .srhtErrorBanner(error: $vm.error) 232 .srhtErrorBanner(error: $vm.error)
224 .refreshable { 233 .refreshable {
225 await reloadDetail(viewModel) 234 await reloadDetail(viewModel)
Hutch/Views/Tickets/TicketListView.swift +49 −15
@@ -23,12 +23,29 @@ struct TicketListView: View {
23 @State private var showBulkCloseSheet = false 23 @State private var showBulkCloseSheet = false
24 @State private var showBulkAssignSheet = false 24 @State private var showBulkAssignSheet = false
25 @State private var bulkActionResult: TicketBulkActionResult? 25 @State private var bulkActionResult: TicketBulkActionResult?
26 @State private var pinChangeCount = 0
26 27
27 private var isOwnedByCurrentUser: Bool { 28 private var isOwnedByCurrentUser: Bool {
28 guard let currentUser = appState.currentUser else { return false } 29 guard let currentUser = appState.currentUser else { return false }
29 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) 30 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
30 } 31 }
31 32
33 private var currentUserKey: String? {
34 appState.currentUser?.canonicalName
35 }
36
37 private var isPinnedToHome: Bool {
38 _ = pinChangeCount
39 guard let currentUserKey else { return false }
40 return HomePinStore.isPinned(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
41 }
42
43 private var navigationTitle: String {
44 guard let viewModel, viewModel.isSelectionMode else { return tracker.name }
45 let count = viewModel.selectedTicketCount
46 return count == 0 ? "Select Tickets" : "\(count) Selected"
47 }
48
32 init( 49 init(
33 tracker: TrackerSummary, 50 tracker: TrackerSummary,
34 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }, 51 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ },
@@ -47,7 +64,7 @@ struct TicketListView: View {
47 SRHTLoadingStateView(message: "Loading tickets…") 64 SRHTLoadingStateView(message: "Loading tickets…")
48 } 65 }
49 } 66 }
50 .navigationTitle(tracker.name) 67 .navigationTitle(navigationTitle)
51 .navigationBarTitleDisplayMode(.inline) 68 .navigationBarTitleDisplayMode(.inline)
52 .toolbar { 69 .toolbar {
53 ToolbarItem(placement: .topBarLeading) { 70 ToolbarItem(placement: .topBarLeading) {
@@ -66,16 +83,6 @@ struct TicketListView: View {
66 } 83 }
67 .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) 84 .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
68 } else { 85 } else {
69 SRHTShareButton(
70 url: SRHTWebURL.tracker(
71 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
72 trackerName: tracker.name
73 ),
74 target: .tracker
75 ) {
76 Image(systemName: "square.and.arrow.up")
77 }
78
79 Button { 86 Button {
80 showCreateTicketSheet = true 87 showCreateTicketSheet = true
81 } label: { 88 } label: {
@@ -83,10 +90,6 @@ struct TicketListView: View {
83 } 90 }
84 .accessibilityLabel("Create ticket") 91 .accessibilityLabel("Create ticket")
85 92
86 Button("Select") {
87 viewModel?.setSelectionMode(true)
88 }
89
90 trackerActionsMenu 93 trackerActionsMenu
91 } 94 }
92 } 95 }
@@ -242,6 +245,12 @@ struct TicketListView: View {
242 } 245 }
243 } 246 }
244 247
248 private func togglePinnedState() {
249 guard let currentUserKey else { return }
250 HomePinStore.togglePin(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults)
251 pinChangeCount += 1
252 }
253
245 @ViewBuilder 254 @ViewBuilder
246 private func listContent(_ viewModel: TicketListViewModel) -> some View { 255 private func listContent(_ viewModel: TicketListViewModel) -> some View {
247 @Bindable var vm = viewModel 256 @Bindable var vm = viewModel
@@ -429,6 +438,31 @@ struct TicketListView: View {
429 438
430 private var trackerActionsMenu: some View { 439 private var trackerActionsMenu: some View {
431 Menu { 440 Menu {
441 Button {
442 viewModel?.setSelectionMode(true)
443 } label: {
444 Label("Select", systemImage: "checkmark.circle")
445 }
446
447 if currentUserKey != nil {
448 Button {
449 togglePinnedState()
450 } label: {
451 Label(
452 isPinnedToHome ? "Unpin from Home" : "Pin to Home",
453 systemImage: isPinnedToHome ? "pin.slash" : "pin"
454 )
455 }
456 }
457
458 if let shareURL = SRHTWebURL.tracker(tracker) {
459 ShareLink(item: shareURL) {
460 Label("Share", systemImage: "square.and.arrow.up")
461 }
462 }
463
464 Divider()
465
432 if let trackerURL = SRHTWebURL.tracker(tracker) { 466 if let trackerURL = SRHTWebURL.tracker(tracker) {
433 Button { 467 Button {
434 openURL(trackerURL) 468 openURL(trackerURL)
Hutch/Views/Work/WorkPrototypeView.swift added +7
@@ -0,0 +1,7 @@
1import SwiftUI
2
3struct WorkPrototypeView: View {
4 var body: some View {
5 WorkView()
6 }
7}
Hutch/Views/Work/WorkView.swift added +351
@@ -0,0 +1,351 @@
1import SwiftUI
2
3struct WorkView: View {
4 private enum Scope: String, CaseIterable, Identifiable {
5 case all = "All"
6 case unread = "Unread"
7 case assigned = "Assigned"
8
9 var id: String { rawValue }
10 }
11
12 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
13 @Environment(AppState.self) private var appState
14 @Environment(\.scenePhase) private var scenePhase
15 @State private var viewModel: HomeViewModel?
16 @State private var scope: Scope = .all
17
18 var body: some View {
19 Group {
20 if let viewModel {
21 content(viewModel)
22 } else {
23 SRHTLoadingStateView(message: "Loading Work…")
24 }
25 }
26 .navigationTitle("Work")
27 .navigationBarTitleDisplayMode(.inline)
28 .task {
29 guard let currentUser = appState.currentUser else { return }
30 await ensureViewModel(currentUser: currentUser).loadDashboard()
31 }
32 .onChange(of: scenePhase) { _, newPhase in
33 guard newPhase == .active, let viewModel else { return }
34 Task {
35 await viewModel.loadDashboard()
36 }
37 }
38 }
39
40 @ViewBuilder
41 private func content(_ viewModel: HomeViewModel) -> some View {
42 List {
43 headerSection(viewModel)
44 scopeSection
45
46 switch scope {
47 case .all:
48 allScopeContent(viewModel)
49 case .unread:
50 unreadSection(viewModel, compactWhenEmpty: true)
51 case .assigned:
52 assignedSection(viewModel)
53 }
54 }
55 .themedList()
56 .listStyle(.insetGrouped)
57 .refreshable {
58 await viewModel.loadDashboard()
59 }
60 .connectivityOverlay(hasContent: hasWorkContent(viewModel)) {
61 await viewModel.loadDashboard()
62 }
63 }
64
65 private func headerSection(_ viewModel: HomeViewModel) -> some View {
66 Section {
67 VStack(alignment: .leading, spacing: 6) {
68 Text(title(viewModel))
69 .font(.headline)
70 if workCount(viewModel) > 0 {
71 Text(summary(viewModel))
72 .font(.subheadline)
73 .foregroundStyle(.secondary)
74 }
75 }
76 .padding(.vertical, 2)
77 }
78 }
79
80 @ViewBuilder
81 private func allScopeContent(_ viewModel: HomeViewModel) -> some View {
82 if unreadCount(viewModel) > 0 {
83 unreadSection(viewModel, compactWhenEmpty: false)
84 }
85
86 assignedSection(viewModel)
87
88 if workCount(viewModel) == 0 {
89 Section {
90 WorkCompactMessageRow(text: "Nothing to do", systemImage: "checkmark.circle")
91 }
92 }
93 }
94
95 private var scopeSection: some View {
96 Section {
97 Picker("Scope", selection: $scope) {
98 ForEach(Scope.allCases) { scope in
99 Text(scope.rawValue).tag(scope)
100 }
101 }
102 .pickerStyle(.segmented)
103 .listRowBackground(Color.clear)
104 .listRowInsets(EdgeInsets())
105 }
106 }
107
108 @ViewBuilder
109 private func unreadSection(_ viewModel: HomeViewModel, compactWhenEmpty: Bool) -> some View {
110 Section {
111 if isLoadingUnread(viewModel) {
112 WorkLoadingRow(label: "Loading unread threads")
113 } else if viewModel.unreadInboxThreads.isEmpty {
114 if compactWhenEmpty {
115 WorkCompactMessageRow(text: "No unread threads", systemImage: "tray")
116 }
117 } else {
118 ForEach(viewModel.unreadInboxThreads) { thread in
119 NavigationLink {
120 ThreadDetailView(
121 thread: thread,
122 onViewed: { viewModel.markInboxThreadRead(thread) },
123 onMarkRead: { viewModel.markInboxThreadRead(thread) },
124 onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
125 )
126 } label: {
127 WorkThreadRow(thread: thread)
128 }
129 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
130 if swipeActionsEnabled {
131 Button {
132 viewModel.markInboxThreadRead(thread)
133 } label: {
134 Label("Mark Read", systemImage: "envelope.open")
135 }
136 .tint(.blue)
137 }
138 }
139 }
140 }
141 } header: {
142 Text("Unread Threads")
143 } footer: {
144 NavigationLink {
145 MailingListListView()
146 } label: {
147 Label("Open mailing list workspace", systemImage: "list.bullet")
148 .font(.subheadline.weight(.medium))
149 }
150 }
151 }
152
153 @ViewBuilder
154 private func assignedSection(_ viewModel: HomeViewModel) -> some View {
155 Section {
156 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
157 WorkLoadingRow(label: "Loading assigned tickets")
158 } else if viewModel.assignedTickets.isEmpty {
159 WorkCompactMessageRow(text: "No assigned tickets", systemImage: "person.crop.circle.badge.checkmark")
160 } else {
161 ForEach(viewModel.assignedTickets) { ticket in
162 NavigationLink {
163 TicketDetailView(
164 ownerUsername: ticket.ownerUsername,
165 trackerName: ticket.trackerName,
166 trackerId: ticket.trackerId,
167 trackerRid: ticket.trackerRid,
168 ticketId: ticket.ticket.id
169 )
170 } label: {
171 WorkAssignedTicketRow(ticket: ticket)
172 }
173 .swipeActions(edge: .leading, allowsFullSwipe: true) {
174 if swipeActionsEnabled {
175 if ticket.ticket.status.isOpen {
176 Button {
177 Task { await viewModel.resolveTicket(ticket) }
178 } label: {
179 Label("Resolve", systemImage: "checkmark.circle")
180 }
181 .tint(.green)
182 } else {
183 Button {
184 Task { await viewModel.reopenTicket(ticket) }
185 } label: {
186 Label("Reopen", systemImage: "arrow.uturn.backward")
187 }
188 .tint(.blue)
189 }
190 }
191 }
192 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
193 if swipeActionsEnabled {
194 Button {
195 Task { await viewModel.unassignFromMe(ticket) }
196 } label: {
197 Label("Unassign", systemImage: "person.badge.minus")
198 }
199 .tint(.orange)
200 }
201 }
202 }
203 }
204 } header: {
205 Text("Assigned Tickets")
206 } footer: {
207 NavigationLink {
208 TrackerListView()
209 } label: {
210 Label("Open tracker workspace", systemImage: "checklist")
211 .font(.subheadline.weight(.medium))
212 }
213 }
214 }
215
216 private func title(_ viewModel: HomeViewModel) -> String {
217 let count = workCount(viewModel)
218 if count == 0 {
219 return "Queue clear"
220 }
221 return "\(count) item\(count == 1 ? "" : "s") need attention"
222 }
223
224 private func summary(_ viewModel: HomeViewModel) -> String {
225 "\(unreadCount(viewModel)) unread • \(viewModel.assignedTickets.count) assigned"
226 }
227
228 private func workCount(_ viewModel: HomeViewModel) -> Int {
229 unreadCount(viewModel) + viewModel.assignedTickets.count
230 }
231
232 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
233 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
234 }
235
236 private func isLoadingUnread(_ viewModel: HomeViewModel) -> Bool {
237 viewModel.unreadInboxThreadCount == nil && viewModel.unreadInboxThreads.isEmpty
238 }
239
240 private func hasWorkContent(_ viewModel: HomeViewModel) -> Bool {
241 workCount(viewModel) > 0
242 }
243
244 @MainActor
245 private func ensureViewModel(currentUser: User) -> HomeViewModel {
246 if let viewModel {
247 return viewModel
248 }
249
250 let newViewModel = HomeViewModel(
251 currentUser: currentUser,
252 client: appState.client,
253 systemStatusRepository: appState.systemStatusRepository,
254 defaults: appState.accountDefaults,
255 accountID: appState.activeAccountID
256 )
257 viewModel = newViewModel
258 return newViewModel
259 }
260}
261
262private struct WorkThreadRow: View {
263 let thread: InboxThreadSummary
264
265 var body: some View {
266 VStack(alignment: .leading, spacing: 6) {
267 HStack(alignment: .top, spacing: 8) {
268 Circle()
269 .fill(thread.isUnread ? .blue : .clear)
270 .frame(width: 8, height: 8)
271 .padding(.top, 5)
272
273 Text(thread.displaySubject)
274 .font(.subheadline.weight(.semibold))
275 .lineLimit(2)
276 }
277
278 Text(thread.listDisplayName)
279 .font(.caption)
280 .foregroundStyle(.secondary)
281 .lineLimit(1)
282
283 Text(thread.metadataLine)
284 .font(.caption)
285 .foregroundStyle(.tertiary)
286 .lineLimit(1)
287 }
288 .padding(.vertical, 2)
289 }
290}
291
292private struct WorkAssignedTicketRow: View {
293 let ticket: HomeAssignedTicket
294
295 var body: some View {
296 VStack(alignment: .leading, spacing: 6) {
297 HStack(alignment: .firstTextBaseline, spacing: 8) {
298 Text("#\(ticket.ticket.id)")
299 .font(.caption.monospacedDigit())
300 .foregroundStyle(.secondary)
301
302 Text(ticket.ticket.title)
303 .font(.subheadline.weight(.semibold))
304 .lineLimit(2)
305
306 Spacer(minLength: 8)
307
308 Text(ticket.ticket.status.displayName)
309 .font(.caption2.weight(.semibold))
310 .foregroundStyle(ticket.ticket.status.isOpen ? .orange : .secondary)
311 }
312
313 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName)")
314 .font(.caption)
315 .foregroundStyle(.secondary)
316 .lineLimit(1)
317
318 Text(ticket.ticket.created.relativeDescription)
319 .font(.caption)
320 .foregroundStyle(.tertiary)
321 }
322 .padding(.vertical, 2)
323 }
324}
325
326private struct WorkCompactMessageRow: View {
327 let text: String
328 let systemImage: String
329
330 var body: some View {
331 Label(text, systemImage: systemImage)
332 .font(.caption)
333 .foregroundStyle(.secondary)
334 .padding(.vertical, 2)
335 }
336}
337
338private struct WorkLoadingRow: View {
339 let label: String
340
341 var body: some View {
342 HStack(spacing: 10) {
343 ProgressView()
344 .controlSize(.small)
345 Text(label)
346 .font(.subheadline)
347 .foregroundStyle(.secondary)
348 }
349 .padding(.vertical, 4)
350 }
351}
labels.sh deleted −96
@@ -1,96 +0,0 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4TRACKER="hutch"
5ERR_FILE="$(mktemp)"
6trap 'rm -f "$ERR_FILE"' EXIT
7
8label_ticket() {
9 local ticket_id="$1"
10 shift
11
12 for label in "$@"; do
13 echo "Applying label '$label' to ticket #$ticket_id"
14 if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then
15 if grep -q "already assigned to this ticket" "$ERR_FILE"; then
16 echo " already present, skipping"
17 else
18 cat "$ERR_FILE" >&2
19 exit 1
20 fi
21 fi
22 done
23}
24
25# v2.16.x
26label_ticket 14 enhancement ui ux # Home: Collapsible Sections
27label_ticket 15 enhancement ui # Home: See All Navigation
28label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping
29
30label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle
31label_ticket 18 enhancement builds # Builds: Per-Repo Filter
32label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish
33
34label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions
35label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility
36
37label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes
38label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish
39
40label_ticket 24 new-feature theming ui # Theming: AMOLED Mode
41label_ticket 25 new-feature theming ui # Theming: High-Density Mode
42
43# v2.17.x
44label_ticket 26 new-feature projects # Projects: Project List View
45label_ticket 27 new-feature projects # Projects: Project Detail View
46label_ticket 28 enhancement projects ui # Projects: Pin to Home
47
48label_ticket 29 enhancement builds # Builds: Log Search
49label_ticket 30 enhancement builds # Builds: Jump to Error
50label_ticket 31 enhancement builds # Builds: Artifact List
51
52label_ticket 32 enhancement tickets # Tickets: Saved Filters
53label_ticket 33 enhancement tickets # Tickets: Label Filtering
54
55label_ticket 34 enhancement inbox # Inbox: Basic Threading
56label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs
57
58# v2.18.x
59label_ticket 36 new-feature projects # Projects: Create Project
60label_ticket 37 enhancement projects # Projects: Edit Project
61label_ticket 38 enhancement projects # Projects: Manage Linked Resources
62
63label_ticket 39 new-feature acl repo # ACL: View Permissions
64label_ticket 40 enhancement acl repo # ACL: Add and Remove Users
65label_ticket 41 enhancement acl repo # ACL: Edit Permissions
66
67label_ticket 42 enhancement repo # Repo Settings: Edit Metadata
68label_ticket 43 enhancement repo # Repo Settings: Set Default Branch
69label_ticket 44 enhancement repo # Repo Settings: Visibility Changes
70
71label_ticket 45 new-feature tickets # Tickets: Label CRUD
72label_ticket 46 enhancement tickets # Tickets: Bulk Actions
73
74# v2.19.x
75label_ticket 47 new-feature search # Search: Per-Type Search
76label_ticket 48 enhancement search # Search: Recent Searches
77
78label_ticket 49 new-feature accounts # Accounts: Multi-Account Support
79label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI
80label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching
81
82label_ticket 52 enhancement power # Power: Open in Browser
83label_ticket 53 enhancement power # Power: Copy Actions
84label_ticket 54 enhancement power # Power: Debug View Toggle
85
86# v2.20.x
87label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation
88
89label_ticket 56 enhancement performance # Performance: Caching Improvements
90label_ticket 57 enhancement performance # Performance: List Virtualization
91
92label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity
93
94label_ticket 59 bug api # Errors: Normalize GraphQL Handling
95
96echo "Done."