Commit b4a6bd651f
Unsigned
Layout: unified · split
gitbay/Views/Repos/RepoView.swift +140 −60
| @@ -20,13 +20,15 @@ struct RepoView: View { | ||
| 20 | 20 | @State private var confirmingFork = false |
| 21 | 21 | @State private var forkName = "" |
| 22 | 22 | @State private var forkDestination: RepoRoute? |
| 23 | @State private var showingMore = false | |
| 23 | @Environment(\.dynamicTypeSize) private var typeSize | |
| 24 | @ScaledMetric(relativeTo: .title3) private var statValueHeight: CGFloat = 26 | |
| 24 | 25 | @State private var destination: Destination? |
| 25 | 26 | |
| 26 | 27 | /// Pushed from buttons rather than NavigationLinks: several share a |
| 27 | 28 | /// list row, and a link there claims the whole row and adds a chevron. |
| 28 | 29 | private enum Destination: Hashable { |
| 29 | 30 | case files, mergeRequests, issues, builds, owner, settings |
| 31 | case history, refs, milestones, labels, releases, wiki, grep | |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | var body: some View { |
| @@ -50,47 +52,13 @@ struct RepoView: View { | ||
| 50 | 52 | header(detail) |
| 51 | 53 | |
| 52 | 54 | Section { |
| 53 | Grid(horizontalSpacing: 0, verticalSpacing: 0) { | |
| 54 | GridRow { | |
| 55 | tile("Files", systemImage: "folder", id: "files", to: .files) | |
| 56 | tile("Merge requests", systemImage: "arrow.triangle.merge", id: "merge-requests", | |
| 57 | to: .mergeRequests, count: model.openMRs) | |
| 58 | } | |
| 59 | GridRow { | |
| 60 | tile("Issues", systemImage: "smallcircle.filled.circle", id: "issues", | |
| 61 | to: .issues, count: model.openIssues) | |
| 62 | tile("Builds", systemImage: "hammer", id: "builds", | |
| 63 | to: .builds, build: model.latestBuild) | |
| 64 | } | |
| 65 | } | |
| 66 | .listRowInsets(EdgeInsets()) | |
| 55 | statStrip | |
| 56 | .listRowInsets(EdgeInsets()) | |
| 67 | 57 | } |
| 68 | 58 | |
| 69 | Section { | |
| 70 | DisclosureGroup("More", isExpanded: $showingMore) { | |
| 71 | NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) { | |
| 72 | Label("History", systemImage: "clock") | |
| 73 | } | |
| 74 | NavigationLink(value: RepoRoute.refs(repo: path)) { | |
| 75 | Label("Branches & Tags", systemImage: "arrow.triangle.branch") | |
| 76 | } | |
| 77 | NavigationLink(value: RepoRoute.milestones(repo: path)) { | |
| 78 | Label("Milestones", systemImage: "flag") | |
| 79 | } | |
| 80 | NavigationLink(value: RepoRoute.labels(repo: path)) { | |
| 81 | Label("Labels", systemImage: "tag") | |
| 82 | } | |
| 83 | NavigationLink(value: ReleaseRoute.list(repo: path)) { | |
| 84 | Label("Releases", systemImage: "shippingbox") | |
| 85 | } | |
| 86 | NavigationLink(value: RepoRoute.wiki(repo: path)) { | |
| 87 | Label("Wiki", systemImage: "book") | |
| 88 | } | |
| 89 | NavigationLink(value: RepoRoute.grep(repo: path)) { | |
| 90 | Label("Search in Files", systemImage: "text.magnifyingglass") | |
| 91 | } | |
| 92 | } | |
| 93 | .accessibilityIdentifier("repo-more") | |
| 59 | Section("More") { | |
| 60 | morePills | |
| 61 | .padding(.vertical, 4) | |
| 94 | 62 | } |
| 95 | 63 | |
| 96 | 64 | if let readme = model.readme { |
| @@ -125,6 +93,13 @@ struct RepoView: View { | ||
| 125 | 93 | case .builds: BuildListView(client: client, repo: path) |
| 126 | 94 | case .owner: ProfileView(client: client, name: owner) |
| 127 | 95 | case .settings: RepoSettingsView(client: client, repo: path) |
| 96 | case .history: LogView(client: client, repo: path, ref: nil, path: nil) | |
| 97 | case .refs: RefsView(client: client, repo: path) | |
| 98 | case .milestones: MilestoneListView(client: client, repo: path) | |
| 99 | case .labels: LabelListView(client: client, repo: path) | |
| 100 | case .releases: ReleaseListView(client: client, repo: path) | |
| 101 | case .wiki: WikiView(client: client, repo: path) | |
| 102 | case .grep: GrepView(client: client, repo: path) | |
| 128 | 103 | } |
| 129 | 104 | } |
| 130 | 105 | .alert("Fork \(path)", isPresented: $confirmingFork) { |
| @@ -312,36 +287,141 @@ struct RepoView: View { | ||
| 312 | 287 | |
| 313 | 288 | private var owner: String { String(path.split(separator: "/").first ?? "") } |
| 314 | 289 | |
| 315 | private func tile( | |
| 316 | _ title: String, systemImage: String, id: String, to target: Destination, | |
| 317 | count: String? = nil, build: Build? = nil | |
| 290 | /// Files, merge requests, issues and builds in one row; two rows at | |
| 291 | /// accessibility text sizes. | |
| 292 | private var statStrip: some View { | |
| 293 | let columns = typeSize.isAccessibilitySize ? 2 : 4 | |
| 294 | return Grid(horizontalSpacing: 0, verticalSpacing: 0) { | |
| 295 | ForEach(0..<(4 / columns), id: \.self) { row in | |
| 296 | GridRow { | |
| 297 | ForEach(0..<columns, id: \.self) { column in | |
| 298 | stat(row * columns + column, first: column == 0) | |
| 299 | .overlay(alignment: .top) { | |
| 300 | if row > 0 { Rectangle().fill(Color.gbLine).frame(height: 0.5) } | |
| 301 | } | |
| 302 | } | |
| 303 | } | |
| 304 | } | |
| 305 | } | |
| 306 | } | |
| 307 | ||
| 308 | @ViewBuilder | |
| 309 | private func stat(_ index: Int, first: Bool) -> some View { | |
| 310 | switch index { | |
| 311 | case 0: | |
| 312 | statCell("Files", label: "Files", id: "files", to: .files, first: first) { | |
| 313 | Image(systemName: "folder").foregroundStyle(Color.gbAccent) | |
| 314 | } | |
| 315 | case 1: | |
| 316 | statCell("MRs", label: "Merge requests", value: model.openMRs.map { "\($0) open" }, | |
| 317 | id: "merge-requests", to: .mergeRequests, first: first) { | |
| 318 | count(model.openMRs, systemImage: "arrow.triangle.merge") | |
| 319 | } | |
| 320 | case 2: | |
| 321 | statCell("Issues", label: "Issues", value: model.openIssues.map { "\($0) open" }, | |
| 322 | id: "issues", to: .issues, first: first) { | |
| 323 | count(model.openIssues, systemImage: "smallcircle.filled.circle") | |
| 324 | } | |
| 325 | default: | |
| 326 | statCell("Builds", label: "Builds", value: model.latestBuild?.status, | |
| 327 | id: "builds", to: .builds, first: first) { | |
| 328 | if let build = model.latestBuild { | |
| 329 | Image(systemName: build.statusIcon).foregroundStyle(build.statusColor) | |
| 330 | } else { | |
| 331 | Image(systemName: "hammer").foregroundStyle(Color.gbAccent) | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 335 | } | |
| 336 | ||
| 337 | /// The count when it loaded, otherwise the icon. | |
| 338 | @ViewBuilder | |
| 339 | private func count(_ count: String?, systemImage: String) -> some View { | |
| 340 | if let count { | |
| 341 | Text(count).fontWeight(.medium).foregroundStyle(Color.gbAccent) | |
| 342 | } else { | |
| 343 | Image(systemName: systemImage).foregroundStyle(Color.gbAccent) | |
| 344 | } | |
| 345 | } | |
| 346 | ||
| 347 | private func statCell( | |
| 348 | _ title: String, label: String, value: String? = nil, id: String, | |
| 349 | to target: Destination, first: Bool, @ViewBuilder top: () -> some View | |
| 318 | 350 | ) -> some View { |
| 319 | 351 | Button { destination = target } label: { |
| 320 | HStack(spacing: 8) { | |
| 321 | Image(systemName: systemImage) | |
| 322 | .foregroundStyle(Color.gbAccent) | |
| 323 | .frame(width: 24) | |
| 352 | VStack(spacing: 2) { | |
| 353 | top() | |
| 354 | .font(.gbSans(.title3)) | |
| 355 | .frame(height: statValueHeight) | |
| 324 | 356 | Text(title) |
| 357 | .font(.gbSans(.caption)) | |
| 325 | 358 | .lineLimit(1) |
| 326 | .minimumScaleFactor(0.8) | |
| 327 | Spacer(minLength: 4) | |
| 328 | if let count { | |
| 329 | Text(count) | |
| 330 | .font(.gbSans(.subheadline)) | |
| 331 | .foregroundStyle(.secondary) | |
| 359 | } | |
| 360 | .padding(.vertical, 10) | |
| 361 | .frame(maxWidth: .infinity) | |
| 362 | .contentShape(Rectangle()) | |
| 363 | } | |
| 364 | .buttonStyle(.plain) | |
| 365 | .overlay(alignment: .leading) { | |
| 366 | if !first { Rectangle().fill(Color.gbLine).frame(width: 0.5) } | |
| 367 | } | |
| 368 | .accessibilityElement(children: .ignore) | |
| 369 | .accessibilityLabel(label) | |
| 370 | .accessibilityValue(value ?? "") | |
| 371 | .accessibilityAddTraits(.isButton) | |
| 372 | .accessibilityIdentifier("repo-link-\(id)") | |
| 373 | } | |
| 374 | ||
| 375 | /// The rest of the repository, always visible: two columns, one at | |
| 376 | /// accessibility text sizes. | |
| 377 | @ViewBuilder | |
| 378 | private var morePills: some View { | |
| 379 | let search = pill("Search in Files", systemImage: "text.magnifyingglass", to: .grep) | |
| 380 | if typeSize.isAccessibilitySize { | |
| 381 | VStack(spacing: 8) { | |
| 382 | pill("History", systemImage: "clock", to: .history) | |
| 383 | pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs) | |
| 384 | pill("Milestones", systemImage: "flag", to: .milestones) | |
| 385 | pill("Labels", systemImage: "tag", to: .labels) | |
| 386 | pill("Releases", systemImage: "shippingbox", to: .releases) | |
| 387 | pill("Wiki", systemImage: "book", to: .wiki) | |
| 388 | search | |
| 389 | } | |
| 390 | } else { | |
| 391 | Grid(horizontalSpacing: 8, verticalSpacing: 8) { | |
| 392 | GridRow { | |
| 393 | pill("History", systemImage: "clock", to: .history) | |
| 394 | pill("Branches & Tags", systemImage: "arrow.triangle.branch", to: .refs) | |
| 395 | } | |
| 396 | GridRow { | |
| 397 | pill("Milestones", systemImage: "flag", to: .milestones) | |
| 398 | pill("Labels", systemImage: "tag", to: .labels) | |
| 399 | } | |
| 400 | GridRow { | |
| 401 | pill("Releases", systemImage: "shippingbox", to: .releases) | |
| 402 | pill("Wiki", systemImage: "book", to: .wiki) | |
| 332 | 403 | } |
| 333 | if let build { | |
| 334 | Image(systemName: build.statusIcon) | |
| 335 | .foregroundStyle(build.statusColor) | |
| 336 | .accessibilityLabel(build.status) | |
| 404 | GridRow { | |
| 405 | search.gridCellColumns(2) | |
| 337 | 406 | } |
| 338 | 407 | } |
| 339 | .padding(.horizontal, 14) | |
| 340 | .padding(.vertical, 12) | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | private func pill(_ title: String, systemImage: String, to target: Destination) -> some View { | |
| 412 | Button { destination = target } label: { | |
| 413 | Label { | |
| 414 | Text(title).lineLimit(1).minimumScaleFactor(0.8) | |
| 415 | } icon: { | |
| 416 | Image(systemName: systemImage).foregroundStyle(Color.gbAccent) | |
| 417 | } | |
| 418 | .font(.gbSans(.subheadline)) | |
| 419 | .padding(.horizontal, 10) | |
| 420 | .padding(.vertical, 8) | |
| 341 | 421 | .frame(maxWidth: .infinity, alignment: .leading) |
| 422 | .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.gbLine, lineWidth: 0.5)) | |
| 342 | 423 | .contentShape(Rectangle()) |
| 343 | 424 | } |
| 344 | 425 | .buttonStyle(.plain) |
| 345 | .accessibilityIdentifier("repo-link-\(id)") | |
| 346 | 426 | } |
| 347 | 427 | } |
gitbayUITests/LiveSmokeUITests.swift +2 −7
| @@ -447,14 +447,9 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 447 | 447 | app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch |
| 448 | 448 | } |
| 449 | 449 | |
| 450 | /// A link in the repo screen's More section, above the README. | |
| 451 | /// Expands the section when it is still collapsed. | |
| 450 | /// A button in the repo screen's More section, above the README. | |
| 452 | 451 | private func moreLink(_ title: String) -> XCUIElement { |
| 453 | let link = app.staticTexts[title].firstMatch | |
| 454 | if link.exists { return link } | |
| 455 | let more = app.descendants(matching: .any).matching(identifier: "repo-more").firstMatch | |
| 456 | XCTAssertTrue(scrollTo(more, swipes: 20), "More section not reachable") | |
| 457 | more.tap() | |
| 452 | let link = app.buttons[title].firstMatch | |
| 458 | 453 | XCTAssertTrue(scrollTo(link), "\(title) not under More") |
| 459 | 454 | return link |
| 460 | 455 | } |