gitbay/Views/Discovery/ExploreView.swift

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

182 lines · 6850 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                Picker("Kind", selection: Bindable(search).kind) {
 46                    ForEach(SearchKind.allCases) { kind in
 47                        Text(kind.label).tag(kind)
 48                    }
 49                }
 50                .pickerStyle(.segmented)
 51                .listRowBackground(Color.clear)
 52                .listRowInsets(EdgeInsets())
 53
 54                ForEach(search.state.value ?? []) { result in
 55                    searchRow(for: result)
 56                }
 57            } else {
 58                ForEach(list.state.value ?? []) { repo in
 59                    NavigationLink(value: RepoRoute.repo(repo.path)) {
 60                        VStack(alignment: .leading, spacing: 4) {
 61                            HStack(spacing: 6) {
 62                                Text(path(of: repo))
 63                                if repo.isArchived {
 64                                    GBChip("archived", .secondary)
 65                                }
 66                            }
 67                            .font(.gbSans(.subheadline))
 68                            .lineLimit(1)
 69
 70                            if let description = repo.description, !description.isEmpty {
 71                                Text(description)
 72                                    .font(.gbSans(.caption))
 73                                    .foregroundStyle(.secondary)
 74                                    .lineLimit(2)
 75                            }
 76                            if let topics = repo.topics, !topics.isEmpty {
 77                                ScrollView(.horizontal, showsIndicators: false) {
 78                                    HStack(spacing: 6) {
 79                                        ForEach(topics, id: \.self) { topic in
 80                                            GBChip(topic, .gbAccent)
 81                                        }
 82                                    }
 83                                }
 84                            }
 85                        }
 86                        .padding(.vertical, 2)
 87                    }
 88                }
 89                PageFooter(list: list)
 90            }
 91        }
 92        .overlay {
 93            if search.isSearching {
 94                LoadStateOverlay(state: search.state)
 95            } else {
 96                LoadStateOverlay(state: list.state)
 97            }
 98        }
 99        .searchable(text: Bindable(search).query, prompt: "Search this instance")
100        .navigationTitle("Explore")
101        .task {
102            if list.state.value == nil { await list.reload() }
103        }
104        .refreshable { await list.reload() }
105    }
106
107    @ViewBuilder
108    private func searchRow(for result: SearchResult) -> some View {
109        switch result.kind {
110        case "repo":
111            NavigationLink(value: RepoRoute.repo(result.repo)) {
112                Text(styledPath(result.repo))
113                    .font(.gbSans(.subheadline))
114                    .lineLimit(1)
115                    .padding(.vertical, 2)
116            }
117        case "issue":
118            if let number = result.number {
119                NavigationLink(value: IssueRoute.issue(repo: result.repo, number: number)) {
120                    SearchEntryRow(result: result)
121                }
122            } else {
123                SearchEntryRow(result: result)
124            }
125        case "mr":
126            if let number = result.number {
127                NavigationLink(value: MRRoute.mr(repo: result.repo, number: number)) {
128                    SearchEntryRow(result: result)
129                }
130            } else {
131                SearchEntryRow(result: result)
132            }
133        default:
134            SearchEntryRow(result: result)
135        }
136    }
137}
138
139/// An issue or merge request row from `search` — `#` for an issue,
140/// `!` for a merge request, this forge's convention for the two.
141private struct SearchEntryRow: View {
142    let result: SearchResult
143
144    var body: some View {
145        VStack(alignment: .leading, spacing: 4) {
146            Text(result.repo)
147                .font(.gbSans(.caption))
148                .foregroundStyle(.secondary)
149                .lineLimit(1)
150            HStack(alignment: .firstTextBaseline, spacing: 6) {
151                if let number = result.number {
152                    Text((result.kind == "issue" ? "#" : "!") + "\(number)")
153                        .font(.gbMono(.caption))
154                        .foregroundStyle(.secondary)
155                }
156                Text(result.title)
157                    .font(.gbSans(.subheadline).weight(.medium))
158                    .lineLimit(2)
159            }
160            HStack(spacing: 6) {
161                if let state = result.state {
162                    GBChip(state, color(for: state))
163                }
164                Spacer()
165                if let updatedAt = result.updatedAt {
166                    Text(updatedAt, format: .relative(presentation: .named))
167                        .font(.gbSans(.caption))
168                        .foregroundStyle(.secondary)
169                }
170            }
171        }
172        .padding(.vertical, 2)
173    }
174
175    private func color(for state: String) -> Color {
176        switch state {
177        case "open": .gbOK
178        case "merged": .gbDone
179        default: .gbBad
180        }
181    }
182}