gitbay/Views/Dashboard/DashboardView.swift

v1.9.0
gitbay-ios/gitbay/Views/Dashboard/DashboardView.swift history · blame · raw

191 lines · 6861 bytes

  1import SwiftUI
  2
  3/// The native representation of `gitbay dashboard`, ordered and phrased
  4/// like the logged-in web dashboard.
  5struct DashboardView: View {
  6
  7    @Environment(SessionStore.self) private var session
  8    @State private var model: DashboardViewModel
  9    @State private var createModel: RepoCreateViewModel
 10    @State private var composing = false
 11    /// The unread count, for the Dashboard tab's badge.
 12    @Binding var unread: Int
 13
 14    init(client: GitbayClient, unread: Binding<Int>) {
 15        _unread = unread
 16        _model = State(initialValue: DashboardViewModel(client: client))
 17        _createModel = State(initialValue: RepoCreateViewModel(client: client))
 18    }
 19
 20    var body: some View {
 21        RoutingList {
 22            if let data = model.state.value {
 23                itemSection(
 24                    "Waiting on your review", items: data.reviewQueue,
 25                    empty: "Nothing waiting on you", marker: "!"
 26                ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
 27
 28                itemSection(
 29                    "Assigned to you", items: data.assignedIssues,
 30                    empty: "Nothing assigned to you", marker: "#"
 31                ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
 32
 33                itemSection(
 34                    "Open merge requests", items: data.openMRs,
 35                    empty: "No open merge requests", marker: "!"
 36                ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
 37
 38                itemSection(
 39                    "Open issues", items: data.openIssues,
 40                    empty: "No open issues", marker: "#"
 41                ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
 42
 43                pinnedSection(data.pinned)
 44                activitySection(data.recentActivity)
 45            }
 46        }
 47        .overlay { LoadStateOverlay(state: model.state) }
 48        .navigationTitle("Dashboard")
 49        .toolbar {
 50            ToolbarItem(placement: .topBarTrailing) {
 51                RouteLink(value: RepoRoute.notifications) {
 52                    HStack(spacing: 2) {
 53                        Image(systemName: "bell")
 54                        if let unread = model.state.value?.unread, unread > 0 {
 55                            Text(String(unread))
 56                                .font(.gbSans(.caption2))
 57                        }
 58                    }
 59                }
 60                .accessibilityIdentifier("dashboard-notifications-button")
 61                .accessibilityLabel(bellLabel)
 62            }
 63            ToolbarItem(placement: .topBarTrailing) {
 64                Button {
 65                    composing = true
 66                } label: {
 67                    Image(systemName: "plus")
 68                }
 69                .accessibilityLabel("New Repository")
 70                .accessibilityIdentifier("dashboard-create-button")
 71            }
 72        }
 73        .sheet(isPresented: $composing) {
 74            RepoCreateSheet(
 75                model: createModel,
 76                ownerPrefix: session.current?.username ?? ""
 77            ) {
 78                composing = false
 79                Task { await model.load() }
 80            }
 81        }
 82        .task { await model.load() }
 83        .refreshable { await model.load() }
 84        .onChange(of: model.state.value?.unread) { _, count in
 85            if let count { unread = count }
 86        }
 87    }
 88
 89    private var bellLabel: String {
 90        guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
 91        return "Notifications, \(unread) unread"
 92    }
 93
 94    private func itemSection(
 95        _ title: String,
 96        items: [DashboardItem],
 97        empty: String,
 98        marker: String,
 99        route: @escaping (DashboardItem) -> some Hashable
100    ) -> some View {
101        Section {
102            if items.isEmpty {
103                Text(empty)
104                    .font(.gbSans(.subheadline))
105                    .foregroundStyle(.secondary)
106            } else {
107                ForEach(items) { item in
108                    NavigationLink(value: route(item)) {
109                        dashboardItem(item, marker: marker)
110                    }
111                }
112            }
113        } header: {
114            sectionHeader(title, count: items.count)
115        }
116    }
117
118    private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
119        VStack(alignment: .leading, spacing: 3) {
120            Text(item.title)
121                .font(.gbSans(.subheadline).weight(.semibold))
122                .lineLimit(2)
123            HStack(spacing: 4) {
124                Text(item.repo + marker + String(item.number))
125                    .font(.gbSans(.caption))
126                Text("·")
127                Text(item.author)
128                    .foregroundStyle(Color.gbAccent)
129                Text("·")
130                Text(item.updatedAt, format: .relative(presentation: .named))
131                if item.state == "source_gone" {
132                    Text("·")
133                    GBChip(stateLabel(item.state), .secondary)
134                }
135            }
136            .font(.gbSans(.caption))
137            .foregroundStyle(.secondary)
138            .lineLimit(1)
139        }
140        .padding(.vertical, 2)
141    }
142
143    private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
144        Section {
145            if pinned.isEmpty {
146                Text("Pin a repository to keep it here")
147                    .font(.gbSans(.subheadline))
148                    .foregroundStyle(.secondary)
149            } else {
150                ForEach(pinned) { repo in
151                    NavigationLink(value: RepoRoute.repo(repo.path)) {
152                        HStack(spacing: 6) {
153                            Text(repo.path)
154                                .font(.gbSans(.subheadline).weight(.medium))
155                                .foregroundStyle(Color.gbAccent)
156                            if repo.isPrivate {
157                                GBChip("Private", .secondary)
158                            }
159                        }
160                    }
161                }
162            }
163        } header: {
164            Text("Pinned")
165        }
166    }
167
168    private func activitySection(_ events: [FeedEvent]) -> some View {
169        Section {
170            if events.isEmpty {
171                Text("No activity yet")
172                    .font(.gbSans(.subheadline))
173                    .foregroundStyle(.secondary)
174            } else {
175                ForEach(events) { event in
176                    FeedLink(event: event)
177                }
178            }
179        } header: {
180            Text("Recent activity")
181        }
182    }
183
184    private func sectionHeader(_ title: String, count: Int) -> some View {
185        HStack(alignment: .firstTextBaseline, spacing: 4) {
186            Text(title)
187            Text(String(count))
188                .foregroundStyle(.secondary)
189        }
190    }
191}