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" : {