gitbay/Views/Discovery/ExploreView.swift

v1.10.0
gitbay-ios/gitbay/Views/Discovery/ExploreView.swift history · blame · raw

184 lines · 6938 bytes

  1import SwiftUI
  2
  3/// What this instance hosts. `repo search` needs a query; this is the
  4/// listing you read when you do not know a name yet.
  5struct ExploreView: View {
  6
  7    @State private var list: PagedListModel<PublicRepo>
  8    @State private var search: SearchViewModel
  9
 10    init(client: GitbayClient) {
 11        _list = State(initialValue: PagedListModel(
 12            client: client,
 13            argv: ["explore"],
 14            emptyMessage: "This instance hosts no public repositories."
 15        ))
 16        _search = State(initialValue: SearchViewModel(client: client))
 17    }
 18
 19    /// One Text, not two: the HStack spaces its children, and a gap
 20    /// between the owner and the name would read as part of the path.
 21    private func path(of repo: PublicRepo) -> AttributedString {
 22        var owner = AttributedString(repo.owner + "/")
 23        owner.foregroundColor = .secondary
 24        var name = AttributedString(repo.name)
 25        name.font = .gbSans(.subheadline).weight(.medium)
 26        return owner + name
 27    }
 28
 29    /// Same styling as `path(of:)`, but for a search result's already
 30    /// joined "owner/name" string.
 31    private func styledPath(_ path: String) -> AttributedString {
 32        guard let slash = path.firstIndex(of: "/") else {
 33            return AttributedString(path)
 34        }
 35        var owner = AttributedString(String(path[...slash]))
 36        owner.foregroundColor = .secondary
 37        var name = AttributedString(String(path[path.index(after: slash)...]))
 38        name.font = .gbSans(.subheadline).weight(.medium)
 39        return owner + name
 40    }
 41
 42    var body: some View {
 43        List {
 44            if search.isSearching {
 45                Section {
 46                    Picker("Kind", selection: Bindable(search).kind) {
 47                        ForEach(SearchKind.allCases) { kind in
 48                            Text(kind.label).tag(kind)
 49                        }
 50                    }
 51                    .pickerStyle(.segmented)
 52                    .listRowBackground(Color.clear)
 53                    .listRowInsets(EdgeInsets())
 54                }
 55
 56                ForEach(search.state.value ?? []) { result in
 57                    searchRow(for: result)
 58                }
 59            } else {
 60                ForEach(list.state.value ?? []) { repo in
 61                    NavigationLink(value: RepoRoute.repo(repo.path)) {
 62                        VStack(alignment: .leading, spacing: 4) {
 63                            HStack(spacing: 6) {
 64                                Text(path(of: repo))
 65                                if repo.isArchived {
 66                                    GBChip("archived", .secondary)
 67                                }
 68                            }
 69                            .font(.gbSans(.subheadline))
 70                            .lineLimit(1)
 71
 72                            if let description = repo.description, !description.isEmpty {
 73                                Text(description)
 74                                    .font(.gbSans(.caption))
 75                                    .foregroundStyle(.secondary)
 76                                    .lineLimit(2)
 77                            }
 78                            if let topics = repo.topics, !topics.isEmpty {
 79                                ScrollView(.horizontal, showsIndicators: false) {
 80                                    HStack(spacing: 6) {
 81                                        ForEach(topics, id: \.self) { topic in
 82                                            GBChip(topic, .gbAccent)
 83                                        }
 84                                    }
 85                                }
 86                            }
 87                        }
 88                        .padding(.vertical, 2)
 89                    }
 90                }
 91                PageFooter(list: list)
 92            }
 93        }
 94        .overlay {
 95            if search.isSearching {
 96                LoadStateOverlay(state: search.state)
 97            } else {
 98                LoadStateOverlay(state: list.state)
 99            }
100        }
101        .searchable(text: Bindable(search).query, prompt: "Search this instance")
102        .navigationTitle("Explore")
103        .task {
104            if list.state.value == nil { await list.reload() }
105        }
106        .refreshable { await list.reload() }
107    }
108
109    @ViewBuilder
110    private func searchRow(for result: SearchResult) -> some View {
111        switch result.kind {
112        case "repo":
113            NavigationLink(value: RepoRoute.repo(result.repo)) {
114                Text(styledPath(result.repo))
115                    .font(.gbSans(.subheadline))
116                    .lineLimit(1)
117                    .padding(.vertical, 2)
118            }
119        case "issue":
120            if let number = result.number {
121                NavigationLink(value: IssueRoute.issue(repo: result.repo, number: number)) {
122                    SearchEntryRow(result: result)
123                }
124            } else {
125                SearchEntryRow(result: result)
126            }
127        case "mr":
128            if let number = result.number {
129                NavigationLink(value: MRRoute.mr(repo: result.repo, number: number)) {
130                    SearchEntryRow(result: result)
131                }
132            } else {
133                SearchEntryRow(result: result)
134            }
135        default:
136            SearchEntryRow(result: result)
137        }
138    }
139}
140
141/// An issue or merge request row from `search` — `#` for an issue,
142/// `!` for a merge request, this forge's convention for the two.
143private struct SearchEntryRow: View {
144    let result: SearchResult
145
146    var body: some View {
147        VStack(alignment: .leading, spacing: 4) {
148            Text(result.repo)
149                .font(.gbSans(.caption))
150                .foregroundStyle(.secondary)
151                .lineLimit(1)
152            HStack(alignment: .firstTextBaseline, spacing: 6) {
153                if let number = result.number {
154                    Text((result.kind == "issue" ? "#" : "!") + "\(number)")
155                        .font(.gbMono(.caption))
156                        .foregroundStyle(.secondary)
157                }
158                Text(result.title)
159                    .font(.gbSans(.subheadline).weight(.medium))
160                    .lineLimit(2)
161            }
162            HStack(spacing: 6) {
163                if let state = result.state {
164                    GBChip(stateLabel(state), color(for: state))
165                }
166                Spacer()
167                if let updatedAt = result.updatedAt {
168                    Text(updatedAt, format: .relative(presentation: .named))
169                        .font(.gbSans(.caption))
170                        .foregroundStyle(.secondary)
171                }
172            }
173        }
174        .padding(.vertical, 2)
175    }
176
177    private func color(for state: String) -> Color {
178        switch state {
179        case "open": .gbOK
180        case "merged": .gbDone
181        default: .gbBad
182        }
183    }
184}