├── 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 | --------------------------------------------------------------------------------