
Push Notifications
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates iOS push notification infrastructure with APNs registration, notification handling, categories/actions, and rich notifications.
About
Generates push notification infrastructure for iOS/macOS apps with APNs registration, handling, categories/actions, and rich notifications with images. A developer uses it when adding push notifications or configuring APNs in a Swift app.
- APNs registration plus basic, rich, and silent notification support
- Notification categories, actions, and Notification Service Extension setup
Push Notifications by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill push-notificationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates iOS push notification infrastructure with APNs registration, notification handling, categories/actions, and rich notifications.
Files
Push Notifications Generator
Generate push notification infrastructure with APNs registration, handling, and rich notifications.
When This Skill Activates
- User wants to add push notifications to their app
- User mentions APNs (Apple Push Notification service)
- User asks about notification categories or actions
- User wants rich notifications with images or custom UI
Pre-Generation Checks
Before generating, verify:
1. Existing Notification Code
# Check for existing notification handling
grep -r "UNUserNotificationCenter\|registerForRemoteNotifications" --include="*.swift" | head -52. Entitlements
# Check for push notification entitlement
find . -name "*.entitlements" -exec grep -l "aps-environment" {} \;3. App Delegate or SwiftUI App
# Determine app structure
grep -r "@main\|UIApplicationDelegate" --include="*.swift" | head -5Configuration Questions
1. Notification Types
- Basic - Simple alerts with title/body
- Rich - Include images, custom UI (requires Notification Service Extension)
- Both - Full notification support
2. Notification Actions
- None - Just display notifications
- Simple Actions - Quick action buttons
- Custom Categories - Multiple action categories
3. Silent Notifications
- Yes - Background data updates
- No - User-visible only
Generated Files
Core Infrastructure
Sources/Notifications/
├── NotificationManager.swift # Central notification management
├── NotificationDelegate.swift # UNUserNotificationCenterDelegate
├── NotificationCategories.swift # Action categories definition
└── NotificationPayload.swift # Type-safe payload parsingRich Notifications (Optional)
NotificationServiceExtension/
├── NotificationService.swift # Modify notifications before display
└── Info.plist # Extension configurationContent Extension (Optional)
NotificationContentExtension/
├── NotificationViewController.swift # Custom notification UI
├── MainInterface.storyboard
└── Info.plistKey Features
Registration Flow
@MainActor
final class NotificationManager {
static let shared = NotificationManager()
func requestAuthorization() async throws -> Bool {
let center = UNUserNotificationCenter.current()
let options: UNAuthorizationOptions = [.alert, .badge, .sound]
return try await center.requestAuthorization(options: options)
}
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
}Handling Notifications
// Foreground notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .sound, .badge]
}
// Notification tap/action
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
await handleNotificationAction(response.actionIdentifier, userInfo: userInfo)
}Action Categories
enum NotificationCategory: String {
case message = "MESSAGE_CATEGORY"
case reminder = "REMINDER_CATEGORY"
var actions: [UNNotificationAction] {
switch self {
case .message:
return [
UNNotificationAction(identifier: "REPLY", title: "Reply", options: []),
UNNotificationAction(identifier: "MARK_READ", title: "Mark as Read", options: [])
]
case .reminder:
return [
UNNotificationAction(identifier: "COMPLETE", title: "Complete", options: []),
UNNotificationAction(identifier: "SNOOZE", title: "Snooze", options: [])
]
}
}
}Required Capabilities
In Xcode
1. Select project target 2. Signing & Capabilities tab 3. Add "Push Notifications" capability 4. Add "Background Modes" > "Remote notifications" (for silent notifications)
Entitlements
<key>aps-environment</key>
<string>development</string> <!-- or "production" -->Integration Steps
1. SwiftUI App
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
NotificationCategories.registerAll()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("Device Token: \(token)")
// Send token to your server
}
}2. Request Permission (at appropriate time)
Button("Enable Notifications") {
Task {
let granted = try await NotificationManager.shared.requestAuthorization()
if granted {
await MainActor.run {
NotificationManager.shared.registerForRemoteNotifications()
}
}
}
}3. Server-Side Setup
Configure your server to send APNs requests:
- Use APNs HTTP/2 API
- Include team ID, key ID, and .p8 key
- Target:
api.push.apple.com(production) orapi.sandbox.push.apple.com(development)
Testing
Local Notifications (Simulator)
func scheduleTestNotification() {
let content = UNMutableNotificationContent()
content.title = "Test"
content.body = "This is a test notification"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}Remote Notifications (Physical Device)
1. Build to physical device 2. Copy device token from console 3. Use APNs testing tool or curl:
curl -v \
--header "authorization: bearer $JWT_TOKEN" \
--header "apns-topic: com.yourcompany.yourapp" \
--header "apns-push-type: alert" \
--http2 \
--data '{"aps":{"alert":{"title":"Test","body":"Hello"}}}' \
https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKENReferences
Push Notification Patterns
Best practices for implementing push notifications in iOS/macOS apps.
Registration Flow
Complete Registration Sequence
import UserNotifications
import UIKit
@MainActor
@Observable
final class NotificationManager {
static let shared = NotificationManager()
private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
private(set) var deviceToken: String?
private init() {}
// MARK: - Authorization
/// Request notification authorization.
func requestAuthorization() async throws -> Bool {
let center = UNUserNotificationCenter.current()
let options: UNAuthorizationOptions = [
.alert,
.badge,
.sound,
.providesAppNotificationSettings // iOS 15.4+
]
let granted = try await center.requestAuthorization(options: options)
// Update status
authorizationStatus = try await center.notificationSettings().authorizationStatus
return granted
}
/// Check current authorization status.
func checkAuthorizationStatus() async -> UNAuthorizationStatus {
let settings = await UNUserNotificationCenter.current().notificationSettings()
authorizationStatus = settings.authorizationStatus
return authorizationStatus
}
// MARK: - Registration
/// Register for remote notifications.
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
/// Unregister from remote notifications.
func unregisterForRemoteNotifications() {
UIApplication.shared.unregisterForRemoteNotifications()
deviceToken = nil
}
/// Handle successful registration.
func didRegister(with deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
self.deviceToken = token
print("📱 Device token: \(token)")
// Send to your server
Task {
await sendTokenToServer(token)
}
}
/// Handle registration failure.
func didFailToRegister(with error: Error) {
print("❌ Failed to register: \(error.localizedDescription)")
}
private func sendTokenToServer(_ token: String) async {
// TODO: Implement server token upload
// await APIClient.shared.registerDeviceToken(token)
}
// MARK: - Badge Management
/// Clear the app badge.
func clearBadge() async {
try? await UNUserNotificationCenter.current().setBadgeCount(0)
}
/// Set badge count.
func setBadge(_ count: Int) async {
try? await UNUserNotificationCenter.current().setBadgeCount(count)
}
}Notification Delegate
Complete Implementation
import UserNotifications
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
static let shared = NotificationDelegate()
private override init() {
super.init()
}
// MARK: - Foreground Notifications
/// Called when notification arrives while app is in foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let userInfo = notification.request.content.userInfo
// Log or process the notification
print("📬 Received notification in foreground: \(userInfo)")
// Parse payload
if let payload = NotificationPayload(userInfo: userInfo) {
await handlePayload(payload, inForeground: true)
}
// Return presentation options
// Customize based on notification type
return [.banner, .sound, .badge, .list]
}
// MARK: - Notification Response
/// Called when user interacts with notification.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let actionIdentifier = response.actionIdentifier
print("📬 User responded to notification: \(actionIdentifier)")
// Handle based on action
switch actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped notification
if let payload = NotificationPayload(userInfo: userInfo) {
await handlePayload(payload, inForeground: false)
}
case UNNotificationDismissActionIdentifier:
// User dismissed notification
break
default:
// Custom action
await handleCustomAction(actionIdentifier, userInfo: userInfo)
}
}
// MARK: - Settings
/// Called when user wants to manage notification settings from the notification.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
// Open app's notification settings
NotificationCenter.default.post(
name: .openNotificationSettings,
object: nil
)
}
// MARK: - Handlers
private func handlePayload(_ payload: NotificationPayload, inForeground: Bool) async {
switch payload.type {
case .message:
// Navigate to message
await MainActor.run {
NotificationCenter.default.post(
name: .navigateToMessage,
object: nil,
userInfo: ["messageId": payload.resourceId ?? ""]
)
}
case .reminder:
// Handle reminder
break
case .update:
// Handle update notification
break
case .unknown:
break
}
}
private func handleCustomAction(_ identifier: String, userInfo: [AnyHashable: Any]) async {
switch identifier {
case "REPLY_ACTION":
// Handle reply
break
case "MARK_READ_ACTION":
// Mark as read
break
case "COMPLETE_ACTION":
// Complete task
break
case "SNOOZE_ACTION":
// Snooze reminder
await scheduleSnooze(userInfo: userInfo)
default:
print("Unknown action: \(identifier)")
}
}
private func scheduleSnooze(userInfo: [AnyHashable: Any]) async {
// Re-schedule notification for later
let content = UNMutableNotificationContent()
content.title = userInfo["title"] as? String ?? "Reminder"
content.body = userInfo["body"] as? String ?? ""
content.sound = .default
content.userInfo = userInfo
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: 15 * 60, // 15 minutes
repeats: false
)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
try? await UNUserNotificationCenter.current().add(request)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let openNotificationSettings = Notification.Name("openNotificationSettings")
static let navigateToMessage = Notification.Name("navigateToMessage")
}Notification Categories
Category Registration
import UserNotifications
enum NotificationCategories {
// MARK: - Category Identifiers
enum Identifier: String {
case message = "MESSAGE_CATEGORY"
case reminder = "REMINDER_CATEGORY"
case update = "UPDATE_CATEGORY"
}
// MARK: - Action Identifiers
enum Action: String {
// Message actions
case reply = "REPLY_ACTION"
case markRead = "MARK_READ_ACTION"
// Reminder actions
case complete = "COMPLETE_ACTION"
case snooze = "SNOOZE_ACTION"
// Update actions
case viewUpdate = "VIEW_UPDATE_ACTION"
case dismiss = "DISMISS_ACTION"
}
// MARK: - Registration
static func registerAll() {
let categories: Set<UNNotificationCategory> = [
messageCategory,
reminderCategory,
updateCategory
]
UNUserNotificationCenter.current().setNotificationCategories(categories)
}
// MARK: - Category Definitions
private static var messageCategory: UNNotificationCategory {
let replyAction = UNNotificationAction(
identifier: Action.reply.rawValue,
title: "Reply",
options: [.foreground]
)
let markReadAction = UNNotificationAction(
identifier: Action.markRead.rawValue,
title: "Mark as Read",
options: []
)
return UNNotificationCategory(
identifier: Identifier.message.rawValue,
actions: [replyAction, markReadAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
}
private static var reminderCategory: UNNotificationCategory {
let completeAction = UNNotificationAction(
identifier: Action.complete.rawValue,
title: "Complete",
options: [.foreground]
)
let snoozeAction = UNNotificationAction(
identifier: Action.snooze.rawValue,
title: "Snooze 15 min",
options: []
)
return UNNotificationCategory(
identifier: Identifier.reminder.rawValue,
actions: [completeAction, snoozeAction],
intentIdentifiers: [],
options: []
)
}
private static var updateCategory: UNNotificationCategory {
let viewAction = UNNotificationAction(
identifier: Action.viewUpdate.rawValue,
title: "View",
options: [.foreground]
)
return UNNotificationCategory(
identifier: Identifier.update.rawValue,
actions: [viewAction],
intentIdentifiers: [],
options: []
)
}
}Payload Parsing
Type-Safe Payload
import Foundation
/// Parsed notification payload.
struct NotificationPayload {
let type: NotificationType
let title: String?
let body: String?
let resourceId: String?
let deepLink: URL?
let imageURL: URL?
let extra: [String: Any]
enum NotificationType: String {
case message
case reminder
case update
case unknown
}
init?(userInfo: [AnyHashable: Any]) {
// Parse APS
guard let aps = userInfo["aps"] as? [String: Any] else {
return nil
}
// Parse alert
if let alert = aps["alert"] as? [String: Any] {
title = alert["title"] as? String
body = alert["body"] as? String
} else if let alert = aps["alert"] as? String {
title = nil
body = alert
} else {
title = nil
body = nil
}
// Parse custom fields
let typeString = userInfo["type"] as? String ?? "unknown"
type = NotificationType(rawValue: typeString) ?? .unknown
resourceId = userInfo["resource_id"] as? String
if let deepLinkString = userInfo["deep_link"] as? String {
deepLink = URL(string: deepLinkString)
} else {
deepLink = nil
}
if let imageString = userInfo["image_url"] as? String {
imageURL = URL(string: imageString)
} else {
imageURL = nil
}
// Store extra fields
var extra: [String: Any] = [:]
for (key, value) in userInfo {
if let key = key as? String,
key != "aps" && key != "type" && key != "resource_id" &&
key != "deep_link" && key != "image_url" {
extra[key] = value
}
}
self.extra = extra
}
}
// MARK: - Expected Payload Format
/*
Server should send payloads in this format:
{
"aps": {
"alert": {
"title": "New Message",
"body": "You have a new message from John"
},
"badge": 1,
"sound": "default",
"category": "MESSAGE_CATEGORY",
"mutable-content": 1 // Required for Notification Service Extension
},
"type": "message",
"resource_id": "msg_12345",
"deep_link": "myapp://messages/msg_12345",
"image_url": "https://example.com/image.jpg"
}
*/Rich Notifications
Notification Service Extension
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard let bestAttemptContent else {
contentHandler(request.content)
return
}
// Download and attach image if URL provided
if let imageURLString = request.content.userInfo["image_url"] as? String,
let imageURL = URL(string: imageURLString) {
downloadAndAttachImage(from: imageURL, to: bestAttemptContent) {
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Deliver best attempt if time expires
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
private func downloadAndAttachImage(
from url: URL,
to content: UNMutableNotificationContent,
completion: @escaping () -> Void
) {
let task = URLSession.shared.downloadTask(with: url) { localURL, _, error in
defer { completion() }
guard let localURL, error == nil else { return }
// Move to accessible location
let fileManager = FileManager.default
let tmpDir = fileManager.temporaryDirectory
let fileName = url.lastPathComponent
let destinationURL = tmpDir.appendingPathComponent(fileName)
try? fileManager.removeItem(at: destinationURL)
try? fileManager.moveItem(at: localURL, to: destinationURL)
// Create attachment
if let attachment = try? UNNotificationAttachment(
identifier: "image",
url: destinationURL,
options: nil
) {
content.attachments = [attachment]
}
}
task.resume()
}
}Silent Notifications
Handling Background Updates
// In AppDelegate
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
// Check if silent notification
guard let aps = userInfo["aps"] as? [String: Any],
aps["content-available"] as? Int == 1 else {
return .noData
}
do {
// Perform background work
let updated = try await performBackgroundUpdate(userInfo: userInfo)
return updated ? .newData : .noData
} catch {
return .failed
}
}
private func performBackgroundUpdate(userInfo: [AnyHashable: Any]) async throws -> Bool {
// Example: Sync data
// let syncManager = SyncManager.shared
// return try await syncManager.syncIfNeeded()
return true
}Silent Notification Payload
{
"aps": {
"content-available": 1
},
"sync_type": "messages",
"timestamp": "2024-01-15T10:30:00Z"
}Local Notifications
Scheduling
extension NotificationManager {
/// Schedule a local notification.
func scheduleLocalNotification(
title: String,
body: String,
at date: Date,
category: NotificationCategories.Identifier? = nil,
userInfo: [String: Any] = [:]
) async throws -> String {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = userInfo
if let category {
content.categoryIdentifier = category.rawValue
}
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: date
)
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: false
)
let identifier = UUID().uuidString
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(request)
return identifier
}
/// Cancel a scheduled notification.
func cancelNotification(identifier: String) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: [identifier])
}
/// Get all pending notifications.
func getPendingNotifications() async -> [UNNotificationRequest] {
await UNUserNotificationCenter.current().pendingNotificationRequests()
}
}App Delegate Integration
Complete Setup
import UIKit
import UserNotifications
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Set delegate
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Register categories
NotificationCategories.registerAll()
// Check if launched from notification
if let notification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
handleLaunchNotification(notification)
}
return true
}
// MARK: - Remote Notification Registration
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
NotificationManager.shared.didRegister(with: deviceToken)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
NotificationManager.shared.didFailToRegister(with: error)
}
// MARK: - Background Notifications
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
// Handle silent notifications
return await handleSilentNotification(userInfo)
}
// MARK: - Helpers
private func handleLaunchNotification(_ userInfo: [AnyHashable: Any]) {
if let payload = NotificationPayload(userInfo: userInfo) {
// App was launched from notification
// Handle deep link or navigation
}
}
private func handleSilentNotification(_ userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
guard let aps = userInfo["aps"] as? [String: Any],
aps["content-available"] as? Int == 1 else {
return .noData
}
// Perform background work
return .newData
}
}import UserNotifications
/// Notification categories define the actions available for each notification type.
///
/// Register on app launch:
/// ```swift
/// NotificationCategories.registerAll()
/// ```
///
/// Server should include `category` in APNs payload:
/// ```json
/// {
/// "aps": {
/// "alert": { "title": "New Message", "body": "Hello!" },
/// "category": "MESSAGE_CATEGORY"
/// }
/// }
/// ```
enum NotificationCategories {
// MARK: - Category Identifiers
enum Identifier: String, CaseIterable {
case message = "MESSAGE_CATEGORY"
case reminder = "REMINDER_CATEGORY"
case update = "UPDATE_CATEGORY"
case social = "SOCIAL_CATEGORY"
}
// MARK: - Action Identifiers
enum Action: String {
// Message actions
case reply = "REPLY_ACTION"
case markRead = "MARK_READ_ACTION"
// Reminder actions
case complete = "COMPLETE_ACTION"
case snooze = "SNOOZE_ACTION"
// Update actions
case view = "VIEW_ACTION"
case later = "LATER_ACTION"
// Social actions
case like = "LIKE_ACTION"
case comment = "COMMENT_ACTION"
}
// MARK: - Registration
/// Register all notification categories.
/// Call this on app launch in didFinishLaunchingWithOptions.
static func registerAll() {
let categories: Set<UNNotificationCategory> = [
messageCategory,
reminderCategory,
updateCategory,
socialCategory
]
UNUserNotificationCenter.current().setNotificationCategories(categories)
#if DEBUG
print("📋 [Notifications] Registered \(categories.count) categories")
#endif
}
// MARK: - Category Definitions
/// Message category with reply and mark read actions.
private static var messageCategory: UNNotificationCategory {
// Text input action for replies
let replyAction = UNTextInputNotificationAction(
identifier: Action.reply.rawValue,
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type your reply..."
)
let markReadAction = UNNotificationAction(
identifier: Action.markRead.rawValue,
title: "Mark as Read",
options: []
)
return UNNotificationCategory(
identifier: Identifier.message.rawValue,
actions: [replyAction, markReadAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "New message",
categorySummaryFormat: "%u new messages",
options: [.customDismissAction, .allowInCarPlay]
)
}
/// Reminder category with complete and snooze actions.
private static var reminderCategory: UNNotificationCategory {
let completeAction = UNNotificationAction(
identifier: Action.complete.rawValue,
title: "Complete",
options: [.foreground] // Opens app
)
let snoozeAction = UNNotificationAction(
identifier: Action.snooze.rawValue,
title: "Snooze 15 min",
options: []
)
return UNNotificationCategory(
identifier: Identifier.reminder.rawValue,
actions: [completeAction, snoozeAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "Reminder",
options: []
)
}
/// Update category with view and later actions.
private static var updateCategory: UNNotificationCategory {
let viewAction = UNNotificationAction(
identifier: Action.view.rawValue,
title: "View",
options: [.foreground]
)
let laterAction = UNNotificationAction(
identifier: Action.later.rawValue,
title: "Later",
options: []
)
return UNNotificationCategory(
identifier: Identifier.update.rawValue,
actions: [viewAction, laterAction],
intentIdentifiers: [],
options: []
)
}
/// Social category with like and comment actions.
private static var socialCategory: UNNotificationCategory {
let likeAction = UNNotificationAction(
identifier: Action.like.rawValue,
title: "❤️ Like",
options: []
)
let commentAction = UNTextInputNotificationAction(
identifier: Action.comment.rawValue,
title: "Comment",
options: [.foreground],
textInputButtonTitle: "Post",
textInputPlaceholder: "Write a comment..."
)
return UNNotificationCategory(
identifier: Identifier.social.rawValue,
actions: [likeAction, commentAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "New activity",
categorySummaryFormat: "%u new notifications",
options: [.customDismissAction]
)
}
}
// MARK: - Category Summary Formats
/*
Category Summary Format Specifiers:
%u - Number of notifications in the group
%@ - The summary argument from the notification content
Example:
- categorySummaryFormat: "%u new messages from %@"
- With 3 notifications from "John"
- Shows: "3 new messages from John"
To set the summary argument:
content.summaryArgument = "John"
content.summaryArgumentCount = 3 // Optional, defaults to 1
*/
// MARK: - Action Options Reference
/*
UNNotificationActionOptions:
.authenticationRequired - Requires device unlock
.destructive - Red text, indicates destructive action
.foreground - Opens the app when selected
UNNotificationCategoryOptions:
.customDismissAction - Calls delegate when dismissed
.allowInCarPlay - Shows in CarPlay
.hiddenPreviewsShowTitle - Shows title even when previews hidden
.hiddenPreviewsShowSubtitle - Shows subtitle even when previews hidden
.allowAnnouncement - Siri can announce
*/
import Foundation
import UserNotifications
/// Handles notification presentation and user responses.
///
/// Setup in AppDelegate:
/// ```swift
/// func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
/// UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
/// NotificationCategories.registerAll()
/// return true
/// }
/// ```
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
// MARK: - Singleton
static let shared = NotificationDelegate()
// MARK: - Handlers
/// Custom handler for notification tap. Set this to handle navigation.
var onNotificationTap: ((NotificationPayload) async -> Void)?
/// Custom handler for notification actions. Set this to handle action buttons.
var onNotificationAction: ((String, NotificationPayload) async -> Void)?
// MARK: - Initialization
private override init() {
super.init()
}
// MARK: - UNUserNotificationCenterDelegate
/// Called when a notification arrives while app is in foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let userInfo = notification.request.content.userInfo
#if DEBUG
print("📬 [Notifications] Received in foreground")
print(" Title: \(notification.request.content.title)")
print(" Body: \(notification.request.content.body)")
#endif
// Parse and optionally handle
if let payload = NotificationPayload(userInfo: userInfo) {
// You can choose to handle silently or show banner
// For example, don't show banner if user is already viewing related content
// return []
_ = payload // Suppress unused warning
}
// Show notification banner, sound, and badge
return [.banner, .sound, .badge, .list]
}
/// Called when user interacts with a notification.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let actionIdentifier = response.actionIdentifier
#if DEBUG
print("📬 [Notifications] User responded")
print(" Action: \(actionIdentifier)")
#endif
guard let payload = NotificationPayload(userInfo: userInfo) else {
return
}
switch actionIdentifier {
case UNNotificationDefaultActionIdentifier:
// User tapped the notification
await handleNotificationTap(payload)
case UNNotificationDismissActionIdentifier:
// User dismissed (only if category has .customDismissAction)
#if DEBUG
print(" User dismissed notification")
#endif
default:
// Custom action button
await handleCustomAction(actionIdentifier, payload: payload, response: response)
}
}
/// Called when user opens notification settings from notification.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
// Post notification for app to handle
NotificationCenter.default.post(
name: .openNotificationSettings,
object: nil
)
}
// MARK: - Handlers
private func handleNotificationTap(_ payload: NotificationPayload) async {
// Use custom handler if set
if let handler = onNotificationTap {
await handler(payload)
return
}
// Default handling based on type
await MainActor.run {
switch payload.type {
case .message:
if let resourceId = payload.resourceId {
NotificationCenter.default.post(
name: .navigateToMessage,
object: nil,
userInfo: ["messageId": resourceId]
)
}
case .reminder:
if let resourceId = payload.resourceId {
NotificationCenter.default.post(
name: .navigateToReminder,
object: nil,
userInfo: ["reminderId": resourceId]
)
}
case .update:
NotificationCenter.default.post(
name: .showUpdateDetails,
object: nil,
userInfo: payload.extra
)
case .unknown:
// Handle deep link if available
if let deepLink = payload.deepLink {
NotificationCenter.default.post(
name: .handleDeepLink,
object: nil,
userInfo: ["url": deepLink]
)
}
}
}
}
private func handleCustomAction(
_ identifier: String,
payload: NotificationPayload,
response: UNNotificationResponse
) async {
// Use custom handler if set
if let handler = onNotificationAction {
await handler(identifier, payload)
return
}
// Default action handling
switch identifier {
case NotificationCategories.Action.reply.rawValue:
// Handle text input reply
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(text: textResponse.userText, payload: payload)
}
case NotificationCategories.Action.markRead.rawValue:
await markAsRead(payload: payload)
case NotificationCategories.Action.complete.rawValue:
await completeTask(payload: payload)
case NotificationCategories.Action.snooze.rawValue:
await snoozeReminder(payload: payload)
default:
#if DEBUG
print("⚠️ [Notifications] Unknown action: \(identifier)")
#endif
}
}
// MARK: - Action Implementations
private func handleReply(text: String, payload: NotificationPayload) async {
#if DEBUG
print("💬 [Notifications] Reply: \(text)")
#endif
// TODO: Send reply to server
// await APIClient.shared.sendReply(to: payload.resourceId, text: text)
}
private func markAsRead(payload: NotificationPayload) async {
#if DEBUG
print("✓ [Notifications] Mark as read: \(payload.resourceId ?? "unknown")")
#endif
// TODO: Mark as read on server
// await APIClient.shared.markAsRead(payload.resourceId)
}
private func completeTask(payload: NotificationPayload) async {
#if DEBUG
print("✓ [Notifications] Complete task: \(payload.resourceId ?? "unknown")")
#endif
// TODO: Complete task on server
// await APIClient.shared.completeTask(payload.resourceId)
}
private func snoozeReminder(payload: NotificationPayload) async {
#if DEBUG
print("⏰ [Notifications] Snooze: \(payload.resourceId ?? "unknown")")
#endif
// Reschedule notification for 15 minutes later
do {
try await NotificationManager.shared.scheduleLocalNotification(
title: payload.title ?? "Reminder",
body: payload.body ?? "",
in: 15 * 60, // 15 minutes
category: NotificationCategories.Identifier.reminder.rawValue,
userInfo: payload.extra
)
} catch {
#if DEBUG
print("⚠️ [Notifications] Failed to snooze: \(error)")
#endif
}
}
}
// MARK: - Notification Names
extension Notification.Name {
/// Posted when user wants to open notification settings.
static let openNotificationSettings = Notification.Name("openNotificationSettings")
/// Posted when navigating to a message from notification.
static let navigateToMessage = Notification.Name("navigateToMessage")
/// Posted when navigating to a reminder from notification.
static let navigateToReminder = Notification.Name("navigateToReminder")
/// Posted when showing update details from notification.
static let showUpdateDetails = Notification.Name("showUpdateDetails")
/// Posted when handling a deep link from notification.
static let handleDeepLink = Notification.Name("handleDeepLink")
}
import Foundation
import UserNotifications
#if canImport(UIKit)
import UIKit
#endif
/// Central manager for push notification registration and handling.
///
/// Usage:
/// ```swift
/// // Request permission
/// let granted = try await NotificationManager.shared.requestAuthorization()
///
/// // Register for remote notifications
/// if granted {
/// NotificationManager.shared.registerForRemoteNotifications()
/// }
///
/// // Schedule local notification
/// try await NotificationManager.shared.scheduleLocalNotification(
/// title: "Reminder",
/// body: "Don't forget!",
/// at: Date().addingTimeInterval(3600)
/// )
/// ```
@MainActor
@Observable
final class NotificationManager {
// MARK: - Singleton
static let shared = NotificationManager()
// MARK: - Properties
/// Current authorization status.
private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
/// Device token for remote notifications (hex string).
private(set) var deviceToken: String?
/// Whether notifications are enabled.
var isAuthorized: Bool {
authorizationStatus == .authorized
}
/// Whether notifications are provisionally authorized.
var isProvisional: Bool {
authorizationStatus == .provisional
}
// MARK: - Initialization
private init() {
Task {
await refreshAuthorizationStatus()
}
}
// MARK: - Authorization
/// Request notification authorization from the user.
///
/// - Parameter options: Authorization options (defaults to alert, badge, sound).
/// - Returns: Whether authorization was granted.
@discardableResult
func requestAuthorization(
options: UNAuthorizationOptions = [.alert, .badge, .sound]
) async throws -> Bool {
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: options)
await refreshAuthorizationStatus()
return granted
}
/// Request provisional authorization (iOS 12+).
/// Notifications are delivered quietly without prompting the user.
@discardableResult
func requestProvisionalAuthorization() async throws -> Bool {
let options: UNAuthorizationOptions = [.alert, .badge, .sound, .provisional]
return try await requestAuthorization(options: options)
}
/// Refresh the current authorization status.
func refreshAuthorizationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
authorizationStatus = settings.authorizationStatus
}
/// Check if a specific notification setting is enabled.
func isSettingEnabled(_ keyPath: KeyPath<UNNotificationSettings, UNNotificationSetting>) async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
return settings[keyPath: keyPath] == .enabled
}
// MARK: - Remote Notification Registration
/// Register for remote notifications.
func registerForRemoteNotifications() {
#if canImport(UIKit) && !os(watchOS)
UIApplication.shared.registerForRemoteNotifications()
#endif
}
/// Unregister from remote notifications.
func unregisterForRemoteNotifications() {
#if canImport(UIKit) && !os(watchOS)
UIApplication.shared.unregisterForRemoteNotifications()
#endif
deviceToken = nil
}
/// Called by AppDelegate when registration succeeds.
func didRegisterForRemoteNotifications(with deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
self.deviceToken = token
#if DEBUG
print("📱 [Notifications] Device token: \(token)")
#endif
// Send token to server
Task {
await sendTokenToServer(token)
}
}
/// Called by AppDelegate when registration fails.
func didFailToRegisterForRemoteNotifications(with error: Error) {
#if DEBUG
print("❌ [Notifications] Registration failed: \(error.localizedDescription)")
#endif
}
// MARK: - Server Communication
private func sendTokenToServer(_ token: String) async {
// TODO: Implement your server token registration
// Example:
// do {
// try await APIClient.shared.registerDeviceToken(token)
// } catch {
// print("Failed to register token with server: \(error)")
// }
}
// MARK: - Badge Management
/// Clear the app badge.
func clearBadge() async {
do {
try await UNUserNotificationCenter.current().setBadgeCount(0)
} catch {
#if DEBUG
print("⚠️ [Notifications] Failed to clear badge: \(error)")
#endif
}
}
/// Set the app badge count.
func setBadge(_ count: Int) async {
do {
try await UNUserNotificationCenter.current().setBadgeCount(count)
} catch {
#if DEBUG
print("⚠️ [Notifications] Failed to set badge: \(error)")
#endif
}
}
// MARK: - Local Notifications
/// Schedule a local notification.
///
/// - Parameters:
/// - title: Notification title.
/// - body: Notification body.
/// - date: When to deliver the notification.
/// - category: Optional category identifier for actions.
/// - userInfo: Additional data to include.
/// - Returns: The notification identifier (for cancellation).
@discardableResult
func scheduleLocalNotification(
title: String,
body: String,
at date: Date,
category: String? = nil,
userInfo: [String: Any] = [:]
) async throws -> String {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = userInfo
if let category {
content.categoryIdentifier = category
}
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second],
from: date
)
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: false
)
let identifier = UUID().uuidString
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(request)
#if DEBUG
print("📅 [Notifications] Scheduled: \(identifier) for \(date)")
#endif
return identifier
}
/// Schedule a notification with a time interval.
@discardableResult
func scheduleLocalNotification(
title: String,
body: String,
in timeInterval: TimeInterval,
category: String? = nil,
userInfo: [String: Any] = [:]
) async throws -> String {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = userInfo
if let category {
content.categoryIdentifier = category
}
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: timeInterval,
repeats: false
)
let identifier = UUID().uuidString
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(request)
return identifier
}
/// Cancel a scheduled notification.
func cancelNotification(identifier: String) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: [identifier])
}
/// Cancel all pending notifications.
func cancelAllNotifications() {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
/// Get all pending notification requests.
func getPendingNotifications() async -> [UNNotificationRequest] {
await UNUserNotificationCenter.current().pendingNotificationRequests()
}
/// Get all delivered notifications.
func getDeliveredNotifications() async -> [UNNotification] {
await UNUserNotificationCenter.current().deliveredNotifications()
}
/// Remove specific delivered notifications.
func removeDeliveredNotifications(identifiers: [String]) {
UNUserNotificationCenter.current()
.removeDeliveredNotifications(withIdentifiers: identifiers)
}
/// Remove all delivered notifications.
func removeAllDeliveredNotifications() {
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
}
}
// MARK: - Settings URL Helper
extension NotificationManager {
/// Open system notification settings for this app.
func openNotificationSettings() {
#if canImport(UIKit) && !os(watchOS)
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else {
return
}
Task { @MainActor in
await UIApplication.shared.open(url)
}
#endif
}
}
import Foundation
/// Type-safe representation of a push notification payload.
///
/// Expected server payload format:
/// ```json
/// {
/// "aps": {
/// "alert": {
/// "title": "Notification Title",
/// "body": "Notification body text"
/// },
/// "badge": 1,
/// "sound": "default",
/// "category": "MESSAGE_CATEGORY",
/// "mutable-content": 1
/// },
/// "type": "message",
/// "resource_id": "msg_12345",
/// "deep_link": "myapp://messages/msg_12345",
/// "image_url": "https://example.com/image.jpg",
/// "custom_field": "custom_value"
/// }
/// ```
struct NotificationPayload: Sendable {
// MARK: - Types
/// Notification type for routing.
enum NotificationType: String, Sendable {
case message
case reminder
case update
case social
case promo
case unknown
}
// MARK: - Properties
/// Type of notification for routing.
let type: NotificationType
/// Notification title from alert.
let title: String?
/// Notification body from alert.
let body: String?
/// Subtitle from alert.
let subtitle: String?
/// Resource identifier for deep linking.
let resourceId: String?
/// Deep link URL.
let deepLink: URL?
/// Image URL for rich notifications.
let imageURL: URL?
/// Badge count.
let badge: Int?
/// Category identifier.
let category: String?
/// Thread identifier for grouping.
let threadId: String?
/// Any extra custom fields.
let extra: [String: Any]
/// Original userInfo dictionary.
let rawUserInfo: [AnyHashable: Any]
// MARK: - Initialization
init?(userInfo: [AnyHashable: Any]) {
rawUserInfo = userInfo
// Parse APS dictionary
guard let aps = userInfo["aps"] as? [String: Any] else {
return nil
}
// Parse alert
if let alert = aps["alert"] as? [String: Any] {
title = alert["title"] as? String
body = alert["body"] as? String
subtitle = alert["subtitle"] as? String
} else if let alertString = aps["alert"] as? String {
title = nil
body = alertString
subtitle = nil
} else {
title = nil
body = nil
subtitle = nil
}
// Parse badge
badge = aps["badge"] as? Int
// Parse category
category = aps["category"] as? String
// Parse thread ID
threadId = aps["thread-id"] as? String
// Parse custom fields
let typeString = userInfo["type"] as? String ?? "unknown"
type = NotificationType(rawValue: typeString) ?? .unknown
resourceId = userInfo["resource_id"] as? String
?? userInfo["resourceId"] as? String
?? userInfo["id"] as? String
if let deepLinkString = userInfo["deep_link"] as? String
?? userInfo["deepLink"] as? String
?? userInfo["url"] as? String {
deepLink = URL(string: deepLinkString)
} else {
deepLink = nil
}
if let imageString = userInfo["image_url"] as? String
?? userInfo["imageUrl"] as? String
?? userInfo["image"] as? String {
imageURL = URL(string: imageString)
} else {
imageURL = nil
}
// Collect extra fields
let reservedKeys: Set<String> = [
"aps", "type", "resource_id", "resourceId", "id",
"deep_link", "deepLink", "url",
"image_url", "imageUrl", "image"
]
var extra: [String: Any] = [:]
for (key, value) in userInfo {
if let key = key as? String, !reservedKeys.contains(key) {
extra[key] = value
}
}
self.extra = extra
}
}
// MARK: - Convenience Accessors
extension NotificationPayload {
/// Check if this is a silent notification.
var isSilent: Bool {
guard let aps = rawUserInfo["aps"] as? [String: Any] else {
return false
}
return aps["content-available"] as? Int == 1 && title == nil && body == nil
}
/// Check if this notification has rich content (image).
var hasRichContent: Bool {
imageURL != nil
}
/// Get a typed extra value.
func extra<T>(_ key: String, as type: T.Type = T.self) -> T? {
extra[key] as? T
}
}
// MARK: - Debug Description
extension NotificationPayload: CustomDebugStringConvertible {
var debugDescription: String {
"""
NotificationPayload:
type: \(type.rawValue)
title: \(title ?? "nil")
body: \(body ?? "nil")
resourceId: \(resourceId ?? "nil")
deepLink: \(deepLink?.absoluteString ?? "nil")
imageURL: \(imageURL?.absoluteString ?? "nil")
badge: \(badge.map { String($0) } ?? "nil")
category: \(category ?? "nil")
isSilent: \(isSilent)
extra: \(extra.keys.joined(separator: ", "))
"""
}
}
// MARK: - Example Payloads
/*
Message notification:
{
"aps": {
"alert": { "title": "John", "body": "Hey, how are you?" },
"badge": 1,
"sound": "default",
"category": "MESSAGE_CATEGORY",
"thread-id": "conversation_123"
},
"type": "message",
"resource_id": "msg_456",
"deep_link": "myapp://messages/msg_456"
}
Reminder notification:
{
"aps": {
"alert": { "title": "Reminder", "body": "Meeting in 15 minutes" },
"sound": "default",
"category": "REMINDER_CATEGORY"
},
"type": "reminder",
"resource_id": "reminder_789",
"meeting_id": "meeting_123"
}
Silent notification (background update):
{
"aps": {
"content-available": 1
},
"type": "sync",
"sync_type": "messages",
"timestamp": "2024-01-15T10:30:00Z"
}
Rich notification (with image):
{
"aps": {
"alert": { "title": "New Photo", "body": "Alice shared a photo" },
"sound": "default",
"mutable-content": 1
},
"type": "social",
"resource_id": "photo_123",
"image_url": "https://example.com/photos/preview.jpg"
}
*/