Commit f321cd5b62
Verified · cmc
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift +7 −2
| @@ -54,12 +54,15 @@ nonisolated enum NotificationDestination: Hashable, Sendable { | |||
| 54 | final class NotificationsViewModel { | 54 | final class NotificationsViewModel { |
| 55 | 55 | ||
| 56 | let list: PagedListModel<InboxNotification> | 56 | let list: PagedListModel<InboxNotification> |
| 57 | /// Off shows unread only, the default listing; on adds `--all`. | 57 | /// Off shows unread only, the default listing; on adds `--all`. A |
| 58 | /// segmented control is quick to tap repeatedly, so a stale reload | ||
| 59 | /// from an earlier tap must not land after a later one. | ||
| 58 | var showAll = false { | 60 | var showAll = false { |
| 59 | didSet { | 61 | didSet { |
| 60 | guard showAll != oldValue else { return } | 62 | guard showAll != oldValue else { return } |
| 61 | configureList() | 63 | configureList() |
| 62 | Task { await list.reload() } | 64 | reloadTask?.cancel() |
| 65 | reloadTask = Task { await list.reload() } | ||
| 63 | } | 66 | } |
| 64 | } | 67 | } |
| 65 | 68 | ||
| @@ -67,6 +70,7 @@ final class NotificationsViewModel { | |||
| 67 | private(set) var working = false | 70 | private(set) var working = false |
| 68 | 71 | ||
| 69 | private let client: GitbayClient | 72 | private let client: GitbayClient |
| 73 | private var reloadTask: Task<Void, Never>? | ||
| 70 | 74 | ||
| 71 | init(client: GitbayClient) { | 75 | init(client: GitbayClient) { |
| 72 | self.client = client | 76 | self.client = client |
| @@ -80,6 +84,7 @@ final class NotificationsViewModel { | |||
| 80 | var state: LoadState<[InboxNotification]> { list.state } | 84 | var state: LoadState<[InboxNotification]> { list.state } |
| 81 | 85 | ||
| 82 | func load() async { | 86 | func load() async { |
| 87 | reloadTask?.cancel() | ||
| 83 | await list.reload() | 88 | await list.reload() |
| 84 | } | 89 | } |
| 85 | 90 | ||
gitbay/ContentView.swift +2
| @@ -114,6 +114,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 114 | AccountView(client: client) | 114 | AccountView(client: client) |
| 115 | case .addAccount: | 115 | case .addAccount: |
| 116 | SignInView() | 116 | SignInView() |
| 117 | case .notifications: | ||
| 118 | NotificationsView(client: client) | ||
| 117 | } | 119 | } |
| 118 | } | 120 | } |
| 119 | 121 | ||
gitbay/Dashboard/DashboardModels.swift +5 −1
| @@ -14,9 +14,12 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | |||
| 14 | /// Retained by the CLI for compatibility; builds are not a web | 14 | /// Retained by the CLI for compatibility; builds are not a web |
| 15 | /// dashboard section. | 15 | /// dashboard section. |
| 16 | let builds: [DashboardBuild] | 16 | let builds: [DashboardBuild] |
| 17 | /// The unread notification count, so a client showing a badge does | ||
| 18 | /// not need a second read to fill it. | ||
| 19 | let unread: Int | ||
| 17 | 20 | ||
| 18 | enum CodingKeys: String, CodingKey { | 21 | enum CodingKeys: String, CodingKey { |
| 19 | case pinned, builds | 22 | case pinned, builds, unread |
| 20 | case reviewQueue = "review_queue" | 23 | case reviewQueue = "review_queue" |
| 21 | case openMRs = "open_mrs" | 24 | case openMRs = "open_mrs" |
| 22 | case assignedIssues = "assigned_issues" | 25 | case assignedIssues = "assigned_issues" |
| @@ -33,6 +36,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | |||
| 33 | pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] | 36 | pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? [] |
| 34 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] | 37 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] |
| 35 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] | 38 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] |
| 39 | unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0 | ||
| 36 | } | 40 | } |
| 37 | } | 41 | } |
| 38 | 42 | ||
gitbay/Views/Account/NotificationsView.swift added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The notification inbox: `notifications list [--all]`, paginated, with | ||
| 4 | /// a switch between the default unread-only listing and everything. | ||
| 5 | struct NotificationsView: View { | ||
| 6 | |||
| 7 | @State private var model: NotificationsViewModel | ||
| 8 | |||
| 9 | init(client: GitbayClient) { | ||
| 10 | _model = State(initialValue: NotificationsViewModel(client: client)) | ||
| 11 | } | ||
| 12 | |||
| 13 | var body: some View { | ||
| 14 | List { | ||
| 15 | Picker("Filter", selection: Bindable(model).showAll) { | ||
| 16 | Text("Unread").tag(false) | ||
| 17 | Text("All").tag(true) | ||
| 18 | } | ||
| 19 | .pickerStyle(.segmented) | ||
| 20 | .listRowBackground(Color.clear) | ||
| 21 | .listRowInsets(EdgeInsets()) | ||
| 22 | |||
| 23 | if let actionError = model.actionError { | ||
| 24 | Section { GBNotice(actionError) } | ||
| 25 | } | ||
| 26 | |||
| 27 | ForEach(model.state.value ?? []) { notification in | ||
| 28 | NotificationLink(notification: notification) | ||
| 29 | .swipeActions { | ||
| 30 | if notification.isUnread { | ||
| 31 | Button("Mark read") { | ||
| 32 | Task { await model.markRead(notification.id) } | ||
| 33 | } | ||
| 34 | .tint(.gbAccent) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | PageFooter(list: model.list) | ||
| 39 | } | ||
| 40 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 41 | .navigationTitle("Notifications") | ||
| 42 | .navigationBarTitleDisplayMode(.inline) | ||
| 43 | .toolbar { | ||
| 44 | ToolbarItem(placement: .topBarTrailing) { | ||
| 45 | Button("Mark all read") { | ||
| 46 | Task { await model.markAllRead() } | ||
| 47 | } | ||
| 48 | .disabled(model.working) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | .task { await model.load() } | ||
| 52 | .refreshable { await model.load() } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Routes to `destination` when the path resolves to a screen; a | ||
| 57 | /// notification the app cannot route renders plainly and is not | ||
| 58 | /// tappable. | ||
| 59 | private struct NotificationLink: View { | ||
| 60 | let notification: InboxNotification | ||
| 61 | |||
| 62 | @ViewBuilder | ||
| 63 | var body: some View { | ||
| 64 | switch notification.destination { | ||
| 65 | case .issue(let repo, let number): | ||
| 66 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { | ||
| 67 | NotificationRow(notification: notification) | ||
| 68 | } | ||
| 69 | case .mr(let repo, let number): | ||
| 70 | NavigationLink(value: MRRoute.mr(repo: repo, number: number)) { | ||
| 71 | NotificationRow(notification: notification) | ||
| 72 | } | ||
| 73 | case .build(let repo, let number): | ||
| 74 | NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) { | ||
| 75 | NotificationRow(notification: notification) | ||
| 76 | } | ||
| 77 | case nil: | ||
| 78 | NotificationRow(notification: notification) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | private struct NotificationRow: View { | ||
| 84 | let notification: InboxNotification | ||
| 85 | |||
| 86 | var body: some View { | ||
| 87 | VStack(alignment: .leading, spacing: 4) { | ||
| 88 | HStack(alignment: .firstTextBaseline) { | ||
| 89 | Text(notification.actor) | ||
| 90 | .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) | ||
| 91 | Spacer() | ||
| 92 | Text(notification.createdAt, format: .relative(presentation: .named)) | ||
| 93 | .font(.gbSans(.caption)) | ||
| 94 | .foregroundStyle(.secondary) | ||
| 95 | } | ||
| 96 | Text(notification.summary) | ||
| 97 | .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular)) | ||
| 98 | .foregroundStyle(notification.isUnread ? .primary : .secondary) | ||
| 99 | .lineLimit(2) | ||
| 100 | } | ||
| 101 | .padding(.vertical, 2) | ||
| 102 | } | ||
| 103 | } | ||
gitbay/Views/Dashboard/DashboardView.swift +12
| @@ -44,6 +44,18 @@ struct DashboardView: View { | |||
| 44 | .overlay { LoadStateOverlay(state: model.state) } | 44 | .overlay { LoadStateOverlay(state: model.state) } |
| 45 | .navigationTitle("Dashboard") | 45 | .navigationTitle("Dashboard") |
| 46 | .toolbar { | 46 | .toolbar { |
| 47 | ToolbarItem(placement: .topBarTrailing) { | ||
| 48 | NavigationLink(value: RepoRoute.notifications) { | ||
| 49 | HStack(spacing: 2) { | ||
| 50 | Image(systemName: "bell") | ||
| 51 | if let unread = model.state.value?.unread, unread > 0 { | ||
| 52 | Text(String(unread)) | ||
| 53 | .font(.gbSans(.caption2)) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | } | ||
| 57 | .accessibilityIdentifier("dashboard-notifications-button") | ||
| 58 | } | ||
| 47 | ToolbarItem(placement: .topBarTrailing) { | 59 | ToolbarItem(placement: .topBarTrailing) { |
| 48 | Button { | 60 | Button { |
| 49 | composing = true | 61 | composing = true |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -20,4 +20,5 @@ nonisolated enum RepoRoute: Hashable { | |||
| 20 | case profile(String) | 20 | case profile(String) |
| 21 | case account | 21 | case account |
| 22 | case addAccount | 22 | case addAccount |
| 23 | case notifications | ||
| 23 | } | 24 | } |