Add native iOS notifications (#64)
This commit is contained in:
@@ -4,6 +4,7 @@ import WidgetKit
|
||||
@MainActor
|
||||
final class AppContext {
|
||||
let core: GotchaCore
|
||||
lazy var notifications = NotificationCoordinator(context: self)
|
||||
private let window: UIWindow
|
||||
private(set) var tabs = UITabBarController()
|
||||
private(set) var navigationControllers: [UINavigationController] = []
|
||||
@@ -96,14 +97,77 @@ final class AppContext {
|
||||
}
|
||||
|
||||
func route(_ activity: ActivityRow) {
|
||||
route(
|
||||
serverId: nil,
|
||||
target: activity.target,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
number: activity.number,
|
||||
sha: activity.sha
|
||||
)
|
||||
}
|
||||
|
||||
func route(_ notification: NotificationRow) {
|
||||
route(
|
||||
serverId: notification.serverId,
|
||||
target: notification.target,
|
||||
owner: notification.owner,
|
||||
repository: notification.repository,
|
||||
number: notification.number,
|
||||
sha: notification.sha
|
||||
)
|
||||
}
|
||||
|
||||
func route(notificationUserInfo userInfo: [AnyHashable: Any]) {
|
||||
let target: ActivityTargetKind
|
||||
switch userInfo["target"] as? String {
|
||||
case "repository": target = .repository
|
||||
case "issue": target = .issue
|
||||
case "pull": target = .pullRequest
|
||||
case "commit": target = .commit
|
||||
default: target = .none
|
||||
}
|
||||
route(
|
||||
serverId: userInfo["serverId"] as? String,
|
||||
target: target,
|
||||
owner: userInfo["owner"] as? String ?? "",
|
||||
repository: userInfo["repository"] as? String ?? "",
|
||||
number: (userInfo["number"] as? NSNumber)?.int64Value ?? 0,
|
||||
sha: userInfo["sha"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
private func route(
|
||||
serverId: String?,
|
||||
target: ActivityTargetKind,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Int64,
|
||||
sha: String
|
||||
) {
|
||||
if let serverId {
|
||||
guard let index = core.servers().firstIndex(where: { $0.id == serverId }) else {
|
||||
tabs.present(errorAlert("That notification's server is no longer configured."), animated: true)
|
||||
return
|
||||
}
|
||||
if core.activeServerIndex() != UInt32(index) {
|
||||
do {
|
||||
try selectServer(index: UInt32(index))
|
||||
} catch {
|
||||
tabs.present(errorAlert(error.localizedDescription), animated: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
let navigation = navigationControllers[0]
|
||||
switch activity.target {
|
||||
tabs.selectedIndex = 0
|
||||
switch target {
|
||||
case .repository:
|
||||
navigation.pushViewController(
|
||||
IssuesViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository
|
||||
owner: owner,
|
||||
repository: repository
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
@@ -111,9 +175,9 @@ final class AppContext {
|
||||
navigation.pushViewController(
|
||||
IssueViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
number: activity.number
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
@@ -121,9 +185,9 @@ final class AppContext {
|
||||
navigation.pushViewController(
|
||||
PullViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
number: activity.number
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
number: number
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
@@ -131,9 +195,9 @@ final class AppContext {
|
||||
navigation.pushViewController(
|
||||
FilesViewController(
|
||||
context: self,
|
||||
owner: activity.owner,
|
||||
repository: activity.repository,
|
||||
sha: activity.sha
|
||||
owner: owner,
|
||||
repository: repository,
|
||||
sha: sha
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
window.rootViewController = context.makeRootController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
context.notifications.start()
|
||||
context.showStartupErrorIfNeeded()
|
||||
if let url = launchOptions?[.url] as? URL {
|
||||
context.route(widgetURL: url)
|
||||
@@ -33,5 +34,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
context?.notifications.applicationDidBecomeActive()
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
context?.notifications.applicationDidEnterBackground()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,12 +104,23 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
finishPagination(hasMore: result.nextPage != nil)
|
||||
title = page?.serverName
|
||||
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
|
||||
HeatmapView(page: page, selectedFilter: filter.rawValue) { [weak self] index in
|
||||
HeatmapView(
|
||||
page: page,
|
||||
selectedFilter: filter.rawValue,
|
||||
onFilter: { [weak self] index in
|
||||
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
|
||||
guard filter != self.filter else { return }
|
||||
self.filter = filter
|
||||
self.loadPage(1, refreshing: false)
|
||||
}
|
||||
},
|
||||
onNotifications: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
NotificationsViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
)
|
||||
} }
|
||||
updateActivities()
|
||||
} catch {
|
||||
@@ -179,7 +190,12 @@ final class HeatmapView: UIView {
|
||||
private let cells: [HeatCell]
|
||||
private let calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
init(page: HomePage, selectedFilter: Int, onFilter: @escaping (Int) -> Void) {
|
||||
init(
|
||||
page: HomePage,
|
||||
selectedFilter: Int,
|
||||
onFilter: @escaping (Int) -> Void,
|
||||
onNotifications: @escaping () -> Void
|
||||
) {
|
||||
cells = page.heatCells
|
||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
|
||||
backgroundColor = .systemBackground
|
||||
@@ -225,6 +241,15 @@ final class HeatmapView: UIView {
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(button)
|
||||
}
|
||||
let notifications = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onNotifications() }
|
||||
)
|
||||
notifications.setImage(UIImage(systemName: "bell"), for: .normal)
|
||||
notifications.tintColor = .secondaryLabel
|
||||
notifications.accessibilityLabel = "Notifications"
|
||||
notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(notifications)
|
||||
addSubview(filters)
|
||||
NSLayoutConstraint.activate([
|
||||
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
|
||||
212
ios/Sources/NotificationCoordinator.swift
Normal file
212
ios/Sources/NotificationCoordinator.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import BackgroundTasks
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class NotificationCoordinator: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
static let refreshIdentifier = "de.rfc1437.gotcha.notifications.refresh"
|
||||
|
||||
private unowned let context: AppContext
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private var timer: Timer?
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
#if DEBUG
|
||||
private let validatesBackgroundNotifications = ProcessInfo.processInfo.arguments.contains(
|
||||
"--validate-background-notifications"
|
||||
)
|
||||
#endif
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func start() {
|
||||
center.delegate = self
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.refreshIdentifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
Task { @MainActor in
|
||||
guard let self, let task = task as? BGAppRefreshTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
self.handle(task)
|
||||
}
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) {
|
||||
[weak self] _ in
|
||||
Task { @MainActor in self?.refresh(deliverAlerts: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive() {
|
||||
#if DEBUG
|
||||
guard !validatesBackgroundNotifications else { return }
|
||||
#endif
|
||||
refresh(deliverAlerts: false)
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground() {
|
||||
scheduleBackgroundRefresh()
|
||||
#if DEBUG
|
||||
guard validatesBackgroundNotifications else { return }
|
||||
let identifier = UIApplication.shared.beginBackgroundTask()
|
||||
pollingTask?.cancel()
|
||||
pollingTask = Task {
|
||||
defer { UIApplication.shared.endBackgroundTask(identifier) }
|
||||
try? await poll(deliverAlerts: true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async throws -> Bool {
|
||||
if !enabled {
|
||||
try context.core.setNotificationsEnabled(enabled: false)
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.refreshIdentifier)
|
||||
center.removeAllPendingNotificationRequests()
|
||||
return false
|
||||
}
|
||||
|
||||
var settings = await center.notificationSettings()
|
||||
if settings.authorizationStatus == .notDetermined {
|
||||
_ = try await center.requestAuthorization(options: [.alert, .sound])
|
||||
settings = await center.notificationSettings()
|
||||
}
|
||||
guard Self.isAuthorized(settings.authorizationStatus) else { return false }
|
||||
try context.core.setNotificationsEnabled(enabled: true)
|
||||
scheduleBackgroundRefresh()
|
||||
try await poll(deliverAlerts: false)
|
||||
return true
|
||||
}
|
||||
|
||||
func authorizationDescription() async -> String {
|
||||
switch await center.notificationSettings().authorizationStatus {
|
||||
case .notDetermined: return "Not requested"
|
||||
case .denied: return "Disabled in iOS Settings"
|
||||
case .authorized: return "Allowed"
|
||||
case .provisional: return "Delivered quietly"
|
||||
case .ephemeral: return "Allowed temporarily"
|
||||
@unknown default: return "Managed by iOS"
|
||||
}
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func refresh(deliverAlerts: Bool) {
|
||||
pollingTask?.cancel()
|
||||
pollingTask = Task {
|
||||
do {
|
||||
let settings = await center.notificationSettings()
|
||||
guard
|
||||
context.core.settings().notificationsEnabled,
|
||||
Self.isAuthorized(settings.authorizationStatus)
|
||||
else { return }
|
||||
try await poll(deliverAlerts: deliverAlerts)
|
||||
} catch {
|
||||
// Foreground screens surface API errors when the user explicitly refreshes them.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func poll(deliverAlerts: Bool) async throws {
|
||||
let rows = try await context.core.pollNotifications()
|
||||
guard deliverAlerts else { return }
|
||||
for row in rows where row.target != .none {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title(for: row.target)
|
||||
content.body = "Open Gotcha to view the update."
|
||||
content.sound = .default
|
||||
content.threadIdentifier = "gotcha.\(row.serverId)"
|
||||
content.userInfo = [
|
||||
"serverId": row.serverId,
|
||||
"threadId": row.id,
|
||||
"target": targetName(row.target),
|
||||
"owner": row.owner,
|
||||
"repository": row.repository,
|
||||
"number": row.number,
|
||||
"sha": row.sha,
|
||||
]
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "gotcha.\(row.serverId).\(row.id)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
try await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleBackgroundRefresh() {
|
||||
guard context.core.settings().notificationsEnabled else { return }
|
||||
let request = BGAppRefreshTaskRequest(identifier: Self.refreshIdentifier)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
|
||||
private func handle(_ backgroundTask: BGAppRefreshTask) {
|
||||
scheduleBackgroundRefresh()
|
||||
pollingTask?.cancel()
|
||||
let task = Task {
|
||||
do {
|
||||
let settings = await center.notificationSettings()
|
||||
guard Self.isAuthorized(settings.authorizationStatus) else {
|
||||
backgroundTask.setTaskCompleted(success: true)
|
||||
return
|
||||
}
|
||||
try await poll(deliverAlerts: true)
|
||||
backgroundTask.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
backgroundTask.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
pollingTask = task
|
||||
backgroundTask.expirationHandler = { task.cancel() }
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[]
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
context.route(notificationUserInfo: userInfo)
|
||||
guard
|
||||
let serverId = userInfo["serverId"] as? String,
|
||||
let id = userInfo["threadId"] as? NSNumber
|
||||
else { return }
|
||||
try? await context.core.markNotificationRead(serverId: serverId, id: id.int64Value)
|
||||
}
|
||||
|
||||
private static func isAuthorized(_ status: UNAuthorizationStatus) -> Bool {
|
||||
status == .authorized || status == .provisional || status == .ephemeral
|
||||
}
|
||||
|
||||
private func title(for target: ActivityTargetKind) -> String {
|
||||
switch target {
|
||||
case .repository: return "New repository notification"
|
||||
case .issue: return "New issue notification"
|
||||
case .pullRequest: return "New pull request notification"
|
||||
case .commit: return "New commit notification"
|
||||
case .none: return "New server notification"
|
||||
}
|
||||
}
|
||||
|
||||
private func targetName(_ target: ActivityTargetKind) -> String {
|
||||
switch target {
|
||||
case .repository: return "repository"
|
||||
case .issue: return "issue"
|
||||
case .pullRequest: return "pull"
|
||||
case .commit: return "commit"
|
||||
case .none: return "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ios/Sources/NotificationsScreen.swift
Normal file
179
ios/Sources/NotificationsScreen.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import UIKit
|
||||
|
||||
final class NotificationCell: UITableViewCell {
|
||||
private let icon = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let metaLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline)
|
||||
icon.setContentHuggingPriority(.required, for: .horizontal)
|
||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
titleLabel.numberOfLines = 2
|
||||
detailLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
detailLabel.textColor = .secondaryLabel
|
||||
detailLabel.numberOfLines = 2
|
||||
metaLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
metaLabel.textColor = .tertiaryLabel
|
||||
[titleLabel, detailLabel, metaLabel].forEach {
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
let labels = UIStackView(arrangedSubviews: [titleLabel, detailLabel, metaLabel])
|
||||
labels.axis = .vertical
|
||||
labels.spacing = 4
|
||||
let stack = UIStackView(arrangedSubviews: [icon, labels])
|
||||
stack.alignment = .top
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||
icon.widthAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
func configure(_ row: NotificationRow) {
|
||||
icon.image = UIImage(systemName: symbolName(for: row.target))
|
||||
icon.tintColor = row.unread ? .tintColor : .secondaryLabel
|
||||
titleLabel.text = row.title
|
||||
detailLabel.text = row.detail
|
||||
metaLabel.text = row.meta
|
||||
accessoryType = row.target == .none ? .none : .disclosureIndicator
|
||||
selectionStyle = row.target == .none ? .none : .default
|
||||
accessibilityValue = row.unread ? "Open" : "Closed"
|
||||
}
|
||||
|
||||
private func symbolName(for target: ActivityTargetKind) -> String {
|
||||
switch target {
|
||||
case .repository: return "books.vertical"
|
||||
case .issue: return "exclamationmark.circle"
|
||||
case .pullRequest: return "arrow.triangle.pull"
|
||||
case .commit: return "point.topleft.down.to.point.bottomright.curvepath"
|
||||
case .none: return "bell"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotificationsViewController: RefreshingTableViewController {
|
||||
private let context: AppContext
|
||||
private let statusControl = UISegmentedControl(items: ["Open", "Closed"])
|
||||
private var rows: [NotificationRow] = []
|
||||
private var currentPage: UInt32 = 0
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
super.init()
|
||||
title = "Notifications"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 92
|
||||
statusControl.selectedSegmentIndex = 0
|
||||
statusControl.addTarget(self, action: #selector(statusChanged), for: .valueChanged)
|
||||
statusControl.accessibilityLabel = "Notification status"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: statusControl)
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
guard currentPage > 0 else { return }
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
loadNotifications(page: 1, refreshing: refreshing)
|
||||
}
|
||||
|
||||
override func loadMoreContent() {
|
||||
loadNotifications(page: currentPage + 1, refreshing: false)
|
||||
}
|
||||
|
||||
private func loadNotifications(page: UInt32, refreshing: Bool) {
|
||||
if page == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let result = try await context.core.notifications(
|
||||
status: statusControl.selectedSegmentIndex == 0 ? .open : .closed,
|
||||
page: page
|
||||
)
|
||||
if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) }
|
||||
currentPage = page
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
tableView.reloadData()
|
||||
let status = statusControl.selectedSegmentIndex == 0 ? "open" : "closed"
|
||||
tableView.backgroundView = rows.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No \(status) notifications",
|
||||
detail: "This server has no \(status) notifications."
|
||||
)
|
||||
: nil
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if page == 1 { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
rows.count
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: "notification",
|
||||
for: indexPath
|
||||
) as! NotificationCell
|
||||
cell.configure(rows[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let row = rows[indexPath.row]
|
||||
guard row.target != .none else { return }
|
||||
guard row.unread else {
|
||||
context.route(row)
|
||||
return
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
|
||||
guard !Task.isCancelled else { return }
|
||||
context.route(row)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func statusChanged() {
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import UIKit
|
||||
final class SettingsViewController: UITableViewController {
|
||||
private let context: AppContext
|
||||
private let appearanceControl = UISegmentedControl(items: ["Auto", "Light", "Dark"])
|
||||
private let notificationSwitch = UISwitch()
|
||||
private var notificationStatus = "Managed by iOS"
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
@@ -19,23 +21,58 @@ final class SettingsViewController: UITableViewController {
|
||||
let settings = context.core.settings()
|
||||
appearanceControl.selectedSegmentIndex = Int(settings.appearance)
|
||||
appearanceControl.addTarget(self, action: #selector(appearanceChanged), for: .valueChanged)
|
||||
notificationSwitch.isOn = settings.notificationsEnabled
|
||||
notificationSwitch.accessibilityLabel = "Background notifications"
|
||||
notificationSwitch.addTarget(self, action: #selector(notificationsChanged), for: .valueChanged)
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||
Task {
|
||||
notificationStatus = await context.notifications.authorizationDescription()
|
||||
updateNotificationSettingsCell()
|
||||
}
|
||||
}
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : 2
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"Appearance"
|
||||
section == 0 ? "Appearance" : "Notifications"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Follow iOS automatically or choose a fixed appearance."
|
||||
if section == 0 {
|
||||
return "Follow iOS automatically or choose a fixed appearance."
|
||||
}
|
||||
return "Gotcha checks periodically for server notifications. iOS decides when background refresh runs; delivery style, sounds, Focus, and summaries remain under your control in iOS Settings."
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
guard indexPath.section == 0 else {
|
||||
if indexPath.row == 0 {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Background notifications"
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryView = notificationSwitch
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Notification Settings"
|
||||
content.secondaryText = notificationStatus
|
||||
cell.contentConfiguration = content
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
appearanceControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.contentView.addSubview(appearanceControl)
|
||||
@@ -48,6 +85,12 @@ final class SettingsViewController: UITableViewController {
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1, indexPath.row == 1 else { return }
|
||||
context.notifications.openSystemSettings()
|
||||
}
|
||||
|
||||
@objc private func appearanceChanged() {
|
||||
do {
|
||||
try context.core.setAppearance(index: UInt32(appearanceControl.selectedSegmentIndex))
|
||||
@@ -56,4 +99,43 @@ final class SettingsViewController: UITableViewController {
|
||||
show(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func notificationsChanged() {
|
||||
let requested = notificationSwitch.isOn
|
||||
notificationSwitch.isEnabled = false
|
||||
Task {
|
||||
do {
|
||||
let enabled = try await context.notifications.setEnabled(requested)
|
||||
notificationSwitch.isOn = enabled
|
||||
if requested && !enabled { showNotificationsDisabledAlert() }
|
||||
} catch {
|
||||
notificationSwitch.isOn = context.core.settings().notificationsEnabled
|
||||
show(error: error)
|
||||
}
|
||||
notificationSwitch.isEnabled = true
|
||||
notificationStatus = await context.notifications.authorizationDescription()
|
||||
updateNotificationSettingsCell()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNotificationSettingsCell() {
|
||||
guard let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 1)) else { return }
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = "Notification Settings"
|
||||
content.secondaryText = notificationStatus
|
||||
cell.contentConfiguration = content
|
||||
}
|
||||
|
||||
private func showNotificationsDisabledAlert() {
|
||||
let alert = UIAlertController(
|
||||
title: "Notifications Are Disabled",
|
||||
message: "Allow notifications in iOS Settings, then turn Background notifications on again.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Not Now", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { [weak self] _ in
|
||||
self?.context.notifications.openSystemSettings()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user