Commit 0c4b027468

0c4b0274687b483f8ec96aea0b8cce3aa89ff25b

parent: 30bac29e44

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 03:06 UTC

feat: polish read-only project views and home integration

Refs: https://todo.sr.ht/~ccleberg/hutch/36
Refs: https://todo.sr.ht/~ccleberg/hutch/37
Refs: https://todo.sr.ht/~ccleberg/hutch/38

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 41; 518 CURRENT_PROJECT_VERSION = 43;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.18.0; 535 MARKETING_VERSION = 2.19.0;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 41; 555 CURRENT_PROJECT_VERSION = 43;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.18.0; 572 MARKETING_VERSION = 2.19.0;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 41; 635 CURRENT_PROJECT_VERSION = 43;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.18.0; 645 MARKETING_VERSION = 2.19.0;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 41; 664 CURRENT_PROJECT_VERSION = 43;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.18.0; 674 MARKETING_VERSION = 2.19.0;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/Extensions/SRHTWebURL.swift +16
@@ -63,6 +63,22 @@ enum SRHTWebURL {
63 ) 63 )
64 } 64 }
65 65
66 static func projectSource(_ source: Project.SourceRepo) -> URL? {
67 userScopedURL(
68 host: "\(source.repoType.service.rawValue).sr.ht",
69 ownerUsername: source.ownerUsername,
70 pathComponents: [source.name]
71 )
72 }
73
74 static func mailingList(ownerUsername: String, listName: String) -> URL? {
75 userScopedURL(
76 host: "lists.sr.ht",
77 ownerUsername: ownerUsername,
78 pathComponents: [listName]
79 )
80 }
81
66 static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? { 82 static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? {
67 userScopedURL( 83 userScopedURL(
68 host: "todo.sr.ht", 84 host: "todo.sr.ht",
Hutch/Models/Project.swift +106
@@ -115,11 +115,104 @@ struct Project: Identifiable, Hashable, Sendable {
115 return nil 115 return nil
116 } 116 }
117 117
118 var displayName: String {
119 Self.normalizedText(name) ?? "Untitled Project"
120 }
121
122 var displayDescription: String? {
123 Self.normalizedText(description)
124 }
125
126 var displayTags: [String] {
127 var seen = Set<String>()
128 return tags.compactMap { tag in
129 guard let normalized = Self.normalizedText(tag) else { return nil }
130 let key = normalized.lowercased()
131 guard seen.insert(key).inserted else { return nil }
132 return normalized
133 }
134 }
135
136 var websiteURL: URL? {
137 guard let website = Self.normalizedText(website) else { return nil }
138 return URL(string: website)
139 }
140
141 var hasLinkedResources: Bool {
142 !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
143 }
144
145 var metadataLine: String {
146 var parts = [visibility.displayName, updated.relativeDescription]
147 if let summary = resourceSummary {
148 parts.append(summary)
149 }
150 return parts.joined(separator: " • ")
151 }
152
118 private static func resourceCountText(count: Int, singular: String) -> String? { 153 private static func resourceCountText(count: Int, singular: String) -> String? {
119 guard count > 0 else { return nil } 154 guard count > 0 else { return nil }
120 let label = count == 1 ? singular : "\(singular)s" 155 let label = count == 1 ? singular : "\(singular)s"
121 return "\(count) \(label)" 156 return "\(count) \(label)"
122 } 157 }
158
159 fileprivate static func normalizedText(_ value: String?) -> String? {
160 guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
161 !trimmed.isEmpty else {
162 return nil
163 }
164 return trimmed
165 }
166}
167
168extension Project.MailingList {
169 var displayName: String {
170 Project.normalizedText(name) ?? "Untitled Mailing List"
171 }
172
173 var displayDescription: String? {
174 Project.normalizedText(description)
175 }
176
177 var ownerDisplayName: String {
178 Project.normalizedText(owner.canonicalName) ?? "~unknown"
179 }
180}
181
182extension Project.SourceRepo {
183 var displayName: String {
184 Project.normalizedText(name) ?? "Untitled Repository"
185 }
186
187 var displayDescription: String? {
188 Project.normalizedText(description)
189 }
190
191 var ownerDisplayName: String {
192 Project.normalizedText(owner.canonicalName) ?? "~unknown"
193 }
194
195 var webURL: URL? {
196 SRHTWebURL.projectSource(self)
197 }
198}
199
200extension Project.Tracker {
201 var displayName: String {
202 Project.normalizedText(name) ?? "Untitled Tracker"
203 }
204
205 var displayDescription: String? {
206 Project.normalizedText(description)
207 }
208
209 var ownerDisplayName: String {
210 Project.normalizedText(owner.canonicalName) ?? "~unknown"
211 }
212
213 var webURL: URL? {
214 SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
215 }
123} 216}
124 217
125extension String { 218extension String {
@@ -127,3 +220,16 @@ extension String {
127 hasPrefix("~") ? String(dropFirst()) : self 220 hasPrefix("~") ? String(dropFirst()) : self
128 } 221 }
129} 222}
223
224extension Visibility {
225 var displayName: String {
226 switch self {
227 case .public:
228 "Public"
229 case .unlisted:
230 "Unlisted"
231 case .private:
232 "Private"
233 }
234 }
235}
Hutch/Networking/ProjectService.swift +118
@@ -11,6 +11,17 @@ private struct ProjectPageUser: Decodable, Sendable {
11private struct ProjectPage: Decodable, Sendable { 11private struct ProjectPage: Decodable, Sendable {
12 let results: [ProjectSummaryPayload] 12 let results: [ProjectSummaryPayload]
13 let cursor: String? 13 let cursor: String?
14
15 private enum CodingKeys: String, CodingKey {
16 case results
17 case cursor
18 }
19
20 init(from decoder: any Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22 results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
23 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
24 }
14} 25}
15 26
16private struct ProjectSummaryPayload: Decodable, Sendable { 27private struct ProjectSummaryPayload: Decodable, Sendable {
@@ -21,6 +32,27 @@ private struct ProjectSummaryPayload: Decodable, Sendable {
21 let visibility: Visibility 32 let visibility: Visibility
22 let tags: [String] 33 let tags: [String]
23 let updated: Date 34 let updated: Date
35
36 private enum CodingKeys: String, CodingKey {
37 case rid
38 case name
39 case description
40 case website
41 case visibility
42 case tags
43 case updated
44 }
45
46 init(from decoder: any Decoder) throws {
47 let container = try decoder.container(keyedBy: CodingKeys.self)
48 rid = try container.decode(String.self, forKey: .rid)
49 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
50 description = try container.decodeIfPresent(String.self, forKey: .description)
51 website = try container.decodeIfPresent(String.self, forKey: .website)
52 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
53 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
54 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
55 }
24} 56}
25 57
26private struct ProjectDetailResponse: Decodable, Sendable { 58private struct ProjectDetailResponse: Decodable, Sendable {
@@ -38,11 +70,40 @@ private struct ProjectDetailPayload: Decodable, Sendable {
38 let mailingLists: ProjectMailingListPage 70 let mailingLists: ProjectMailingListPage
39 let sources: ProjectSourcePage 71 let sources: ProjectSourcePage
40 let trackers: ProjectTrackerPage 72 let trackers: ProjectTrackerPage
73
74 private enum CodingKeys: String, CodingKey {
75 case rid
76 case name
77 case description
78 case website
79 case visibility
80 case tags
81 case updated
82 case mailingLists
83 case sources
84 case trackers
85 }
86
87 init(from decoder: any Decoder) throws {
88 let container = try decoder.container(keyedBy: CodingKeys.self)
89 rid = try container.decode(String.self, forKey: .rid)
90 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
91 description = try container.decodeIfPresent(String.self, forKey: .description)
92 website = try container.decodeIfPresent(String.self, forKey: .website)
93 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
94 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
95 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
96 mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
97 sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
98 trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
99 }
41} 100}
42 101
43private struct ProjectMailingListPage: Decodable, Sendable { 102private struct ProjectMailingListPage: Decodable, Sendable {
44 let results: [ProjectMailingListPayload] 103 let results: [ProjectMailingListPayload]
45 let cursor: String? 104 let cursor: String?
105
106 static let empty = ProjectMailingListPage(results: [], cursor: nil)
46} 107}
47 108
48private struct ProjectMailingListPayload: Decodable, Sendable { 109private struct ProjectMailingListPayload: Decodable, Sendable {
@@ -51,11 +112,30 @@ private struct ProjectMailingListPayload: Decodable, Sendable {
51 let description: String? 112 let description: String?
52 let visibility: Visibility 113 let visibility: Visibility
53 let owner: Entity 114 let owner: Entity
115
116 private enum CodingKeys: String, CodingKey {
117 case rid
118 case name
119 case description
120 case visibility
121 case owner
122 }
123
124 init(from decoder: any Decoder) throws {
125 let container = try decoder.container(keyedBy: CodingKeys.self)
126 rid = try container.decode(String.self, forKey: .rid)
127 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
128 description = try container.decodeIfPresent(String.self, forKey: .description)
129 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
130 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
131 }
54} 132}
55 133
56private struct ProjectSourcePage: Decodable, Sendable { 134private struct ProjectSourcePage: Decodable, Sendable {
57 let results: [ProjectSourcePayload] 135 let results: [ProjectSourcePayload]
58 let cursor: String? 136 let cursor: String?
137
138 static let empty = ProjectSourcePage(results: [], cursor: nil)
59} 139}
60 140
61private struct ProjectSourcePayload: Decodable, Sendable { 141private struct ProjectSourcePayload: Decodable, Sendable {
@@ -65,11 +145,32 @@ private struct ProjectSourcePayload: Decodable, Sendable {
65 let visibility: Visibility 145 let visibility: Visibility
66 let owner: Entity 146 let owner: Entity
67 let repoType: Project.SourceRepo.RepoType 147 let repoType: Project.SourceRepo.RepoType
148
149 private enum CodingKeys: String, CodingKey {
150 case rid
151 case name
152 case description
153 case visibility
154 case owner
155 case repoType
156 }
157
158 init(from decoder: any Decoder) throws {
159 let container = try decoder.container(keyedBy: CodingKeys.self)
160 rid = try container.decode(String.self, forKey: .rid)
161 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
162 description = try container.decodeIfPresent(String.self, forKey: .description)
163 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
164 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
165 repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
166 }
68} 167}
69 168
70private struct ProjectTrackerPage: Decodable, Sendable { 169private struct ProjectTrackerPage: Decodable, Sendable {
71 let results: [ProjectTrackerPayload] 170 let results: [ProjectTrackerPayload]
72 let cursor: String? 171 let cursor: String?
172
173 static let empty = ProjectTrackerPage(results: [], cursor: nil)
73} 174}
74 175
75private struct ProjectTrackerPayload: Decodable, Sendable { 176private struct ProjectTrackerPayload: Decodable, Sendable {
@@ -78,6 +179,23 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
78 let description: String? 179 let description: String?
79 let visibility: Visibility 180 let visibility: Visibility
80 let owner: Entity 181 let owner: Entity
182
183 private enum CodingKeys: String, CodingKey {
184 case rid
185 case name
186 case description
187 case visibility
188 case owner
189 }
190
191 init(from decoder: any Decoder) throws {
192 let container = try decoder.container(keyedBy: CodingKeys.self)
193 rid = try container.decode(String.self, forKey: .rid)
194 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
195 description = try container.decodeIfPresent(String.self, forKey: .description)
196 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
197 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
198 }
81} 199}
82 200
83struct ProjectService: Sendable { 201struct ProjectService: Sendable {
Hutch/Views/Home/HomeView.swift +46 −22
@@ -179,7 +179,7 @@ struct HomeView: View {
179 179
180 @ViewBuilder 180 @ViewBuilder
181 private func projectsSection(_ viewModel: HomeViewModel) -> some View { 181 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
182 if !viewModel.pinnedProjects.isEmpty { 182 if viewModel.hasPinnedProjects {
183 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { 183 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
184 NavigationLink { 184 NavigationLink {
185 ProjectsListView() 185 ProjectsListView()
@@ -189,11 +189,27 @@ struct HomeView: View {
189 } 189 }
190 .buttonStyle(.plain) 190 .buttonStyle(.plain)
191 } content: { 191 } content: {
192 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in 192 if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
193 NavigationLink { 193 HomeSectionLoadingRow(label: "Loading pinned projects")
194 ProjectDetailView(project: project) 194 } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
195 } label: { 195 HomeSectionMessageRow(
196 HomeProjectRow(project: project) 196 text: "Couldn’t load pinned projects.",
197 systemImage: "exclamationmark.triangle",
198 emphasized: true,
199 accessibilityHint: error
200 )
201 } else if viewModel.pinnedProjects.isEmpty {
202 HomeSectionMessageRow(
203 text: "Pinned projects will appear here when they’re available.",
204 systemImage: "pin"
205 )
206 } else {
207 ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
208 NavigationLink {
209 ProjectDetailView(project: project)
210 } label: {
211 HomeProjectRow(project: project)
212 }
197 } 213 }
198 } 214 }
199 } 215 }
@@ -388,26 +404,34 @@ private struct HomeProjectRow: View {
388 let project: Project 404 let project: Project
389 405
390 var body: some View { 406 var body: some View {
391 VStack(alignment: .leading, spacing: 4) { 407 VStack(alignment: .leading, spacing: 8) {
392 Text(project.name) 408 HStack(alignment: .top, spacing: 10) {
393 .font(.subheadline.weight(.medium)) 409 VStack(alignment: .leading, spacing: 4) {
394 .lineLimit(1) 410 Text(project.displayName)
411 .font(.subheadline.weight(.medium))
412 .foregroundStyle(.primary)
413 .lineLimit(1)
395 414
396 if let description = project.description, !description.isEmpty { 415 if let description = project.displayDescription {
397 Text(description) 416 Text(description)
398 .font(.caption) 417 .font(.caption)
399 .foregroundStyle(.secondary) 418 .foregroundStyle(.secondary)
400 .lineLimit(1) 419 .lineLimit(2)
401 } 420 }
421 }
402 422
403 if let summary = project.resourceSummary { 423 Spacer(minLength: 8)
404 Text(summary) 424
405 .font(.caption) 425 VisibilityBadge(visibility: project.visibility)
406 .foregroundStyle(.tertiary)
407 .lineLimit(1)
408 } 426 }
427
428 Text(project.metadataLine)
429 .font(.caption)
430 .foregroundStyle(.secondary)
431 .lineLimit(1)
409 } 432 }
410 .padding(.vertical, 2) 433 .contentShape(Rectangle())
434 .padding(.vertical, 4)
411 } 435 }
412} 436}
413 437
Hutch/Views/Home/HomeViewModel.swift +9 −2
@@ -185,6 +185,7 @@ final class HomeViewModel {
185 private(set) var isLoadingProjects = false 185 private(set) var isLoadingProjects = false
186 private(set) var isLoadingAssignedTickets = false 186 private(set) var isLoadingAssignedTickets = false
187 private(set) var isLoadingRecentBuilds = false 187 private(set) var isLoadingRecentBuilds = false
188 private(set) var projectsError: String?
188 private(set) var assignedTicketsError: String? 189 private(set) var assignedTicketsError: String?
189 private(set) var recentBuildsError: String? 190 private(set) var recentBuildsError: String?
190 191
@@ -332,6 +333,7 @@ final class HomeViewModel {
332 isLoadingAssignedTickets = true 333 isLoadingAssignedTickets = true
333 isLoadingRecentBuilds = true 334 isLoadingRecentBuilds = true
334 isLoadingSystemStatus = true 335 isLoadingSystemStatus = true
336 projectsError = nil
335 assignedTicketsError = nil 337 assignedTicketsError = nil
336 recentBuildsError = nil 338 recentBuildsError = nil
337 isShowingStaleSystemStatus = false 339 isShowingStaleSystemStatus = false
@@ -347,8 +349,9 @@ final class HomeViewModel {
347 switch projectsResult { 349 switch projectsResult {
348 case .success(let projects): 350 case .success(let projects):
349 self.projects = projects 351 self.projects = projects
350 case .failure: 352 self.projectsError = nil
351 self.projects = [] 353 case .failure(let error):
354 self.projectsError = error.userFacingMessage
352 } 355 }
353 isLoadingProjects = false 356 isLoadingProjects = false
354 357
@@ -407,6 +410,10 @@ final class HomeViewModel {
407 return pinnedIDs.compactMap { projectsByID[$0] } 410 return pinnedIDs.compactMap { projectsByID[$0] }
408 } 411 }
409 412
413 var hasPinnedProjects: Bool {
414 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
415 }
416
410 var failedBuildCount: Int { 417 var failedBuildCount: Int {
411 recentBuilds.filter { 418 recentBuilds.filter {
412 switch $0.job.status { 419 switch $0.job.status {
Hutch/Views/Projects/ProjectDetailView.swift +101 −51
@@ -5,6 +5,7 @@ struct ProjectDetailView: View {
5 5
6 @Environment(AppState.self) private var appState 6 @Environment(AppState.self) private var appState
7 @Environment(\.dismiss) private var dismiss 7 @Environment(\.dismiss) private var dismiss
8 @Environment(\.openURL) private var openURL
8 @State private var detailProject: Project? 9 @State private var detailProject: Project?
9 @State private var isLoading = false 10 @State private var isLoading = false
10 @State private var error: String? 11 @State private var error: String?
@@ -27,13 +28,14 @@ struct ProjectDetailView: View {
27 var body: some View { 28 var body: some View {
28 List { 29 List {
29 headerSection 30 headerSection
30 linksSection
31 repositoriesSection 31 repositoriesSection
32 trackersSection 32 trackersSection
33 mailingListsSection 33 mailingListsSection
34 linksSection
35 emptyResourcesSection
34 } 36 }
35 .themedList() 37 .themedList()
36 .navigationTitle(displayedProject.name) 38 .navigationTitle(displayedProject.displayName)
37 .navigationBarTitleDisplayMode(.inline) 39 .navigationBarTitleDisplayMode(.inline)
38 .overlay { 40 .overlay {
39 if isLoading, detailProject == nil, !project.isFullyLoaded { 41 if isLoading, detailProject == nil, !project.isFullyLoaded {
@@ -71,19 +73,19 @@ struct ProjectDetailView: View {
71 private var headerSection: some View { 73 private var headerSection: some View {
72 Section { 74 Section {
73 VStack(alignment: .leading, spacing: 10) { 75 VStack(alignment: .leading, spacing: 10) {
74 Text(displayedProject.name) 76 Text(displayedProject.displayName)
75 .font(.headline) 77 .font(.headline)
76 78
77 if let description = displayedProject.description, !description.isEmpty { 79 if let description = displayedProject.displayDescription {
78 Text(description) 80 Text(description)
79 .font(.subheadline) 81 .font(.subheadline)
80 .foregroundStyle(.secondary) 82 .foregroundStyle(.secondary)
81 } 83 }
82 84
83 if !displayedProject.tags.isEmpty { 85 if !displayedProject.displayTags.isEmpty {
84 ScrollView(.horizontal, showsIndicators: false) { 86 ScrollView(.horizontal, showsIndicators: false) {
85 HStack(spacing: 8) { 87 HStack(spacing: 8) {
86 ForEach(displayedProject.tags, id: \.self) { tag in 88 ForEach(displayedProject.displayTags, id: \.self) { tag in
87 Text(tag) 89 Text(tag)
88 .font(.caption.weight(.medium)) 90 .font(.caption.weight(.medium))
89 .padding(.horizontal, 10) 91 .padding(.horizontal, 10)
@@ -94,7 +96,11 @@ struct ProjectDetailView: View {
94 } 96 }
95 } 97 }
96 98
99 LabeledContent("Project", value: displayedProject.visibility.displayName)
97 LabeledContent("Updated", value: displayedProject.updated.relativeDescription) 100 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
101 if let summary = displayedProject.resourceSummary {
102 LabeledContent("Linked", value: summary)
103 }
98 } 104 }
99 .padding(.vertical, 4) 105 .padding(.vertical, 4)
100 } 106 }
@@ -106,10 +112,20 @@ struct ProjectDetailView: View {
106 if !links.isEmpty { 112 if !links.isEmpty {
107 Section("Links") { 113 Section("Links") {
108 ForEach(links) { link in 114 ForEach(links) { link in
109 Link(destination: link.url) { 115 Button {
110 Label(link.title, systemImage: link.systemImage) 116 openURL(link.url)
111 .font(.subheadline) 117 } label: {
118 HStack(spacing: 12) {
119 Label(link.title, systemImage: link.systemImage)
120 .font(.subheadline)
121 .foregroundStyle(.primary)
122 Spacer()
123 Image(systemName: "arrow.up.right")
124 .font(.caption.weight(.semibold))
125 .foregroundStyle(.tertiary)
126 }
112 } 127 }
128 .buttonStyle(.plain)
113 } 129 }
114 } 130 }
115 } 131 }
@@ -122,14 +138,19 @@ struct ProjectDetailView: View {
122 ForEach(displayedProject.sources) { source in 138 ForEach(displayedProject.sources) { source in
123 Button { 139 Button {
124 Task { 140 Task {
125 try? await appState.openProjectSource(source) 141 do {
126 dismiss() 142 try await appState.openProjectSource(source)
143 dismiss()
144 } catch {
145 self.error = "Couldn’t open repository. \(error.userFacingMessage)"
146 }
127 } 147 }
128 } label: { 148 } label: {
129 ProjectResourceRow( 149 ProjectResourceRow(
130 title: source.name, 150 title: source.displayName,
131 subtitle: source.owner.canonicalName, 151 subtitle: source.ownerDisplayName,
132 detail: source.description 152 detail: source.displayDescription,
153 systemImage: "book.closed"
133 ) 154 )
134 } 155 }
135 .buttonStyle(.plain) 156 .buttonStyle(.plain)
@@ -145,14 +166,19 @@ struct ProjectDetailView: View {
145 ForEach(displayedProject.trackers) { tracker in 166 ForEach(displayedProject.trackers) { tracker in
146 Button { 167 Button {
147 Task { 168 Task {
148 try? await appState.openProjectTracker(tracker) 169 do {
149 dismiss() 170 try await appState.openProjectTracker(tracker)
171 dismiss()
172 } catch {
173 self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
174 }
150 } 175 }
151 } label: { 176 } label: {
152 ProjectResourceRow( 177 ProjectResourceRow(
153 title: tracker.name, 178 title: tracker.displayName,
154 subtitle: tracker.owner.canonicalName, 179 subtitle: tracker.ownerDisplayName,
155 detail: tracker.description 180 detail: tracker.displayDescription,
181 systemImage: "checklist"
156 ) 182 )
157 } 183 }
158 .buttonStyle(.plain) 184 .buttonStyle(.plain)
@@ -171,9 +197,10 @@ struct ProjectDetailView: View {
171 dismiss() 197 dismiss()
172 } label: { 198 } label: {
173 ProjectResourceRow( 199 ProjectResourceRow(
174 title: mailingList.name, 200 title: mailingList.displayName,
175 subtitle: mailingList.owner.canonicalName, 201 subtitle: mailingList.ownerDisplayName,
176 detail: mailingList.description 202 detail: mailingList.displayDescription,
203 systemImage: "list.bullet"
177 ) 204 )
178 } 205 }
179 .buttonStyle(.plain) 206 .buttonStyle(.plain)
@@ -182,6 +209,19 @@ struct ProjectDetailView: View {
182 } 209 }
183 } 210 }
184 211
212 @ViewBuilder
213 private var emptyResourcesSection: some View {
214 if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
215 Section {
216 ContentUnavailableView(
217 "No Linked Resources",
218 systemImage: "square.stack.3d.up.slash",
219 description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
220 )
221 }
222 }
223 }
224
185 private func loadProjectIfNeeded(forceRefresh: Bool = false) async { 225 private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
186 guard forceRefresh || !project.isFullyLoaded else { 226 guard forceRefresh || !project.isFullyLoaded else {
187 detailProject = project 227 detailProject = project
@@ -197,7 +237,7 @@ struct ProjectDetailView: View {
197 let service = ProjectService(client: appState.client) 237 let service = ProjectService(client: appState.client)
198 detailProject = try await service.fetchProjectDetail(rid: project.id) 238 detailProject = try await service.fetchProjectDetail(rid: project.id)
199 } catch { 239 } catch {
200 self.error = "Failed to load project" 240 self.error = "Couldn’t load project. \(error.userFacingMessage)"
201 } 241 }
202 } 242 }
203 243
@@ -210,31 +250,26 @@ struct ProjectDetailView: View {
210 private func projectLinks(for project: Project) -> [ProjectLink] { 250 private func projectLinks(for project: Project) -> [ProjectLink] {
211 var links: [ProjectLink] = [] 251 var links: [ProjectLink] = []
212 252
213 if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines), 253 if let url = project.websiteURL {
214 let url = URL(string: website), 254 links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
215 !website.isEmpty {
216 links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url))
217 } 255 }
218 256
219 if let source = project.sources.first, 257 if let source = project.sources.first,
220 let url = sourceURL(for: source) { 258 let url = source.webURL {
221 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url)) 259 links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
222 } 260 }
223 261
224 if let tracker = project.trackers.first, 262 if let tracker = project.trackers.first,
225 let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) { 263 let url = tracker.webURL {
226 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url)) 264 links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
227 } 265 }
228 266
229 return links 267 if let mailingList = project.mailingLists.first,
230 } 268 let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
269 links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
270 }
231 271
232 private func sourceURL(for source: Project.SourceRepo) -> URL? { 272 return links
233 var components = URLComponents()
234 components.scheme = "https"
235 components.host = "\(source.repoType.service.rawValue).sr.ht"
236 components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)"
237 return components.url
238 } 273 }
239} 274}
240 275
@@ -249,24 +284,39 @@ private struct ProjectResourceRow: View {
249 let title: String 284 let title: String
250 let subtitle: String 285 let subtitle: String
251 let detail: String? 286 let detail: String?
287 let systemImage: String
252 288
253 var body: some View { 289 var body: some View {
254 VStack(alignment: .leading, spacing: 4) { 290 HStack(alignment: .top, spacing: 12) {
255 Text(title) 291 Image(systemName: systemImage)
256 .font(.subheadline.weight(.medium)) 292 .frame(width: 18, alignment: .leading)
257
258 Text(subtitle)
259 .font(.caption)
260 .foregroundStyle(.secondary) 293 .foregroundStyle(.secondary)
261 .lineLimit(1)
262 294
263 if let detail, !detail.isEmpty { 295 VStack(alignment: .leading, spacing: 4) {
264 Text(detail) 296 Text(title)
297 .font(.subheadline.weight(.medium))
298 .foregroundStyle(.primary)
299
300 Text(subtitle)
265 .font(.caption) 301 .font(.caption)
266 .foregroundStyle(.tertiary) 302 .foregroundStyle(.secondary)
267 .lineLimit(2) 303 .lineLimit(1)
304
305 if let detail, !detail.isEmpty {
306 Text(detail)
307 .font(.caption)
308 .foregroundStyle(.tertiary)
309 .lineLimit(2)
310 }
268 } 311 }
312
313 Spacer(minLength: 8)
314
315 Image(systemName: "chevron.right")
316 .font(.caption.weight(.semibold))
317 .foregroundStyle(.tertiary)
269 } 318 }
270 .padding(.vertical, 2) 319 .contentShape(Rectangle())
320 .padding(.vertical, 4)
271 } 321 }
272} 322}
Hutch/Views/Projects/ProjectPinStore.swift +28 −4
@@ -6,7 +6,7 @@ enum ProjectPinStore {
6 defaults: UserDefaults = .standard 6 defaults: UserDefaults = .standard
7 ) -> [String] { 7 ) -> [String] {
8 let pinnedProjects = loadAll(defaults: defaults) 8 let pinnedProjects = loadAll(defaults: defaults)
9 return pinnedProjects[userKey] ?? [] 9 return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
10 } 10 }
11 11
12 static func isPinned( 12 static func isPinned(
@@ -23,12 +23,14 @@ enum ProjectPinStore {
23 defaults: UserDefaults = .standard 23 defaults: UserDefaults = .standard
24 ) { 24 ) {
25 var pinnedProjects = loadAll(defaults: defaults) 25 var pinnedProjects = loadAll(defaults: defaults)
26 var projectIDs = pinnedProjects[userKey] ?? [] 26 var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
27 27
28 if let index = projectIDs.firstIndex(of: projectID) { 28 if let index = projectIDs.firstIndex(of: projectID) {
29 projectIDs.remove(at: index) 29 projectIDs.remove(at: index)
30 } else { 30 } else {
31 projectIDs.append(projectID) 31 if let normalizedProjectID = normalizedProjectID(projectID) {
32 projectIDs.append(normalizedProjectID)
33 }
32 } 34 }
33 35
34 pinnedProjects[userKey] = projectIDs 36 pinnedProjects[userKey] = projectIDs
@@ -40,11 +42,33 @@ enum ProjectPinStore {
40 return [:] 42 return [:]
41 } 43 }
42 44
43 return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] 45 let decoded = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
46 return decoded.reduce(into: [String: [String]]()) { result, entry in
47 let normalizedUserKey = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
48 guard !normalizedUserKey.isEmpty else { return }
49 let normalizedProjectIDs = normalizedProjectIDs(entry.value)
50 if !normalizedProjectIDs.isEmpty {
51 result[normalizedUserKey] = normalizedProjectIDs
52 }
53 }
44 } 54 }
45 55
46 private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) { 56 private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
47 guard let data = try? JSONEncoder().encode(pinnedProjects) else { return } 57 guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
48 defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects) 58 defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
49 } 59 }
60
61 private static func normalizedProjectIDs(_ projectIDs: [String]) -> [String] {
62 var seen = Set<String>()
63 return projectIDs.compactMap { projectID in
64 guard let normalizedProjectID = normalizedProjectID(projectID) else { return nil }
65 guard seen.insert(normalizedProjectID).inserted else { return nil }
66 return normalizedProjectID
67 }
68 }
69
70 private static func normalizedProjectID(_ projectID: String) -> String? {
71 let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
72 return trimmed.isEmpty ? nil : trimmed
73 }
50} 74}
Hutch/Views/Projects/ProjectsListView.swift +46 −24
@@ -34,7 +34,11 @@ final class ProjectsListViewModel {
34 do { 34 do {
35 projects = try await service.fetchProjects() 35 projects = try await service.fetchProjects()
36 } catch { 36 } catch {
37 self.error = "Failed to load projects" 37 if projects.isEmpty {
38 self.error = error.userFacingMessage
39 } else {
40 self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
41 }
38 } 42 }
39 } 43 }
40} 44}
@@ -72,6 +76,8 @@ struct ProjectsListView: View {
72 } label: { 76 } label: {
73 ProjectListRow(project: project) 77 ProjectListRow(project: project)
74 } 78 }
79 .buttonStyle(.plain)
80 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
75 } 81 }
76 } 82 }
77 .themedList() 83 .themedList()
@@ -96,7 +102,7 @@ struct ProjectsListView: View {
96 ContentUnavailableView( 102 ContentUnavailableView(
97 "No Projects", 103 "No Projects",
98 systemImage: "square.stack.3d.up", 104 systemImage: "square.stack.3d.up",
99 description: Text("Your SourceHut projects will appear here.") 105 description: Text("Projects from your SourceHut account will appear here when available.")
100 ) 106 )
101 } 107 }
102 } 108 }
@@ -114,33 +120,49 @@ private struct ProjectListRow: View {
114 let project: Project 120 let project: Project
115 121
116 var body: some View { 122 var body: some View {
117 VStack(alignment: .leading, spacing: 4) { 123 VStack(alignment: .leading, spacing: 6) {
118 HStack(alignment: .firstTextBaseline, spacing: 8) { 124 HStack(alignment: .top, spacing: 10) {
119 Text(project.name) 125 VStack(alignment: .leading, spacing: 4) {
120 .font(.subheadline.weight(.medium)) 126 Text(project.displayName)
121 .lineLimit(1) 127 .font(.subheadline.weight(.medium))
122 128 .foregroundStyle(.primary)
123 Spacer() 129 .lineLimit(1)
130
131 if let description = project.displayDescription {
132 Text(description)
133 .font(.caption)
134 .foregroundStyle(.secondary)
135 .lineLimit(2)
136 }
137 }
124 138
125 Text(project.updated.relativeDescription) 139 Spacer(minLength: 8)
126 .font(.caption)
127 .foregroundStyle(.tertiary)
128 }
129 140
130 if let description = project.description, !description.isEmpty { 141 VisibilityBadge(visibility: project.visibility)
131 Text(description)
132 .font(.caption)
133 .foregroundStyle(.secondary)
134 .lineLimit(2)
135 } 142 }
136 143
137 if let summary = project.resourceSummary { 144 Text(project.metadataLine)
138 Text(summary) 145 .font(.caption)
139 .font(.caption) 146 .foregroundStyle(.secondary)
140 .foregroundStyle(.tertiary) 147 .lineLimit(1)
141 .lineLimit(1) 148
149 if !project.displayTags.isEmpty {
150 ScrollView(.horizontal, showsIndicators: false) {
151 HStack(spacing: 6) {
152 ForEach(project.displayTags.prefix(4), id: \.self) { tag in
153 Text(tag)
154 .font(.caption2.weight(.medium))
155 .foregroundStyle(.secondary)
156 .padding(.horizontal, 8)
157 .padding(.vertical, 3)
158 .background(.quaternary, in: Capsule())
159 }
160 }
161 }
162 .scrollDisabled(true)
142 } 163 }
143 } 164 }
144 .padding(.vertical, 2) 165 .contentShape(Rectangle())
166 .padding(.vertical, 4)
145 } 167 }
146} 168}
Hutch/Views/Tickets/TicketListView.swift +133 −76
@@ -194,75 +194,67 @@ struct TicketListView: View {
194 194
195 List { 195 List {
196 Section { 196 Section {
197 VStack(alignment: .leading, spacing: 12) { 197 TicketListFilterHeader(
198 Picker("Filter", selection: $vm.filter) { 198 selectedLabels: viewModel.selectedLabels,
199 ForEach(TicketFilter.allCases, id: \.self) { filter in 199 savedFilters: viewModel.savedFilters,
200 Text(filter.rawValue).tag(filter) 200 activeSavedFilterID: viewModel.activeSavedFilterID,
201 } 201 canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
202 } 202 filter: $vm.filter
203 .pickerStyle(.segmented) 203 ) {
204 204 showLabelFilterSheet = true
205 TicketQuickFilterBar( 205 } onSaveFilter: {
206 selectedLabels: viewModel.selectedLabels, 206 showSaveFilterSheet = true
207 savedFilters: viewModel.savedFilters, 207 } onResetFilters: {
208 activeSavedFilterID: viewModel.activeSavedFilterID, 208 vm.resetFilters()
209 canSaveCurrentFilter: viewModel.hasCustomFilterSelection 209 } onApplySavedFilter: { savedFilter in
210 ) { 210 vm.applySavedFilter(savedFilter)
211 showLabelFilterSheet = true 211 } onDeleteSavedFilter: { savedFilter in
212 } onSaveFilter: { 212 vm.deleteSavedFilter(savedFilter)
213 showSaveFilterSheet = true
214 } onResetFilters: {
215 vm.resetFilters()
216 } onApplySavedFilter: { savedFilter in
217 vm.applySavedFilter(savedFilter)
218 } onDeleteSavedFilter: { savedFilter in
219 vm.deleteSavedFilter(savedFilter)
220 }
221 } 213 }
222 .padding(.vertical, 4)
223 .listRowBackground(Color.clear)
224 .listRowInsets(EdgeInsets()) 214 .listRowInsets(EdgeInsets())
225 } 215 .listRowBackground(Color.clear)
216 .listRowSeparator(.hidden)
226 217
227 // Tickets 218 ForEach(viewModel.filteredTickets) { ticket in
228 ForEach(viewModel.filteredTickets) { ticket in 219 NavigationLink {
229 NavigationLink { 220 TicketDetailView(
230 TicketDetailView( 221 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
231 ownerUsername: String(tracker.owner.canonicalName.dropFirst()), 222 trackerName: tracker.name,
232 trackerName: tracker.name, 223 trackerId: tracker.id,
233 trackerId: tracker.id, 224 trackerRid: tracker.rid,
234 trackerRid: tracker.rid, 225 ticketId: ticket.id
235 ticketId: ticket.id 226 )
236 ) 227 } label: {
237 } label: { 228 TicketRowView(ticket: ticket)
238 TicketRowView(ticket: ticket)
239 }
240 .swipeActions(edge: .leading, allowsFullSwipe: true) {
241 if swipeActionsEnabled {
242 ticketAssignSwipeAction(ticket, viewModel: viewModel)
243 } 229 }
244 } 230 .swipeActions(edge: .leading, allowsFullSwipe: true) {
245 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 231 if swipeActionsEnabled {
246 if swipeActionsEnabled { 232 ticketAssignSwipeAction(ticket, viewModel: viewModel)
247 ticketStatusSwipeAction(ticket, viewModel: viewModel) 233 }
248 ticketLabelSwipeAction(ticket, viewModel: viewModel) 234 }
235 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
236 if swipeActionsEnabled {
237 ticketStatusSwipeAction(ticket, viewModel: viewModel)
238 ticketLabelSwipeAction(ticket, viewModel: viewModel)
239 }
240 }
241 .task {
242 await viewModel.loadMoreIfNeeded(currentItem: ticket)
249 } 243 }
250 } 244 }
251 .task {
252 await viewModel.loadMoreIfNeeded(currentItem: ticket)
253 }
254 }
255 245
256 if viewModel.isLoadingMore { 246 if viewModel.isLoadingMore {
257 HStack { 247 HStack {
258 Spacer() 248 Spacer()
259 ProgressView() 249 ProgressView()
260 Spacer() 250 Spacer()
251 }
252 .listRowSeparator(.hidden)
261 } 253 }
262 .listRowSeparator(.hidden)
263 } 254 }
264 } 255 }
265 .listStyle(.plain) 256 .listStyle(.plain)
257 .listSectionSpacing(.compact)
266 .searchable( 258 .searchable(
267 text: $vm.searchText, 259 text: $vm.searchText,
268 placement: .navigationBarDrawer(displayMode: .always), 260 placement: .navigationBarDrawer(displayMode: .always),
@@ -538,11 +530,12 @@ private struct TicketLabelsSheet: View {
538 } 530 }
539} 531}
540 532
541private struct TicketQuickFilterBar: View { 533private struct TicketListFilterHeader: View {
542 let selectedLabels: [TicketLabel] 534 let selectedLabels: [TicketLabel]
543 let savedFilters: [SavedTicketFilter] 535 let savedFilters: [SavedTicketFilter]
544 let activeSavedFilterID: SavedTicketFilter.ID? 536 let activeSavedFilterID: SavedTicketFilter.ID?
545 let canSaveCurrentFilter: Bool 537 let canSaveCurrentFilter: Bool
538 @Binding var filter: TicketFilter
546 let onShowLabels: () -> Void 539 let onShowLabels: () -> Void
547 let onSaveFilter: () -> Void 540 let onSaveFilter: () -> Void
548 let onResetFilters: () -> Void 541 let onResetFilters: () -> Void
@@ -551,26 +544,45 @@ private struct TicketQuickFilterBar: View {
551 544
552 var body: some View { 545 var body: some View {
553 VStack(alignment: .leading, spacing: 10) { 546 VStack(alignment: .leading, spacing: 10) {
554 HStack(spacing: 8) { 547 Picker("Filter", selection: $filter) {
555 Button(action: onShowLabels) { 548 ForEach(TicketFilter.allCases, id: \.self) { filter in
556 Label(labelButtonTitle, systemImage: "tag") 549 Text(filter.rawValue).tag(filter)
557 .font(.caption.weight(.medium))
558 } 550 }
559 .buttonStyle(.bordered) 551 }
552 .pickerStyle(.segmented)
553
554 TicketQuickFilterBar(
555 selectedLabels: selectedLabels,
556 savedFilters: savedFilters,
557 activeSavedFilterID: activeSavedFilterID,
558 canSaveCurrentFilter: canSaveCurrentFilter,
559 onShowLabels: onShowLabels,
560 onSaveFilter: onSaveFilter,
561 onResetFilters: onResetFilters,
562 onApplySavedFilter: onApplySavedFilter,
563 onDeleteSavedFilter: onDeleteSavedFilter
564 )
565 }
566 .padding(.horizontal, 16)
567 .padding(.top, 6)
568 .padding(.bottom, 10)
569 }
570}
560 571
561 Button(action: onSaveFilter) { 572private struct TicketQuickFilterBar: View {
562 Label("Save Filter", systemImage: "square.and.arrow.down") 573 let selectedLabels: [TicketLabel]
563 .font(.caption.weight(.medium)) 574 let savedFilters: [SavedTicketFilter]
564 } 575 let activeSavedFilterID: SavedTicketFilter.ID?
565 .buttonStyle(.bordered) 576 let canSaveCurrentFilter: Bool
566 .disabled(!canSaveCurrentFilter) 577 let onShowLabels: () -> Void
578 let onSaveFilter: () -> Void
579 let onResetFilters: () -> Void
580 let onApplySavedFilter: (SavedTicketFilter) -> Void
581 let onDeleteSavedFilter: (SavedTicketFilter) -> Void
567 582
568 if canSaveCurrentFilter { 583 var body: some View {
569 Button("Reset", action: onResetFilters) 584 VStack(alignment: .leading, spacing: 8) {
570 .font(.caption.weight(.medium)) 585 actionButtons
571 .buttonStyle(.bordered)
572 }
573 }
574 586
575 if !selectedLabels.isEmpty { 587 if !selectedLabels.isEmpty {
576 ScrollView(.horizontal, showsIndicators: false) { 588 ScrollView(.horizontal, showsIndicators: false) {
@@ -623,6 +635,51 @@ private struct TicketQuickFilterBar: View {
623 private var labelButtonTitle: String { 635 private var labelButtonTitle: String {
624 selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))" 636 selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
625 } 637 }
638
639 @ViewBuilder
640 private var actionButtons: some View {
641 ViewThatFits(in: .horizontal) {
642 HStack(spacing: 8) {
643 labelsButton
644 saveFilterButton
645 if canSaveCurrentFilter {
646 resetButton
647 }
648 Spacer(minLength: 0)
649 }
650
651 FlowLayout(spacing: 8) {
652 labelsButton
653 saveFilterButton
654 if canSaveCurrentFilter {
655 resetButton
656 }
657 }
658 }
659 }
660
661 private var labelsButton: some View {
662 Button(action: onShowLabels) {
663 Label(labelButtonTitle, systemImage: "tag")
664 .font(.caption.weight(.medium))
665 }
666 .buttonStyle(.bordered)
667 }
668
669 private var saveFilterButton: some View {
670 Button(action: onSaveFilter) {
671 Label("Save Filter", systemImage: "square.and.arrow.down")
672 .font(.caption.weight(.medium))
673 }
674 .buttonStyle(.bordered)
675 .disabled(!canSaveCurrentFilter)
676 }
677
678 private var resetButton: some View {
679 Button("Reset", action: onResetFilters)
680 .font(.caption.weight(.medium))
681 .buttonStyle(.bordered)
682 }
626} 683}
627 684
628private struct TicketFilterLabelsSheet: View { 685private struct TicketFilterLabelsSheet: View {
HutchTests/ProjectPinStoreTests.swift +13
@@ -25,4 +25,17 @@ struct ProjectPinStoreTests {
25 25
26 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty) 26 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
27 } 27 }
28
29 @Test
30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
31 let defaults = UserDefaults(suiteName: #function)!
32 defaults.removePersistentDomain(forName: #function)
33
34 let encoded = try! JSONEncoder().encode([
35 "~alice": [" project-1 ", "", "project-1", "project-2"]
36 ])
37 defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
38
39 #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1", "project-2"])
40 }
28} 41}
HutchTests/ProjectTests.swift +21
@@ -71,4 +71,25 @@ struct ProjectTests {
71 71
72 #expect(project.resourceSummary == "Website linked") 72 #expect(project.resourceSummary == "Website linked")
73 } 73 }
74
75 @Test
76 func displayHelpersNormalizeBlankValues() {
77 let project = Project(
78 id: "project-1",
79 name: " ",
80 description: "\n",
81 website: "https://example.com",
82 visibility: .unlisted,
83 tags: [" docs ", "", "Docs", "ios"],
84 updated: Date(timeIntervalSince1970: 0),
85 mailingLists: [],
86 sources: [],
87 trackers: []
88 )
89
90 #expect(project.displayName == "Untitled Project")
91 #expect(project.displayDescription == nil)
92 #expect(project.displayTags == ["docs", "ios"])
93 #expect(project.metadataLine.contains("Unlisted"))
94 }
74} 95}