diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml
index e112ba8..eb6e94f 100644
--- a/Android/app/src/main/AndroidManifest.xml
+++ b/Android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,9 @@
android:name="android.hardware.camera.autofocus"
android:required="false" />
+
+
+
@@ -30,7 +33,16 @@
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher">
+
+
+
+
+
+
+
+
-
+
+ aps-environment
+ production
+ com.apple.developer.aps-environment
+ production
+
diff --git a/Package.swift b/Package.swift
index b733170..723355c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,7 +10,11 @@ let package = Package(
],
dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.4.0"),
- .package(url: "https://source.skip.tools/skip-ui.git", from: "1.26.0"),
+
+ // TODO: Update skip ui package URL and version after skip ui PR has been merged
+ .package(url: "https://source.skip.tools/skip-ui.git", branch: "main"),
+ //.package(url: "https://source.skip.tools/skip-ui.git", from: "1.26.0"),
+
.package(url: "https://source.skip.tools/skip-av.git", "0.0.0"..<"2.0.0"),
.package(url: "https://source.skip.tools/skip-kit.git", "0.0.0"..<"2.0.0"),
.package(url: "https://source.skip.tools/skip-sql.git", "0.12.1"..<"2.0.0"),
@@ -20,6 +24,7 @@ let package = Package(
.package(url: "https://source.skip.tools/skip-keychain.git", "0.3.0"..<"2.0.0"),
.package(url: "https://source.skip.tools/skip-marketplace.git", "0.0.0"..<"2.0.0"),
.package(url: "https://source.skip.tools/skip-authentication-services.git", "0.0.0"..<"2.0.0"),
+ .package(url: "https://source.skip.tools/skip-notify.git", "0.0.0"..<"2.0.0"),
],
targets: [
.target(name: "Showcase", dependencies: [
@@ -33,6 +38,7 @@ let package = Package(
.product(name: "SkipKeychain", package: "skip-keychain"),
.product(name: "SkipMarketplace", package: "skip-marketplace"),
.product(name: "SkipAuthenticationServices", package: "skip-authentication-services"),
+ .product(name: "SkipNotify", package: "skip-notify"),
], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
]
)
diff --git a/Project.xcworkspace/contents.xcworkspacedata b/Project.xcworkspace/contents.xcworkspacedata
index 0f2d128..e1c9da1 100644
--- a/Project.xcworkspace/contents.xcworkspacedata
+++ b/Project.xcworkspace/contents.xcworkspacedata
@@ -4,4 +4,4 @@
-
\ No newline at end of file
+
diff --git a/Sources/Showcase/NotificationPlayground.swift b/Sources/Showcase/NotificationPlayground.swift
new file mode 100644
index 0000000..3c4ee60
--- /dev/null
+++ b/Sources/Showcase/NotificationPlayground.swift
@@ -0,0 +1,187 @@
+// Copyright 2023–2026 Skip
+import SwiftUI
+import SkipKit
+import SkipNotify
+
+struct NotificationPlayground: View {
+ @State var notificationPermission: String = ""
+
+ var body: some View {
+ List {
+ Section {
+ Text("Permission Status: \(self.notificationPermission)")
+ .task {
+ self.notificationPermission = await PermissionManager.queryPostNotificationPermission().rawValue
+ }
+ .foregroundStyle(self.notificationPermission == "authorized" ? .green : .red)
+
+ Button("Request Push Notification Permission") {
+ Task { @MainActor in
+ do {
+ self.notificationPermission = try await PermissionManager.requestPostNotificationPermission(alert: true, sound: false, badge: true).rawValue
+ logger.log("obtained push notification permission: \(self.notificationPermission)")
+ } catch {
+ logger.error("error obtaining push notification permission: \(error)")
+ self.notificationPermission = "error: \(error)"
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ Section {
+ NavigationLink("Skip Notify") {
+ SkipNotifyNotificationPlaygroundView()
+ }
+
+ NavigationLink("Local Notifications") {
+ LocalNotificationPlaygroundView()
+ }
+ }
+ }
+ .navigationTitle("Notification")
+ }
+}
+
+struct SkipNotifyNotificationPlaygroundView: View {
+ @State var token: String = ""
+ var body: some View {
+ VStack {
+ HStack {
+ TextField("Push Notification Client Token", text: $token)
+ .textFieldStyle(.roundedBorder)
+ Button("Copy") {
+ UIPasteboard.general.string = token
+ }
+ .buttonStyle(.automatic)
+ }
+
+ Button("Generate Push Notification Token") {
+ Task { @MainActor in
+ do {
+ self.token = try await SkipNotify.shared.fetchNotificationToken()
+ logger.log("obtained push notification token: \(self.token)")
+ } catch {
+ logger.error("error obtaining push notification token: \(error)")
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .navigationTitle("Skip Notify")
+ .padding()
+ }
+}
+
+struct LocalNotificationPlaygroundView: View {
+ @State var timerDate: Date?
+ @State var nextTriggerDate: Date?
+ let timer = Timer.publish(every: 1.0, on: .main, in: .default).autoconnect()
+ private var secondsUntilNextTrigger: Int? {
+ guard let timerDate, let nextTriggerDate else { return nil }
+ let seconds = Int(nextTriggerDate.timeIntervalSince(timerDate))
+ return seconds > 0 ? seconds : nil
+ }
+
+ private let notificationCenterDelegate = NotificationCenterDelegate()
+
+ init() {
+ let notificationCenter = UNUserNotificationCenter.current()
+ notificationCenter.delegate = self.notificationCenterDelegate
+ }
+
+ var body: some View {
+ VStack(spacing: 20) {
+ VStack(spacing: 10) {
+ Button("Trigger Immediate Push Notification") {
+ Task {
+ await self.addNotificationRequest(
+ title: "Title",
+ body: "Body",
+ identifier: UUID().uuidString
+ )
+ }
+ }
+ .backgroundStyle(.blue)
+ .buttonStyle(.borderedProminent)
+
+ Button("Trigger Scheduled Push Notification") {
+ Task {
+ await self.addNotificationRequest(
+ title: "Title",
+ body: "Body",
+ identifier: UUID().uuidString,
+ trigger: {
+ let result = UNTimeIntervalNotificationTrigger(
+ timeInterval: 10,
+ repeats: false
+ )
+ self.nextTriggerDate = result.nextTriggerDate()
+ return result
+ }()
+ )
+ }
+ }
+ .backgroundStyle(.blue)
+ .buttonStyle(.borderedProminent)
+ .disabled(self.secondsUntilNextTrigger != nil)
+ }
+
+ if let seconds = self.secondsUntilNextTrigger {
+ Text("Next notification in \(seconds) s")
+ .foregroundStyle(.secondary)
+
+ Button("Remove all pending notifications") {
+ self.removeAllPendingNotifications()
+ self.nextTriggerDate = nil
+ }
+ .foregroundStyle(.red)
+ } else {
+ Text("No scheduled notification")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("Local Notifications")
+ .onReceive(self.timer) { date in
+ self.timerDate = Date()
+ }
+ }
+
+ private func addNotificationRequest(
+ title: String,
+ body: String,
+ identifier: String,
+ trigger: UNNotificationTrigger? = nil
+ ) async {
+
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.userInfo = [
+ "identifier": UUID().uuidString
+ ]
+
+ let request = UNNotificationRequest(
+ identifier: identifier,
+ content: content,
+ trigger: trigger
+ )
+
+ let notificationCenter = UNUserNotificationCenter.current()
+ try? await notificationCenter.add(request)
+ }
+
+ private func removeAllPendingNotifications() {
+ let notificationCenter = UNUserNotificationCenter.current()
+ notificationCenter.removeAllPendingNotificationRequests()
+ }
+}
+
+private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification
+ ) async -> UNNotificationPresentationOptions {
+ return [.banner]
+ }
+}
diff --git a/Sources/Showcase/PlaygroundListView.swift b/Sources/Showcase/PlaygroundListView.swift
index 83e8d69..ff569fb 100644
--- a/Sources/Showcase/PlaygroundListView.swift
+++ b/Sources/Showcase/PlaygroundListView.swift
@@ -42,6 +42,7 @@ enum PlaygroundType: CaseIterable, View {
case menu
case modifier
case navigationStack
+ case notification
case observable
case offsetPosition
case onSubmit
@@ -164,6 +165,8 @@ enum PlaygroundType: CaseIterable, View {
return LocalizedStringResource("Modifiers")
case .navigationStack:
return LocalizedStringResource("NavigationStack")
+ case .notification:
+ return LocalizedStringResource("Notification")
case .observable:
return LocalizedStringResource("Observable")
case .offsetPosition:
@@ -329,6 +332,8 @@ enum PlaygroundType: CaseIterable, View {
ModifierPlayground()
case .navigationStack:
NavigationStackPlayground()
+ case .notification:
+ NotificationPlayground()
case .observable:
ObservablePlayground()
case .offsetPosition:
diff --git a/Sources/Showcase/Resources/Localizable.xcstrings b/Sources/Showcase/Resources/Localizable.xcstrings
index a93db69..8a9af39 100644
--- a/Sources/Showcase/Resources/Localizable.xcstrings
+++ b/Sources/Showcase/Resources/Localizable.xcstrings
@@ -1424,6 +1424,9 @@
},
"Fullscreen cover" : {
+ },
+ "Generate Push Notification Token" : {
+
},
"GeometryReader" : {
@@ -1768,6 +1771,9 @@
},
"Local frame: %@" : {
+ },
+ "Local Notifications" : {
+
},
"Localization" : {
@@ -1932,6 +1938,9 @@
},
"New Key" : {
+ },
+ "Next notification in %lld s" : {
+
},
"Nil data" : {
@@ -1944,6 +1953,9 @@
},
"NO" : {
+ },
+ "No scheduled notification" : {
+
},
"No URL" : {
@@ -1962,6 +1974,9 @@
},
"Note: tint should not affect Label appearance" : {
+ },
+ "Notification" : {
+ "extractionState" : "manual"
},
"Observable" : {
@@ -2111,6 +2126,9 @@
},
"PDF Image" : {
+ },
+ "Permission Status: %@" : {
+
},
"Pick Document" : {
@@ -2240,6 +2258,9 @@
},
"Push binding view" : {
+ },
+ "Push Notification Client Token" : {
+
},
"Push Text.position(100, 100)" : {
@@ -2279,9 +2300,15 @@
},
"Remapped URL" : {
+ },
+ "Remove all pending notifications" : {
+
},
"Repository item tap count: %lld" : {
+ },
+ "Request Push Notification Permission" : {
+
},
"Requires iOS 17+" : {
@@ -2734,6 +2761,9 @@
},
"Skip Intro" : {
+ },
+ "Skip Notify" : {
+
},
"Skip Technology" : {
"extractionState" : "manual",
@@ -3124,6 +3154,12 @@
},
"Transition" : {
+ },
+ "Trigger Immediate Push Notification" : {
+
+ },
+ "Trigger Scheduled Push Notification" : {
+
},
"Two Buttons" : {