import SwiftUI /// A round mark: the picture at `url`, or else a pattern in a colour that `did` /// picks, the account's own wherever it shows, as on the pages, or `name` for /// one with no account. struct Avatar: View { let url: String? let name: String var did: String? /// An image in the app, as a coding agent's own face. var face: String? var size: CGFloat = 24 var body: some View { Group { if let face { Image(face).resizable().scaledToFill() } else if let url, let link = URL(string: url) { KeptFace(link: link) { pattern } } else { pattern } } .frame(width: size, height: size) .clipShape(Circle()) .accessibilityHidden(true) } private var pattern: some View { let seed = did ?? name // hsl(hue 45% 52%), as the pages' `.av.id rect`, in the terms SwiftUI takes. let color = Color(hue: Double(Identicon.hue(seed)) / 360, saturation: 0.587, brightness: 0.736) return Canvas { context, area in let unit = area.width / 7 var cells = Path() for cell in Identicon.cells(seed) { cells.addRect(CGRect(x: CGFloat(cell.x + 1) * unit, y: CGFloat(cell.y + 1) * unit, width: unit, height: unit)) } context.fill(cells, with: .color(color), style: FillStyle(antialiased: false)) } .background(Color(light: 0xf4f4f4, dark: 0x1a1a1a)) } } /// Loads what a screen shows, and says when it can't. struct Loading: View { let load: @Sendable () async throws -> Value /// Moved to read it again. What's on screen stays until the answer comes, /// and stays if none does. var refresh = 0 @ViewBuilder let content: (Value) -> Content @State private var value: Value? @State private var failure: String? @State private var loaded: Int? var body: some View { Group { if let value { content(value) } else if let failure { ContentUnavailableView { SwiftUI.Label("This didn't load", systemImage: "wifi.exclamationmark") } description: { Text(failure) } actions: { Button("Try again") { Task { await run() } } } } else { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } } .task(id: refresh) { if value == nil || loaded != refresh { await run() } } } private func run() async { failure = nil do { value = try await load() loaded = refresh } catch is CancellationError { } catch let error as URLError where error.code == .cancelled { } catch { if value == nil { failure = error.localizedDescription } } } } struct StatePill: View { let style: StateStyle var body: some View { HStack(spacing: Space.s1_5) { Image(style.icon).renderingMode(.template).resizable().scaledToFit().frame(width: 14, height: 14) Text(style.label) } .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.onSolid) .padding(.horizontal, Space.s2_5) .padding(.vertical, Space.s1_5) .background(style.pillColor, in: Capsule()) } } extension StateStyle { /// A solid fill under `onSolid` text: in iOS's own colours dark in light and /// dark alike, so white reads on it; in a theme, its own state colours, under its background. var pillColor: Color { switch label { case "Merged": Color(themed: \.done, otherwise: UIColor(hex: 0x7048b5)) case "Open": Color.successFill case "Closed": icon == "oct-issue-closed" ? Color(themed: \.done, otherwise: UIColor(hex: 0x7048b5)) : Color(themed: \.danger, otherwise: UIColor(hex: 0xcf222e)) default: Color(themed: \.neutral, otherwise: UIColor(hex: 0x636363)) } } } /// An icon close beside its words, where a list would space them apart. struct TightLabel: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(spacing: Space.s1) { configuration.icon configuration.title } } } struct BranchChip: View { let name: String var body: some View { Text(name) .font(.footnote.monospaced()) .foregroundStyle(Color.accent) .lineLimit(1) .padding(.horizontal, Space.s2) .padding(.vertical, Space.s1) .background(Color.accentSoft, in: RoundedRectangle(cornerRadius: Radius.small)) } } struct StateIcon: View { let style: StateStyle var size: CGFloat = 20 var body: some View { Image(style.icon).renderingMode(.template).resizable().scaledToFit() .frame(width: size, height: size) .foregroundStyle(style.color) .accessibilityLabel(style.label) } } /// An Octicon, as the web pages use them, tinted by what's around it. struct Oct: View { let name: String var size: CGFloat = Icon.md var body: some View { Image("oct-" + name).renderingMode(.template).resizable().scaledToFit().frame(width: size, height: size) } } struct IconSquare: View { let image: Image let color: Color var body: some View { image.renderingMode(.template).resizable().scaledToFit() .frame(width: 16, height: 16) .foregroundStyle(Color.onSolid) .frame(width: 30, height: 30) .background(color, in: RoundedRectangle(cornerRadius: Radius.medium)) } } struct RepoRow: View { let repo: Repo var body: some View { NavigationLink(value: Route.repo(repo.ref)) { HStack(spacing: Space.s3) { Avatar(url: repo.owner.avatar, name: repo.owner.handle, did: repo.owner.did, size: Size.Avatar.regular) VStack(alignment: .leading, spacing: Space.s0_5) { Text(repo.owner.handle).font(.footnote).foregroundStyle(.secondary).lineLimit(1) HStack(spacing: Space.s1_5) { Text(repo.name).font(.body.weight(.medium)).lineLimit(1) if repo.private == true { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) .accessibilityLabel("Private") } } } } } } } struct CheckSummary { let passed: Int, failed: Int, running: Int init(_ checks: [Check]) { passed = checks.filter { ["success", "cancelled"].contains($0.status) }.count failed = checks.filter { ["failure", "error"].contains($0.status) }.count running = checks.count - passed - failed } var symbol: String { failed > 0 ? "x-circle-fill" : running > 0 ? "clock" : "check-circle-fill" } var color: Color { failed > 0 ? .danger : running > 0 ? .attention : .success } var text: String { if failed > 0 { return failed == 1 ? "1 check failed" : "\(failed) checks failed" } if running > 0 { return running == 1 ? "1 check running" : "\(running) checks running" } return passed == 1 ? "1 check passed" : "All \(passed) checks passed" } } /// An account's picture, at once if it has been seen since the app opened. A /// plain ground until it arrives: its identicon would flash first, though that /// isn't what it looks like. The identicon only if the picture won't load. private struct KeptFace: View { let link: URL @ViewBuilder let fallback: () -> Fallback @State private var image: UIImage? @State private var failed = false var body: some View { Group { if let shown = image ?? Faces.kept(link) { Image(uiImage: shown).resizable().scaledToFill() } else if failed { fallback() } else { Color.fill } } .task(id: link) { image = Faces.kept(link) failed = false guard image == nil else { return } do { image = try await Faces.shared.load(link) } catch is CancellationError { // It's off the screen; the next look asks again. } catch { failed = true } } } } /// A button beside the main one: in a theme, on its surface with its text, as /// the pages' plain `.btn`, so only the main one takes a colour; in iOS's own /// colours, iOS's bordered button. private struct QuietButton: ViewModifier { let large: Bool let radius: CGFloat /// Read, so a change of theme draws it again. @Environment(\.themeGeneration) private var generation func body(content: Content) -> some View { if Themes.current != nil { content.buttonStyle(QuietStyle(large: large, radius: radius)) } else { content.buttonStyle(.bordered).buttonBorderShape(.roundedRectangle(radius: radius)) } } } private struct QuietStyle: ButtonStyle { let large: Bool let radius: CGFloat func makeBody(configuration: Configuration) -> some View { Drawn(configuration: configuration, large: large, radius: radius) } private struct Drawn: View { let configuration: Configuration let large: Bool let radius: CGFloat @Environment(\.isEnabled) private var enabled var body: some View { let shape = RoundedRectangle(cornerRadius: radius) configuration.label .foregroundStyle(Color.fg) .padding(.horizontal, large ? Space.s4 : Space.s3) .padding(.vertical, large ? Space.s3_5 : Space.s1_5) .background(Color.raised, in: shape) .overlay(shape.stroke(Color.line)) .opacity(enabled ? (configuration.isPressed ? 0.7 : 1) : 0.5) .contentShape(shape) } } } extension View { func quietButton(large: Bool = false, radius: CGFloat) -> some View { modifier(QuietButton(large: large, radius: radius)) } }