Add native iOS notifications (#64)

This commit is contained in:
Georg Bauer
2026-08-15 12:50:05 +02:00
parent 69d1709523
commit ced28ba1e6
23 changed files with 1589 additions and 22 deletions

View File

@@ -649,6 +649,12 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func setMilestoneClosed(owner: String, repository: String, id: Int64, closed: Bool) async throws
func markNotificationRead(serverId: String, id: Int64) async throws
func notifications(status: NotificationStatus, page: UInt32) async throws -> NotificationListPage
func pollNotifications() async throws -> [NotificationRow]
func clearPullFilters() throws
func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage
@@ -685,6 +691,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func setIssueStatus(status: String) throws
func setNotificationsEnabled(enabled: Bool) throws
func setPullStatus(status: String) throws
func settings() -> Settings
@@ -1114,6 +1122,54 @@ open func setMilestoneClosed(owner: String, repository: String, id: Int64, close
)
}
open func markNotificationRead(serverId: String, id: Int64)async throws {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_mark_notification_read(
self.uniffiCloneHandle(),FfiConverterString.lower(serverId),FfiConverterInt64.lower(id)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_void,
completeFunc: ffi_gotcha_core_rust_future_complete_void,
freeFunc: ffi_gotcha_core_rust_future_free_void,
liftFunc: { $0 },
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func notifications(status: NotificationStatus, page: UInt32)async throws -> NotificationListPage {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_notifications(
self.uniffiCloneHandle(),FfiConverterTypeNotificationStatus_lower(status),FfiConverterUInt32.lower(page)
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer,
freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer,
liftFunc: FfiConverterTypeNotificationListPage_lift,
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func pollNotifications()async throws -> [NotificationRow] {
return
try await uniffiRustCallAsync(
rustFutureFunc: {
uniffi_gotcha_core_fn_method_gotchacore_poll_notifications(
self.uniffiCloneHandle()
)
},
pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer,
completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer,
freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer,
liftFunc: FfiConverterSequenceTypeNotificationRow.lift,
errorHandler: FfiConverterTypeGotchaError_lift
)
}
open func clearPullFilters()throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(
@@ -1329,6 +1385,15 @@ open func setIssueStatus(status: String)throws {try rustCallWithError(FfiConve
}
}
open func setNotificationsEnabled(enabled: Bool)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(
self.uniffiCloneHandle(),
FfiConverterBool.lower(enabled),uniffiCallStatus
)
}
}
open func setPullStatus(status: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(
@@ -2988,6 +3053,150 @@ public func FfiConverterTypeMilestoneRow_lower(_ value: MilestoneRow) -> RustBuf
}
public struct NotificationListPage: Equatable, Hashable {
public var rows: [NotificationRow]
public var hasMore: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(rows: [NotificationRow], hasMore: Bool) {
self.rows = rows
self.hasMore = hasMore
}
}
#if compiler(>=6)
extension NotificationListPage: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeNotificationListPage: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationListPage {
return
try NotificationListPage(
rows: FfiConverterSequenceTypeNotificationRow.read(from: &buf),
hasMore: FfiConverterBool.read(from: &buf)
)
}
public static func write(_ value: NotificationListPage, into buf: inout [UInt8]) {
FfiConverterSequenceTypeNotificationRow.write(value.rows, into: &buf)
FfiConverterBool.write(value.hasMore, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationListPage_lift(_ buf: RustBuffer) throws -> NotificationListPage {
return try FfiConverterTypeNotificationListPage.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationListPage_lower(_ value: NotificationListPage) -> RustBuffer {
return FfiConverterTypeNotificationListPage.lower(value)
}
public struct NotificationRow: Equatable, Hashable {
public var id: Int64
public var serverId: String
public var title: String
public var detail: String
public var meta: String
public var unread: Bool
public var target: ActivityTargetKind
public var owner: String
public var repository: String
public var number: Int64
public var sha: String
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(id: Int64, serverId: String, title: String, detail: String, meta: String, unread: Bool, target: ActivityTargetKind, owner: String, repository: String, number: Int64, sha: String) {
self.id = id
self.serverId = serverId
self.title = title
self.detail = detail
self.meta = meta
self.unread = unread
self.target = target
self.owner = owner
self.repository = repository
self.number = number
self.sha = sha
}
}
#if compiler(>=6)
extension NotificationRow: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeNotificationRow: FfiConverterRustBuffer {
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationRow {
return
try NotificationRow(
id: FfiConverterInt64.read(from: &buf),
serverId: FfiConverterString.read(from: &buf),
title: FfiConverterString.read(from: &buf),
detail: FfiConverterString.read(from: &buf),
meta: FfiConverterString.read(from: &buf),
unread: FfiConverterBool.read(from: &buf),
target: FfiConverterTypeActivityTargetKind.read(from: &buf),
owner: FfiConverterString.read(from: &buf),
repository: FfiConverterString.read(from: &buf),
number: FfiConverterInt64.read(from: &buf),
sha: FfiConverterString.read(from: &buf)
)
}
public static func write(_ value: NotificationRow, into buf: inout [UInt8]) {
FfiConverterInt64.write(value.id, into: &buf)
FfiConverterString.write(value.serverId, into: &buf)
FfiConverterString.write(value.title, into: &buf)
FfiConverterString.write(value.detail, into: &buf)
FfiConverterString.write(value.meta, into: &buf)
FfiConverterBool.write(value.unread, into: &buf)
FfiConverterTypeActivityTargetKind.write(value.target, into: &buf)
FfiConverterString.write(value.owner, into: &buf)
FfiConverterString.write(value.repository, into: &buf)
FfiConverterInt64.write(value.number, into: &buf)
FfiConverterString.write(value.sha, into: &buf)
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationRow_lift(_ buf: RustBuffer) throws -> NotificationRow {
return try FfiConverterTypeNotificationRow.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationRow_lower(_ value: NotificationRow) -> RustBuffer {
return FfiConverterTypeNotificationRow.lower(value)
}
public struct PullFilterOptions: Equatable, Hashable {
public var milestones: [String]
public var selectedMilestone: String
@@ -3620,13 +3829,15 @@ public struct Settings: Equatable, Hashable {
public var issueStatus: String
public var pullStatus: String
public var appearance: UInt32
public var notificationsEnabled: Bool
// Default memberwise initializers are never public by default, so we
// declare one manually.
public init(issueStatus: String, pullStatus: String, appearance: UInt32) {
public init(issueStatus: String, pullStatus: String, appearance: UInt32, notificationsEnabled: Bool) {
self.issueStatus = issueStatus
self.pullStatus = pullStatus
self.appearance = appearance
self.notificationsEnabled = notificationsEnabled
}
@@ -3647,7 +3858,8 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer {
try Settings(
issueStatus: FfiConverterString.read(from: &buf),
pullStatus: FfiConverterString.read(from: &buf),
appearance: FfiConverterUInt32.read(from: &buf)
appearance: FfiConverterUInt32.read(from: &buf),
notificationsEnabled: FfiConverterBool.read(from: &buf)
)
}
@@ -3655,6 +3867,7 @@ public struct FfiConverterTypeSettings: FfiConverterRustBuffer {
FfiConverterString.write(value.issueStatus, into: &buf)
FfiConverterString.write(value.pullStatus, into: &buf)
FfiConverterUInt32.write(value.appearance, into: &buf)
FfiConverterBool.write(value.notificationsEnabled, into: &buf)
}
}
@@ -4206,6 +4419,72 @@ public func FfiConverterTypeHomeActivityFilter_lower(_ value: HomeActivityFilter
public enum NotificationStatus: Equatable, Hashable {
case `open`
case closed
}
#if compiler(>=6)
extension NotificationStatus: Sendable {}
#endif
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public struct FfiConverterTypeNotificationStatus: FfiConverterRustBuffer {
typealias SwiftType = NotificationStatus
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NotificationStatus {
let variant: Int32 = try readInt(&buf)
switch variant {
case 1: return .`open`
case 2: return .closed
default: throw UniffiInternalError.unexpectedEnumCase
}
}
public static func write(_ value: NotificationStatus, into buf: inout [UInt8]) {
switch value {
case .`open`:
writeInt(&buf, Int32(1))
case .closed:
writeInt(&buf, Int32(2))
}
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationStatus_lift(_ buf: RustBuffer) throws -> NotificationStatus {
return try FfiConverterTypeNotificationStatus.lift(buf)
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
public func FfiConverterTypeNotificationStatus_lower(_ value: NotificationStatus) -> RustBuffer {
return FfiConverterTypeNotificationStatus.lower(value)
}
public enum RepositoryContentKind: Equatable, Hashable {
case directory
@@ -5002,6 +5281,31 @@ fileprivate struct FfiConverterSequenceTypeMilestoneRow: FfiConverterRustBuffer
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
fileprivate struct FfiConverterSequenceTypeNotificationRow: FfiConverterRustBuffer {
typealias SwiftType = [NotificationRow]
public static func write(_ value: [NotificationRow], into buf: inout [UInt8]) {
let len = Int32(value.count)
writeInt(&buf, len)
for item in value {
FfiConverterTypeNotificationRow.write(item, into: &buf)
}
}
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [NotificationRow] {
let len: Int32 = try readInt(&buf)
var seq = [NotificationRow]()
seq.reserveCapacity(Int(len))
for _ in 0 ..< len {
seq.append(try FfiConverterTypeNotificationRow.read(from: &buf))
}
return seq
}
}
#if swift(>=5.8)
@_documentation(visibility: private)
#endif
@@ -5234,6 +5538,15 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed() != 33) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_mark_notification_read() != 39536) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_notifications() != 58813) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_poll_notifications() != 21038) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_clear_pull_filters() != 61566) {
return InitializationResult.apiChecksumMismatch
}
@@ -5288,6 +5601,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status() != 5573) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled() != 13019) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_gotcha_core_checksum_method_gotchacore_set_pull_status() != 40421) {
return InitializationResult.apiChecksumMismatch
}

View File

@@ -373,6 +373,21 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, Ru
uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_milestone_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, int8_t closed
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ
uint64_t uniffi_gotcha_core_fn_method_gotchacore_mark_notification_read(uint64_t ptr, RustBuffer server_id, int64_t id
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_NOTIFICATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_NOTIFICATIONS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_notifications(uint64_t ptr, RustBuffer status, uint32_t page
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_POLL_NOTIFICATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_POLL_NOTIFICATIONS
uint64_t uniffi_gotcha_core_fn_method_gotchacore_poll_notifications(uint64_t ptr
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
void uniffi_gotcha_core_fn_method_gotchacore_clear_pull_filters(uint64_t ptr, RustCallStatus *_Nonnull out_status
@@ -463,6 +478,11 @@ void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32
void uniffi_gotcha_core_fn_method_gotchacore_set_issue_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
void uniffi_gotcha_core_fn_method_gotchacore_set_notifications_enabled(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_PULL_STATUS
void uniffi_gotcha_core_fn_method_gotchacore_set_pull_status(uint64_t ptr, RustBuffer status, RustCallStatus *_Nonnull out_status
@@ -889,6 +909,24 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_MILESTONE_CLOSED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_milestone_closed(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MARK_NOTIFICATION_READ
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_mark_notification_read(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_NOTIFICATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_NOTIFICATIONS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_notifications(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_POLL_NOTIFICATIONS
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_POLL_NOTIFICATIONS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_poll_notifications(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_CLEAR_PULL_FILTERS
@@ -997,6 +1035,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_STATUS
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_status(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_NOTIFICATIONS_ENABLED
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_notifications_enabled(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_PULL_STATUS

View File

@@ -16,7 +16,9 @@
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; };
39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; };
4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; };
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */; };
74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; };
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */; };
7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; };
7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
@@ -83,10 +85,12 @@
99B8279189276A084B69D7D0 /* PullScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullScreens.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueScreens.swift; sourceTree = "<group>"; };
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
C13A39F3C39C1F353D58C307 /* ServerScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerScreens.swift; sourceTree = "<group>"; };
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
D35C7ECBC3EEC8B4659234AE /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsScreen.swift; sourceTree = "<group>"; };
F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; };
F75B3E4FFB9C9992517C4D69 /* Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Support.swift; sourceTree = "<group>"; };
@@ -130,6 +134,8 @@
ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */,
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
64B529D84926EEEDC163A929 /* MilestoneScreens.swift */,
BA5F9B46F07F3EB2333CD4DF /* NotificationCoordinator.swift */,
DE88A28F99C6D0224865E04D /* NotificationsScreen.swift */,
99B8279189276A084B69D7D0 /* PullScreens.swift */,
FD6BB62D12708255650508B2 /* RepositoryDirectoryScreen.swift */,
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */,
@@ -351,6 +357,8 @@
7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */,
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */,
7A511844F3CBA0603A894DDE /* NotificationCoordinator.swift in Sources */,
6DAA1D3230197F537D70735E /* NotificationsScreen.swift in Sources */,
33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */,
130339B2D7AEAC791E50140F /* RepositoryDirectoryScreen.swift in Sources */,
DC155F5DEB86D95FAF709E16 /* RepositoryFileScreens.swift in Sources */,

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>de.rfc1437.gotcha.notifications.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -33,6 +37,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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),

View 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"
}
}
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -34,11 +34,15 @@ targets:
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
ITSAppUsesNonExemptEncryption: false
BGTaskSchedulerPermittedIdentifiers:
- de.rfc1437.gotcha.notifications.refresh
CFBundleURLTypes:
- CFBundleURLName: de.rfc1437.gotcha
CFBundleURLSchemes:
- gotcha
UILaunchScreen: {}
UIBackgroundModes:
- fetch
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
sources: