├── Example
├── Example
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── ExampleApp.swift
│ └── ContentView.swift
└── Example.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── project.pbxproj
├── .gitignore
├── Sources
└── LocalNotificationEditor
│ ├── Internal
│ ├── IdentifiableBox.swift
│ ├── Dictionary+compactMapKeys.swift
│ ├── Dictionary+json.swift
│ └── LocalNotificationEditor.swift
│ ├── NotificationCenterProtocol.swift
│ └── LocalNotificationList.swift
├── Tests
└── LocalNotificationEditorTests
│ └── LocalNotificationEditorTests.swift
├── README.md
├── LICENSE
└── Package.swift
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Example/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/Example/Example/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct ExampleApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/Internal/IdentifiableBox.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct IdentifiableBox: Identifiable {
4 | let id: ID
5 | let value: Value
6 |
7 | init(value: Value, id: KeyPath) {
8 | self.value = value
9 | self.id = value[keyPath: id]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/Internal/Dictionary+compactMapKeys.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Dictionary {
4 | func compactMapKeys(
5 | _ transform: ((Key) throws -> T?)
6 | ) rethrows -> Dictionary {
7 | return try self.reduce(into: [T: Value](), { (result, x) in
8 | if let key = try transform(x.key) {
9 | result[key] = x.value
10 | }
11 | })
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Tests/LocalNotificationEditorTests/LocalNotificationEditorTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import LocalNotificationEditor
3 |
4 | final class LocalNotificationEditorTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/Internal/Dictionary+json.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension [String: Any] {
4 | func json() -> String {
5 | guard JSONSerialization.isValidJSONObject(self),
6 | let json = try? JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted, .sortedKeys]),
7 | let jsonString = String(data: json, encoding: .utf8)
8 | else {
9 | return "{}"
10 | }
11 | return jsonString
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/NotificationCenterProtocol.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UserNotifications
3 |
4 | public protocol UNUserNotificationCenterProtocol {
5 | func add(_ request: UNNotificationRequest) async throws
6 | func removeAllPendingNotificationRequests()
7 | func pendingNotificationRequests() async -> [UNNotificationRequest]
8 | func removePendingNotificationRequests(withIdentifiers: [String])
9 | }
10 |
11 | extension UNUserNotificationCenter: UNUserNotificationCenterProtocol {}
12 |
--------------------------------------------------------------------------------
/Example/Example/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import LocalNotificationEditor
3 | import UserNotifications
4 |
5 | struct ContentView: View {
6 | var body: some View {
7 | NavigationStack {
8 | LocalNotificationList(userNotificationCenter: .current())
9 | }
10 | .task {
11 | _ = try? await UNUserNotificationCenter.current().requestAuthorization(
12 | options: [.alert, .badge, .sound]
13 | )
14 | }
15 | }
16 | }
17 |
18 | #Preview {
19 | ContentView()
20 | }
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LocalNotificationEditor
2 | A SwiftUI view for easily displaying, adding, removing, and editing local notifications for debugging.
3 |
4 |
5 |
6 |
7 |
8 | # Usage
9 | Here's a example code:
10 | ```Swift
11 | import SwiftUI
12 | import LocalNotificationEditor
13 |
14 | public struct MyView: View {
15 | public var body: some View {
16 | NavigationStack {
17 | LocalNotificationList(userNotificationCenter: .current())
18 | }
19 | }
20 | }
21 | ```
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ryu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "LocalNotificationEditor",
8 | platforms: [
9 | .iOS(.v15),
10 | .macOS(.v12),
11 | .macCatalyst(.v15),
12 | .tvOS(.v15),
13 | .watchOS(.v8),
14 | .visionOS(.v1)
15 | ],
16 | products: [
17 | // Products define the executables and libraries a package produces, making them visible to other packages.
18 | .library(
19 | name: "LocalNotificationEditor",
20 | targets: ["LocalNotificationEditor"]),
21 | ],
22 | targets: [
23 | // Targets are the basic building blocks of a package, defining a module or a test suite.
24 | // Targets can depend on other targets in this package and products from dependencies.
25 | .target(
26 | name: "LocalNotificationEditor"),
27 | .testTarget(
28 | name: "LocalNotificationEditorTests",
29 | dependencies: ["LocalNotificationEditor"]),
30 | ]
31 | )
32 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/LocalNotificationList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UserNotifications
3 |
4 | public struct LocalNotificationList: View {
5 | @State var notificationRequests: [UNNotificationRequest] = []
6 | @State var selectedRequest: IdentifiableBox?
7 | @State var isAddNotificationEditorPresented = false
8 | @State var isDeleteAllAlertPresented = false
9 |
10 | let userNotificationCenter: any UNUserNotificationCenterProtocol
11 |
12 | public init(userNotificationCenter: some UNUserNotificationCenterProtocol) {
13 | self.userNotificationCenter = userNotificationCenter
14 | }
15 |
16 | public var body: some View {
17 | List {
18 | ForEach(notificationRequests, id: \.identifier) { request in
19 | HStack {
20 | VStack(alignment: .leading) {
21 | if !request.content.title.isEmpty {
22 | Text(request.content.title)
23 | .font(.title3.bold())
24 | }
25 | if !request.content.subtitle.isEmpty {
26 | Text(request.content.subtitle)
27 | .font(.headline.bold())
28 | }
29 | if !request.content.body.isEmpty {
30 | Text(request.content.body)
31 | .font(.caption)
32 | .foregroundStyle(.secondary)
33 | }
34 | }
35 | Spacer()
36 | Text("id: " + request.identifier)
37 | .font(.caption)
38 | .foregroundStyle(.secondary)
39 | }
40 | .contentShape(Rectangle())
41 | .onTapGesture {
42 | selectedRequest = IdentifiableBox(
43 | value: request,
44 | id: \.identifier
45 | )
46 | }
47 | }
48 | .onDelete { indexSet in
49 | userNotificationCenter.removePendingNotificationRequests(
50 | withIdentifiers: indexSet.map {
51 | notificationRequests[$0].identifier
52 | }
53 | )
54 | notificationRequests.remove(atOffsets: indexSet)
55 | }
56 | }
57 | .overlay {
58 | if notificationRequests.isEmpty, #available(iOS 17.0, *) {
59 | ContentUnavailableView(
60 | "No Pending Notifications",
61 | systemImage: "bell.slash.fill",
62 | description: Text("You currently have no notifications scheduled. Use the '+' button to add new notifications.")
63 | )
64 | }
65 | }
66 | .toolbar {
67 | ToolbarItem(placement: .topBarTrailing) {
68 | Button {
69 | isDeleteAllAlertPresented = true
70 | } label: {
71 | Image(systemName: "trash")
72 | .foregroundStyle(.red)
73 | }
74 | }
75 | ToolbarItem(placement: .topBarTrailing) {
76 | Button {
77 | isAddNotificationEditorPresented = true
78 | } label: {
79 | Image(systemName: "plus")
80 | }
81 | }
82 | }
83 | .alert("Do you really want to delete all notifications?", isPresented: $isDeleteAllAlertPresented) {
84 | Button(role: .cancel) {
85 | } label: {
86 | Text("Cancel")
87 | }
88 | Button(role: .destructive) {
89 | userNotificationCenter.removeAllPendingNotificationRequests()
90 | Task {
91 | await update()
92 | }
93 | } label: {
94 | Text("Delete")
95 | }
96 | }
97 | .sheet(item: $selectedRequest) { box in
98 | LocalNotificationEditor(
99 | userNotificationCenter: userNotificationCenter,
100 | mode: .edit(box.value),
101 | onSave: {
102 | Task {
103 | await update()
104 | }
105 | }
106 | )
107 | }
108 | .sheet(isPresented: $isAddNotificationEditorPresented) {
109 | LocalNotificationEditor(
110 | userNotificationCenter: userNotificationCenter,
111 | mode: .add,
112 | onSave: {
113 | Task {
114 | await update()
115 | }
116 | }
117 | )
118 | }
119 | .refreshable {
120 | await update()
121 | }
122 | .task {
123 | await update()
124 | }
125 | }
126 |
127 | private func update() async {
128 | notificationRequests = await userNotificationCenter.pendingNotificationRequests()
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 4230D4DC2B84DF0800AC3F6B /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4230D4DB2B84DF0800AC3F6B /* ExampleApp.swift */; };
11 | 4230D4DE2B84DF0800AC3F6B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4230D4DD2B84DF0800AC3F6B /* ContentView.swift */; };
12 | 4230D4E02B84DF0B00AC3F6B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4230D4DF2B84DF0B00AC3F6B /* Assets.xcassets */; };
13 | 4230D4E32B84DF0B00AC3F6B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4230D4E22B84DF0B00AC3F6B /* Preview Assets.xcassets */; };
14 | 4230D4EC2B84DF4D00AC3F6B /* LocalNotificationEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 4230D4EB2B84DF4D00AC3F6B /* LocalNotificationEditor */; };
15 | /* End PBXBuildFile section */
16 |
17 | /* Begin PBXFileReference section */
18 | 4230D4D82B84DF0800AC3F6B /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
19 | 4230D4DB2B84DF0800AC3F6B /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; };
20 | 4230D4DD2B84DF0800AC3F6B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
21 | 4230D4DF2B84DF0B00AC3F6B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
22 | 4230D4E22B84DF0B00AC3F6B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
23 | 4230D4E92B84DF2700AC3F6B /* LocalNotificationEditor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = LocalNotificationEditor; path = ..; sourceTree = ""; };
24 | /* End PBXFileReference section */
25 |
26 | /* Begin PBXFrameworksBuildPhase section */
27 | 4230D4D52B84DF0800AC3F6B /* Frameworks */ = {
28 | isa = PBXFrameworksBuildPhase;
29 | buildActionMask = 2147483647;
30 | files = (
31 | 4230D4EC2B84DF4D00AC3F6B /* LocalNotificationEditor in Frameworks */,
32 | );
33 | runOnlyForDeploymentPostprocessing = 0;
34 | };
35 | /* End PBXFrameworksBuildPhase section */
36 |
37 | /* Begin PBXGroup section */
38 | 4230D4CF2B84DF0800AC3F6B = {
39 | isa = PBXGroup;
40 | children = (
41 | 4230D4E92B84DF2700AC3F6B /* LocalNotificationEditor */,
42 | 4230D4DA2B84DF0800AC3F6B /* Example */,
43 | 4230D4D92B84DF0800AC3F6B /* Products */,
44 | 4230D4EA2B84DF4D00AC3F6B /* Frameworks */,
45 | );
46 | sourceTree = "";
47 | };
48 | 4230D4D92B84DF0800AC3F6B /* Products */ = {
49 | isa = PBXGroup;
50 | children = (
51 | 4230D4D82B84DF0800AC3F6B /* Example.app */,
52 | );
53 | name = Products;
54 | sourceTree = "";
55 | };
56 | 4230D4DA2B84DF0800AC3F6B /* Example */ = {
57 | isa = PBXGroup;
58 | children = (
59 | 4230D4DB2B84DF0800AC3F6B /* ExampleApp.swift */,
60 | 4230D4DD2B84DF0800AC3F6B /* ContentView.swift */,
61 | 4230D4DF2B84DF0B00AC3F6B /* Assets.xcassets */,
62 | 4230D4E12B84DF0B00AC3F6B /* Preview Content */,
63 | );
64 | path = Example;
65 | sourceTree = "";
66 | };
67 | 4230D4E12B84DF0B00AC3F6B /* Preview Content */ = {
68 | isa = PBXGroup;
69 | children = (
70 | 4230D4E22B84DF0B00AC3F6B /* Preview Assets.xcassets */,
71 | );
72 | path = "Preview Content";
73 | sourceTree = "";
74 | };
75 | 4230D4EA2B84DF4D00AC3F6B /* Frameworks */ = {
76 | isa = PBXGroup;
77 | children = (
78 | );
79 | name = Frameworks;
80 | sourceTree = "";
81 | };
82 | /* End PBXGroup section */
83 |
84 | /* Begin PBXNativeTarget section */
85 | 4230D4D72B84DF0800AC3F6B /* Example */ = {
86 | isa = PBXNativeTarget;
87 | buildConfigurationList = 4230D4E62B84DF0B00AC3F6B /* Build configuration list for PBXNativeTarget "Example" */;
88 | buildPhases = (
89 | 4230D4D42B84DF0800AC3F6B /* Sources */,
90 | 4230D4D52B84DF0800AC3F6B /* Frameworks */,
91 | 4230D4D62B84DF0800AC3F6B /* Resources */,
92 | );
93 | buildRules = (
94 | );
95 | dependencies = (
96 | );
97 | name = Example;
98 | packageProductDependencies = (
99 | 4230D4EB2B84DF4D00AC3F6B /* LocalNotificationEditor */,
100 | );
101 | productName = Example;
102 | productReference = 4230D4D82B84DF0800AC3F6B /* Example.app */;
103 | productType = "com.apple.product-type.application";
104 | };
105 | /* End PBXNativeTarget section */
106 |
107 | /* Begin PBXProject section */
108 | 4230D4D02B84DF0800AC3F6B /* Project object */ = {
109 | isa = PBXProject;
110 | attributes = {
111 | BuildIndependentTargetsInParallel = 1;
112 | LastSwiftUpdateCheck = 1520;
113 | LastUpgradeCheck = 1520;
114 | TargetAttributes = {
115 | 4230D4D72B84DF0800AC3F6B = {
116 | CreatedOnToolsVersion = 15.2;
117 | };
118 | };
119 | };
120 | buildConfigurationList = 4230D4D32B84DF0800AC3F6B /* Build configuration list for PBXProject "Example" */;
121 | compatibilityVersion = "Xcode 14.0";
122 | developmentRegion = en;
123 | hasScannedForEncodings = 0;
124 | knownRegions = (
125 | en,
126 | Base,
127 | );
128 | mainGroup = 4230D4CF2B84DF0800AC3F6B;
129 | productRefGroup = 4230D4D92B84DF0800AC3F6B /* Products */;
130 | projectDirPath = "";
131 | projectRoot = "";
132 | targets = (
133 | 4230D4D72B84DF0800AC3F6B /* Example */,
134 | );
135 | };
136 | /* End PBXProject section */
137 |
138 | /* Begin PBXResourcesBuildPhase section */
139 | 4230D4D62B84DF0800AC3F6B /* Resources */ = {
140 | isa = PBXResourcesBuildPhase;
141 | buildActionMask = 2147483647;
142 | files = (
143 | 4230D4E32B84DF0B00AC3F6B /* Preview Assets.xcassets in Resources */,
144 | 4230D4E02B84DF0B00AC3F6B /* Assets.xcassets in Resources */,
145 | );
146 | runOnlyForDeploymentPostprocessing = 0;
147 | };
148 | /* End PBXResourcesBuildPhase section */
149 |
150 | /* Begin PBXSourcesBuildPhase section */
151 | 4230D4D42B84DF0800AC3F6B /* Sources */ = {
152 | isa = PBXSourcesBuildPhase;
153 | buildActionMask = 2147483647;
154 | files = (
155 | 4230D4DE2B84DF0800AC3F6B /* ContentView.swift in Sources */,
156 | 4230D4DC2B84DF0800AC3F6B /* ExampleApp.swift in Sources */,
157 | );
158 | runOnlyForDeploymentPostprocessing = 0;
159 | };
160 | /* End PBXSourcesBuildPhase section */
161 |
162 | /* Begin XCBuildConfiguration section */
163 | 4230D4E42B84DF0B00AC3F6B /* Debug */ = {
164 | isa = XCBuildConfiguration;
165 | buildSettings = {
166 | ALWAYS_SEARCH_USER_PATHS = NO;
167 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
168 | CLANG_ANALYZER_NONNULL = YES;
169 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
170 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
171 | CLANG_ENABLE_MODULES = YES;
172 | CLANG_ENABLE_OBJC_ARC = YES;
173 | CLANG_ENABLE_OBJC_WEAK = YES;
174 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
175 | CLANG_WARN_BOOL_CONVERSION = YES;
176 | CLANG_WARN_COMMA = YES;
177 | CLANG_WARN_CONSTANT_CONVERSION = YES;
178 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
179 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
180 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
181 | CLANG_WARN_EMPTY_BODY = YES;
182 | CLANG_WARN_ENUM_CONVERSION = YES;
183 | CLANG_WARN_INFINITE_RECURSION = YES;
184 | CLANG_WARN_INT_CONVERSION = YES;
185 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
186 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
187 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
188 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
189 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
190 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
191 | CLANG_WARN_STRICT_PROTOTYPES = YES;
192 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
193 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
194 | CLANG_WARN_UNREACHABLE_CODE = YES;
195 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
196 | COPY_PHASE_STRIP = NO;
197 | DEBUG_INFORMATION_FORMAT = dwarf;
198 | ENABLE_STRICT_OBJC_MSGSEND = YES;
199 | ENABLE_TESTABILITY = YES;
200 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
201 | GCC_C_LANGUAGE_STANDARD = gnu17;
202 | GCC_DYNAMIC_NO_PIC = NO;
203 | GCC_NO_COMMON_BLOCKS = YES;
204 | GCC_OPTIMIZATION_LEVEL = 0;
205 | GCC_PREPROCESSOR_DEFINITIONS = (
206 | "DEBUG=1",
207 | "$(inherited)",
208 | );
209 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
210 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
211 | GCC_WARN_UNDECLARED_SELECTOR = YES;
212 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
213 | GCC_WARN_UNUSED_FUNCTION = YES;
214 | GCC_WARN_UNUSED_VARIABLE = YES;
215 | IPHONEOS_DEPLOYMENT_TARGET = 17.2;
216 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
217 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
218 | MTL_FAST_MATH = YES;
219 | ONLY_ACTIVE_ARCH = YES;
220 | SDKROOT = iphoneos;
221 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
222 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
223 | };
224 | name = Debug;
225 | };
226 | 4230D4E52B84DF0B00AC3F6B /* Release */ = {
227 | isa = XCBuildConfiguration;
228 | buildSettings = {
229 | ALWAYS_SEARCH_USER_PATHS = NO;
230 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
231 | CLANG_ANALYZER_NONNULL = YES;
232 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
233 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
234 | CLANG_ENABLE_MODULES = YES;
235 | CLANG_ENABLE_OBJC_ARC = YES;
236 | CLANG_ENABLE_OBJC_WEAK = YES;
237 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
238 | CLANG_WARN_BOOL_CONVERSION = YES;
239 | CLANG_WARN_COMMA = YES;
240 | CLANG_WARN_CONSTANT_CONVERSION = YES;
241 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
242 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
243 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
244 | CLANG_WARN_EMPTY_BODY = YES;
245 | CLANG_WARN_ENUM_CONVERSION = YES;
246 | CLANG_WARN_INFINITE_RECURSION = YES;
247 | CLANG_WARN_INT_CONVERSION = YES;
248 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
249 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
250 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
251 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
252 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
253 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
254 | CLANG_WARN_STRICT_PROTOTYPES = YES;
255 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
256 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
257 | CLANG_WARN_UNREACHABLE_CODE = YES;
258 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
259 | COPY_PHASE_STRIP = NO;
260 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
261 | ENABLE_NS_ASSERTIONS = NO;
262 | ENABLE_STRICT_OBJC_MSGSEND = YES;
263 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
264 | GCC_C_LANGUAGE_STANDARD = gnu17;
265 | GCC_NO_COMMON_BLOCKS = YES;
266 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
267 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
268 | GCC_WARN_UNDECLARED_SELECTOR = YES;
269 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
270 | GCC_WARN_UNUSED_FUNCTION = YES;
271 | GCC_WARN_UNUSED_VARIABLE = YES;
272 | IPHONEOS_DEPLOYMENT_TARGET = 17.2;
273 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
274 | MTL_ENABLE_DEBUG_INFO = NO;
275 | MTL_FAST_MATH = YES;
276 | SDKROOT = iphoneos;
277 | SWIFT_COMPILATION_MODE = wholemodule;
278 | VALIDATE_PRODUCT = YES;
279 | };
280 | name = Release;
281 | };
282 | 4230D4E72B84DF0B00AC3F6B /* Debug */ = {
283 | isa = XCBuildConfiguration;
284 | buildSettings = {
285 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
286 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
287 | CODE_SIGN_STYLE = Automatic;
288 | CURRENT_PROJECT_VERSION = 1;
289 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\"";
290 | DEVELOPMENT_TEAM = G8RH83B4LT;
291 | ENABLE_PREVIEWS = YES;
292 | GENERATE_INFOPLIST_FILE = YES;
293 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
294 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
295 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
296 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
298 | LD_RUNPATH_SEARCH_PATHS = (
299 | "$(inherited)",
300 | "@executable_path/Frameworks",
301 | );
302 | MARKETING_VERSION = 1.0;
303 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.Example;
304 | PRODUCT_NAME = "$(TARGET_NAME)";
305 | SWIFT_EMIT_LOC_STRINGS = YES;
306 | SWIFT_VERSION = 5.0;
307 | TARGETED_DEVICE_FAMILY = "1,2";
308 | };
309 | name = Debug;
310 | };
311 | 4230D4E82B84DF0B00AC3F6B /* Release */ = {
312 | isa = XCBuildConfiguration;
313 | buildSettings = {
314 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
315 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
316 | CODE_SIGN_STYLE = Automatic;
317 | CURRENT_PROJECT_VERSION = 1;
318 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\"";
319 | DEVELOPMENT_TEAM = G8RH83B4LT;
320 | ENABLE_PREVIEWS = YES;
321 | GENERATE_INFOPLIST_FILE = YES;
322 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
323 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
324 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
325 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
326 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
327 | LD_RUNPATH_SEARCH_PATHS = (
328 | "$(inherited)",
329 | "@executable_path/Frameworks",
330 | );
331 | MARKETING_VERSION = 1.0;
332 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.Example;
333 | PRODUCT_NAME = "$(TARGET_NAME)";
334 | SWIFT_EMIT_LOC_STRINGS = YES;
335 | SWIFT_VERSION = 5.0;
336 | TARGETED_DEVICE_FAMILY = "1,2";
337 | };
338 | name = Release;
339 | };
340 | /* End XCBuildConfiguration section */
341 |
342 | /* Begin XCConfigurationList section */
343 | 4230D4D32B84DF0800AC3F6B /* Build configuration list for PBXProject "Example" */ = {
344 | isa = XCConfigurationList;
345 | buildConfigurations = (
346 | 4230D4E42B84DF0B00AC3F6B /* Debug */,
347 | 4230D4E52B84DF0B00AC3F6B /* Release */,
348 | );
349 | defaultConfigurationIsVisible = 0;
350 | defaultConfigurationName = Release;
351 | };
352 | 4230D4E62B84DF0B00AC3F6B /* Build configuration list for PBXNativeTarget "Example" */ = {
353 | isa = XCConfigurationList;
354 | buildConfigurations = (
355 | 4230D4E72B84DF0B00AC3F6B /* Debug */,
356 | 4230D4E82B84DF0B00AC3F6B /* Release */,
357 | );
358 | defaultConfigurationIsVisible = 0;
359 | defaultConfigurationName = Release;
360 | };
361 | /* End XCConfigurationList section */
362 |
363 | /* Begin XCSwiftPackageProductDependency section */
364 | 4230D4EB2B84DF4D00AC3F6B /* LocalNotificationEditor */ = {
365 | isa = XCSwiftPackageProductDependency;
366 | productName = LocalNotificationEditor;
367 | };
368 | /* End XCSwiftPackageProductDependency section */
369 | };
370 | rootObject = 4230D4D02B84DF0800AC3F6B /* Project object */;
371 | }
372 |
--------------------------------------------------------------------------------
/Sources/LocalNotificationEditor/Internal/LocalNotificationEditor.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UserNotifications
3 | import CoreLocation
4 |
5 | struct LocalNotificationEditor: View {
6 | @Environment(\.dismiss) private var dismiss
7 |
8 | @State var identifier: String
9 | @State var title: String
10 | @State var subtitle: String
11 | @State var bodyString: String
12 | @State var attachments: [(identifier: String, url: URL)]
13 | @State var badge: Int?
14 | @State var categoryIdentifier: String
15 | @State var filterCriteria: String?
16 | @State var interruptionLevel: UInt
17 | @State var launchImageName: String
18 | @State var relevanceScore: Double
19 | @State var targetContentIdentifier: String?
20 | @State var threadIdentifier: String
21 | @State var userInfoString: String = ""
22 | @State var triggerType: TriggerType?
23 |
24 | var userInfo: [AnyHashable: Any] {
25 | guard let data = userInfoString.data(using: .utf8),
26 | let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
27 | else {
28 | return [:]
29 | }
30 | return value.compactMapKeys { AnyHashable($0) }
31 | }
32 |
33 | var isSaveButtonEnabled: Bool {
34 | !identifier.isEmpty && (!title.isEmpty || !subtitle.isEmpty || !bodyString.isEmpty)
35 | }
36 |
37 | let userNotificationCenter: any UNUserNotificationCenterProtocol
38 | let mode: Mode
39 | let onSave: () -> Void
40 |
41 | enum Mode {
42 | case add
43 | case edit(UNNotificationRequest)
44 | }
45 |
46 | enum TriggerType {
47 | case timeInterval(UNTimeIntervalNotificationTrigger)
48 | case calendar(UNCalendarNotificationTrigger)
49 | case location(UNLocationNotificationTrigger)
50 |
51 | var trigger: UNNotificationTrigger {
52 | switch self {
53 | case .timeInterval(let unTimeIntervalNotificationTrigger):
54 | unTimeIntervalNotificationTrigger
55 | case .calendar(let unCalendarNotificationTrigger):
56 | unCalendarNotificationTrigger
57 | case .location(let location):
58 | location
59 | }
60 | }
61 | }
62 |
63 | init(
64 | userNotificationCenter: some UNUserNotificationCenterProtocol,
65 | mode: Mode,
66 | onSave: @escaping () -> Void
67 | ) {
68 | self.userNotificationCenter = userNotificationCenter
69 | self.mode = mode
70 | self.onSave = onSave
71 |
72 | switch mode {
73 | case .add:
74 | let content = UNNotificationContent()
75 | _identifier = State(initialValue: "")
76 | _title = State(initialValue: content.title)
77 | _subtitle = State(initialValue: content.subtitle)
78 | _bodyString = State(initialValue: content.body)
79 | _attachments = State(initialValue: content.attachments.map { ($0.identifier, $0.url) })
80 | _badge = State(initialValue: content.badge?.intValue)
81 | _categoryIdentifier = State(initialValue: content.categoryIdentifier)
82 | _interruptionLevel = State(initialValue: content.interruptionLevel.rawValue)
83 | _launchImageName = State(initialValue: content.launchImageName)
84 | _relevanceScore = State(initialValue: content.relevanceScore)
85 | _targetContentIdentifier = State(initialValue: content.targetContentIdentifier)
86 | _threadIdentifier = State(initialValue: content.threadIdentifier)
87 | _userInfoString = State(initialValue: "{}")
88 | if #available(iOS 16.0, *) {
89 | _filterCriteria = State(initialValue: content.filterCriteria)
90 | }
91 | var dateComponents = Calendar.current.dateComponents(
92 | in: .autoupdatingCurrent,
93 | from: Date()
94 | )
95 | dateComponents.hour = (dateComponents.hour ?? 0) + 1
96 | _triggerType = State(initialValue: .timeInterval(.init(timeInterval: 60, repeats: true)))
97 | case .edit(let request):
98 | _identifier = State(initialValue: request.identifier)
99 | _title = State(initialValue: request.content.title)
100 | _subtitle = State(initialValue: request.content.subtitle)
101 | _bodyString = State(initialValue: request.content.body)
102 | _attachments = State(initialValue: request.content.attachments.map { ($0.identifier, $0.url) })
103 | _badge = State(initialValue: request.content.badge?.intValue)
104 | _categoryIdentifier = State(initialValue: request.content.categoryIdentifier)
105 | _interruptionLevel = State(initialValue: request.content.interruptionLevel.rawValue)
106 | _launchImageName = State(initialValue: request.content.launchImageName)
107 | _relevanceScore = State(initialValue: request.content.relevanceScore)
108 | _targetContentIdentifier = State(initialValue: request.content.targetContentIdentifier)
109 | _threadIdentifier = State(initialValue: request.content.threadIdentifier)
110 | _userInfoString = State(
111 | initialValue: request.content.userInfo
112 | .compactMapKeys { $0.base as? String }
113 | .json()
114 | )
115 | if #available(iOS 16.0, *) {
116 | _filterCriteria = State(initialValue: request.content.filterCriteria)
117 | }
118 | _triggerType = State(initialValue: {
119 | if let trigger = request.trigger as? UNCalendarNotificationTrigger {
120 | return .calendar(trigger)
121 | } else if let trigger = request.trigger as? UNTimeIntervalNotificationTrigger {
122 | return .timeInterval(trigger)
123 | } else if let trigger = request.trigger as? UNLocationNotificationTrigger {
124 | return .location(trigger)
125 | } else {
126 | return nil
127 | }
128 | }())
129 | }
130 | }
131 |
132 | var body: some View {
133 | NavigationView {
134 | List {
135 | Section("trigger") {
136 | trigger
137 | }
138 | Section("identifier") {
139 | TextField("identifier", text: $identifier)
140 | }
141 | Section("title") {
142 | TextField("title", text: $title)
143 | }
144 | Section("subtitle") {
145 | TextField("subtitle", text: $subtitle)
146 | }
147 | Section("body") {
148 | TextField("body", text: $bodyString)
149 | }
150 | Section("user info") {
151 | TextEditor(text: $userInfoString)
152 | }
153 | Section("badge") {
154 | TextField(
155 | "badge",
156 | text: .init(
157 | get: { badge?.description ?? "" },
158 | set: {
159 | if let newValue = Int($0) {
160 | self.badge = newValue
161 | }
162 | }
163 | )
164 | )
165 | }
166 | Section("category identifier") {
167 | TextField("categoryIdentifier", text: $categoryIdentifier)
168 | }
169 | Section("filter criteria") {
170 | TextField(
171 | "filterCriteria",
172 | text: .init(
173 | get: { filterCriteria ?? "" },
174 | set: { filterCriteria = $0 }
175 | )
176 | )
177 | }
178 | Section("interruption level") {
179 | TextField(
180 | "interruptionLevel",
181 | text: .init(
182 | get: { interruptionLevel.description },
183 | set: {
184 | if let newValue = Int($0) {
185 | interruptionLevel = UInt(newValue)
186 | }
187 | }
188 | )
189 | )
190 | }
191 | Section("launch image name") {
192 | TextField("launch image name", text: $launchImageName)
193 | }
194 | Section("relevance score") {
195 | TextField(
196 | "relevance score",
197 | text: .init(
198 | get: { relevanceScore.description },
199 | set: {
200 | if let newValue = Double($0) {
201 | relevanceScore = newValue
202 | }
203 | }
204 | )
205 | )
206 | }
207 | Section("target content identifier") {
208 | TextField(
209 | "targetContentIdentifier",
210 | text: .init(
211 | get: { targetContentIdentifier ?? "" },
212 | set: { targetContentIdentifier = $0 }
213 | )
214 | )
215 | }
216 | Section("thread identifier") {
217 | TextField("threadIdentifier", text: $threadIdentifier)
218 | }
219 | if !attachments.isEmpty {
220 | Section("Attachments") {
221 | ForEach(attachments, id: \.identifier) { attachment in
222 | HStack {
223 | Text("identifier")
224 | Spacer()
225 | Text(attachment.identifier)
226 | }
227 | HStack {
228 | Text("url")
229 | Spacer()
230 | Text(attachment.url.absoluteString)
231 | }
232 | }
233 | }
234 | }
235 | }
236 | .toolbar {
237 | ToolbarItem(placement: .topBarTrailing) {
238 | Button("Save") {
239 | Task { try await saveButtonTapped() }
240 | }
241 | .disabled(!isSaveButtonEnabled)
242 | }
243 | }
244 | }
245 | }
246 |
247 | @ViewBuilder
248 | private var trigger: some View {
249 | switch triggerType {
250 | case .timeInterval(let trigger):
251 | TextField(
252 | "timeInterval",
253 | text: .init(
254 | get: { trigger.timeInterval.description },
255 | set: {
256 | if let newValue = Double($0) {
257 | triggerType = .timeInterval(
258 | UNTimeIntervalNotificationTrigger(
259 | timeInterval: trigger.repeats ? max(60, newValue) : newValue,
260 | repeats: trigger.repeats
261 | )
262 | )
263 | }
264 | }
265 | )
266 | )
267 | Toggle(
268 | "repeats",
269 | isOn: .init(
270 | get: {
271 | trigger.repeats
272 | },
273 | set: {
274 | triggerType = .timeInterval(
275 | UNTimeIntervalNotificationTrigger(
276 | timeInterval: $0 ? max(60, trigger.timeInterval) : trigger.timeInterval,
277 | repeats: $0
278 | )
279 | )
280 | }
281 | )
282 | )
283 | case .calendar(let trigger):
284 | DatePicker(
285 | selection: .init(
286 | get: {
287 | trigger.dateComponents.date ?? Date()
288 | },
289 | set: {
290 | let dateComponents = Calendar.current.dateComponents(
291 | in: .autoupdatingCurrent,
292 | from: $0
293 | )
294 | triggerType = .calendar(
295 | UNCalendarNotificationTrigger(
296 | dateMatching: dateComponents,
297 | repeats: trigger.repeats
298 | )
299 | )
300 | }
301 | ),
302 | displayedComponents: [.date, .hourAndMinute]
303 | ) {
304 | Text("Date")
305 | }
306 | Toggle(
307 | "repeats",
308 | isOn: .init(
309 | get: {
310 | trigger.repeats
311 | },
312 | set: {
313 | triggerType = .calendar(
314 | UNCalendarNotificationTrigger(
315 | dateMatching: trigger.dateComponents,
316 | repeats: $0
317 | )
318 | )
319 | }
320 | )
321 | )
322 |
323 | case .location(let trigger):
324 | Toggle(
325 | "repeats",
326 | isOn: .init(
327 | get: {
328 | trigger.repeats
329 | },
330 | set: {
331 | triggerType = .location(
332 | UNLocationNotificationTrigger(
333 | region: trigger.region,
334 | repeats: $0
335 | )
336 | )
337 | }
338 | )
339 | )
340 | case nil:
341 | EmptyView()
342 | }
343 | }
344 |
345 | private func saveButtonTapped() async throws {
346 | let request = UNNotificationRequest(
347 | identifier: identifier,
348 | content: createNotificationContent(),
349 | trigger: triggerType?.trigger
350 | )
351 | switch mode {
352 | case .add:
353 | try await userNotificationCenter.add(request)
354 | case .edit:
355 | userNotificationCenter.removePendingNotificationRequests(withIdentifiers: [request.identifier])
356 | try await userNotificationCenter.add(request)
357 | }
358 | dismiss()
359 | onSave()
360 | }
361 |
362 | private func createNotificationContent() -> UNNotificationContent {
363 | let content = UNMutableNotificationContent()
364 | content.title = title
365 | content.body = bodyString
366 | content.attachments = attachments.compactMap {
367 | try? UNNotificationAttachment(
368 | identifier: $0.identifier,
369 | url: $0.url
370 | )
371 | }
372 | if let badge {
373 | content.badge = NSNumber(integerLiteral: badge)
374 | }
375 | content.categoryIdentifier = categoryIdentifier
376 | if let level = UNNotificationInterruptionLevel(rawValue: interruptionLevel) {
377 | content.interruptionLevel = level
378 | }
379 | content.launchImageName = launchImageName
380 | content.relevanceScore = relevanceScore
381 | content.subtitle = subtitle
382 | content.targetContentIdentifier = targetContentIdentifier
383 | content.threadIdentifier = threadIdentifier
384 | content.userInfo = userInfo
385 | if #available(iOS 16.0, *) {
386 | content.filterCriteria = filterCriteria
387 | }
388 | return content
389 | }
390 | }
391 |
392 | #Preview {
393 | LocalNotificationEditor(
394 | userNotificationCenter: UNUserNotificationCenter.current(),
395 | mode: .add
396 | ) {}
397 | }
398 |
--------------------------------------------------------------------------------