gitbay/Views/Account/NotificationsView.swift

v1.4.0
gitbay-ios/gitbay/Views/Account/NotificationsView.swift history · blame · raw

184 lines · 7267 bytes

  1import SwiftUI
  2import UIKit
  3import UserNotifications
  4
  5/// The notification inbox: `notifications list [--all]`, paginated, with
  6/// a switch between the default unread-only listing and everything.
  7struct NotificationsView: View {
  8
  9    @State private var model: NotificationsViewModel
 10    @Environment(PushRegistrar.self) private var registrar
 11    @Environment(\.scenePhase) private var scenePhase
 12    @State private var authorization: UNAuthorizationStatus = .notDetermined
 13
 14    init(client: GitbayClient) {
 15        _model = State(initialValue: NotificationsViewModel(client: client))
 16    }
 17
 18    var body: some View {
 19        List {
 20            pushSection
 21
 22            Picker("Filter", selection: Bindable(model).showAll) {
 23                Text("Unread").tag(false)
 24                Text("All").tag(true)
 25            }
 26            .pickerStyle(.segmented)
 27            .listRowBackground(Color.clear)
 28            .listRowInsets(EdgeInsets())
 29
 30            if let actionError = model.actionError {
 31                Section { GBNotice(actionError) }
 32            }
 33
 34            ForEach(model.state.value ?? []) { notification in
 35                NotificationLink(notification: notification)
 36                    .swipeActions {
 37                        if notification.isUnread {
 38                            Button("Mark read") {
 39                                Task { await model.markRead(notification.id) }
 40                            }
 41                            .tint(.gbAccent)
 42                        }
 43                    }
 44            }
 45            PageFooter(list: model.list)
 46        }
 47        .overlay { LoadStateOverlay(state: model.state) }
 48        .navigationTitle("Notifications")
 49        .navigationBarTitleDisplayMode(.inline)
 50        .toolbar {
 51            ToolbarItem(placement: .topBarTrailing) {
 52                Button("Mark all read") {
 53                    Task { await model.markAllRead() }
 54                }
 55                .disabled(model.working)
 56            }
 57        }
 58        .task { await model.load() }
 59        .task { await model.loadPushSettings() }
 60        .task { authorization = await registrar.authorizationStatus() }
 61        // Settings is exactly where the denied row sends the user, and
 62        // returning from it is a scene activation, not a refresh — this
 63        // is what actually clears a stale "denied" once they flip the
 64        // switch there.
 65        .onChange(of: scenePhase) { _, newPhase in
 66            guard newPhase == .active else { return }
 67            Task {
 68                let status = await registrar.authorizationStatus()
 69                // Granting in Settings is the one route to .authorized
 70                // that never passes through the toggle, so it is also
 71                // the one route that never asks iOS for a device
 72                // token. Without this the toggle turns on, the server
 73                // preference is set, and no `device add` is ever sent.
 74                let justGranted = status == .authorized && authorization != .authorized
 75                authorization = status
 76                if justGranted { await registrar.registerIfAlreadyAuthorized() }
 77            }
 78        }
 79        .refreshable {
 80            await model.load()
 81            await model.loadPushSettings()
 82            authorization = await registrar.authorizationStatus()
 83        }
 84    }
 85
 86    /// Push has two independent switches — iOS authorization and the
 87    /// server preference — and the toggle must read false when either
 88    /// one is off. Permission is asked here, on the user turning the
 89    /// toggle on, not on the screen appearing: iOS asks once per
 90    /// install, and a prompt sprung by navigation earns a reflexive
 91    /// "Don't Allow" that nothing afterwards can explain.
 92    private var pushSection: some View {
 93        Section("Push notifications") {
 94            Toggle("Push notifications", isOn: Binding(
 95                get: { authorization == .authorized && model.pushEnabled },
 96                set: { on in Task { await setPush(on) } }
 97            ))
 98            .disabled(authorization == .denied)
 99
100            if authorization == .denied {
101                GBNotice("Notifications are turned off for gitbay in Settings.", .gbWarn)
102                Button("Open Settings") {
103                    if let url = URL(string: UIApplication.openSettingsURLString) {
104                        UIApplication.shared.open(url)
105                    }
106                }
107                .font(.gbSans(.subheadline))
108            }
109
110            ForEach(model.devices) { device in
111                VStack(alignment: .leading, spacing: 2) {
112                    Text(device.label)
113                        .font(.gbSans(.subheadline))
114                    Text(device.token)
115                        .font(.gbMono(.caption))
116                        .foregroundStyle(.secondary)
117                }
118                .swipeActions {
119                    Button("Remove", role: .destructive) {
120                        Task { await model.removeDevice(device.id) }
121                    }
122                }
123            }
124        }
125    }
126
127    private func setPush(_ on: Bool) async {
128        if on, authorization == .notDetermined {
129            let granted = await registrar.requestPushPermission()
130            authorization = await registrar.authorizationStatus()
131            guard granted else { return }
132        }
133        await model.setPush(on)
134    }
135}
136
137/// Routes to `destination` when the path resolves to a screen; a
138/// notification the app cannot route renders plainly and is not
139/// tappable.
140private struct NotificationLink: View {
141    let notification: InboxNotification
142
143    @ViewBuilder
144    var body: some View {
145        switch notification.destination {
146        case .issue(let repo, let number):
147            NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) {
148                NotificationRow(notification: notification)
149            }
150        case .mr(let repo, let number):
151            NavigationLink(value: MRRoute.mr(repo: repo, number: number)) {
152                NotificationRow(notification: notification)
153            }
154        case .build(let repo, let number):
155            NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) {
156                NotificationRow(notification: notification)
157            }
158        case nil:
159            NotificationRow(notification: notification)
160        }
161    }
162}
163
164private struct NotificationRow: View {
165    let notification: InboxNotification
166
167    var body: some View {
168        VStack(alignment: .leading, spacing: 4) {
169            HStack(alignment: .firstTextBaseline) {
170                Text(notification.actor)
171                    .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
172                Spacer()
173                Text(notification.createdAt, format: .relative(presentation: .named))
174                    .font(.gbSans(.caption))
175                    .foregroundStyle(.secondary)
176            }
177            Text(notification.summary)
178                .font(.gbSans(.subheadline).weight(notification.isUnread ? .semibold : .regular))
179                .foregroundStyle(notification.isUnread ? .primary : .secondary)
180                .lineLimit(2)
181        }
182        .padding(.vertical, 2)
183    }
184}