├── .gitignore ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources └── SwiftfulUtilities │ ├── ATT │ └── AppTrackingTransparencyHelper.swift │ ├── EventKit │ └── EventKitHelper.swift │ ├── Extensions │ ├── BatteryState+EXT.swift │ ├── MeasurementSystem+EXT.swift │ ├── ThermalState+EXT.swift │ └── UIDeviceOrientation+EXT.swift │ ├── LocalNotifications │ ├── AnyNotificationContent.swift │ ├── LocalNotifications.swift │ └── NotificationTriggerOption.swift │ ├── Ratings │ └── AppStoreRatingsHelper.swift │ └── Utilities │ ├── Utilities+EventParameters.swift │ └── Utilities.swift └── Tests └── SwiftfulUtilitiesTests └── SwiftfulUtilitiesTests.swift /.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 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Swiftful Thinking, LLC 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: "SwiftfulUtilities", 8 | platforms: [ 9 | .iOS(.v16), 10 | .macOS(.v12) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "SwiftfulUtilities", 16 | targets: ["SwiftfulUtilities"]), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "SwiftfulUtilities"), 23 | .testTarget( 24 | name: "SwiftfulUtilitiesTests", 25 | dependencies: ["SwiftfulUtilities"] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utilities for Swift projects 🦾 2 | 3 | A generic Utilities implementation to access values from `Bundle.main`, `UIDevice.current`, `UIScreen.main`, `ProcessInfo.processInfo` and `Locale.current`. 4 | 5 | #### Import the file: 6 | 7 | ```swift 8 | import SwiftfulUtilities 9 | 10 | typealias Utilities = SwiftfulUtilities.Utilities 11 | ``` 12 | 13 | #### Access values: 14 | 15 | ```swift 16 | let appVersion = Utilities.appVersion 17 | let isPortrait = Utilities.isPortrait 18 | let isDevUser = Utilities.isDevUser 19 | let identifierForVendor = Utilities.identifierForVendor 20 | // ...and many more! 21 | ``` 22 | 23 | View all values: https://github.com/SwiftfulThinking/SwiftfulUtilities/blob/main/Sources/SwiftfulUtilities/Utilities/Utilities.swift 24 | 25 | #### Bulk export: 26 | 27 | ```swift 28 | let dict = Utilities.eventParameters 29 | print(dict) 30 | ``` 31 | 32 | #### ATT Prompt: 33 | 34 | ```swift 35 | let status = await AppTrackingTransparencyHelper.requestTrackingAuthorization() 36 | let dict = status.eventParameters 37 | ``` 38 | 39 | #### Local Push Notifications: 40 | 41 | ```swift 42 | // Check if can request push authorization 43 | await LocalNotifications.canRequestAuthorization() 44 | 45 | // Request push authorization 46 | let isAuthorized = try await LocalNotifications.requestAuthorization() 47 | 48 | // Schedule push notification 49 | try await LocalNotifications.scheduleNotification(content: content, trigger: trigger) 50 | 51 | // Customize notification content 52 | let content = AnyNotificationContent(id: String, title: String, body: String?, sound: Bool, badge: Int?) 53 | 54 | // Customize trigger option by date, time, or location 55 | let trigger = NotificationTriggerOption.date(date: date, repeats: false) 56 | let trigger = NotificationTriggerOption.time(timeInterval: timeInterval, repeats: false) 57 | let trigger = NotificationTriggerOption.location(coordinates: coordinates, radius: radius, notifyOnEntry: true, notifyOnExit: false, repeats: false) 58 | 59 | // Cancel outstanding push notifications 60 | LocalNotifications.removeAllPendingNotifications() 61 | LocalNotifications.removeAllDeliveredNotifications() 62 | LocalNotifications.removeNotifications(ids: [String]) 63 | ``` 64 | 65 | #### Calendar Events: 66 | 67 | Add info.plist values: 68 | 69 | `Privacy - Calendars Usage Description` : `We request access to the calendar to manage app events.` 70 | 71 | ```swift 72 | // Check if can request calendar access 73 | await EventKitHelper.getCalendarAccessStatus() 74 | 75 | // Request calendar authorization 76 | let isAuthorized = try await EventKitHelper.requestAccessToCalendar() 77 | 78 | // Add calendar events 79 | let eventId = try await EventKitHelper.addEventToCalendar(event) 80 | try await EventKitHelper.modifyEventInCalendar(eventId: eventId, newTitle: "") 81 | try await EventKitHelper.removeEventFromCalendar(eventId: eventId) 82 | ``` 83 | 84 | #### Reminders: 85 | 86 | Add info.plist values: 87 | 88 | `Privacy - Reminders Usage Description` : `We request access to the reminders to manage reminders.` 89 | 90 | ```swift 91 | // Check if can request reminders access 92 | await EventKitHelper.getRemindersAccessStatus() 93 | 94 | // Request calendar authorization 95 | let isAuthorized = try await EventKitHelper.requestAccessToReminders() 96 | 97 | // Add calendar events 98 | let reminderId = try await EventKitHelper.addReminder(reminder) 99 | try await EventKitHelper.modifyReminder(reminderId: reminderId, newTitle: "") 100 | try await EventKitHelper.removeReminder(reminderId: reminderId) 101 | ``` 102 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/ATT/AppTrackingTransparencyHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // asdf.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/20/24. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import AppTrackingTransparency 10 | 11 | public final class AppTrackingTransparencyHelper { 12 | 13 | public static func requestTrackingAuthorization() async -> ATTrackingManager.AuthorizationStatus { 14 | await ATTrackingManager.requestTrackingAuthorization() 15 | } 16 | 17 | } 18 | 19 | public extension ATTrackingManager.AuthorizationStatus { 20 | 21 | var eventParameters: [String: Any] { 22 | [ 23 | "att_status": stringValue, 24 | "att_status_code": rawValue 25 | ] 26 | } 27 | 28 | var stringValue: String { 29 | switch self { 30 | case .notDetermined: 31 | return "not_determined" 32 | case .restricted: 33 | return "restricted" 34 | case .denied: 35 | return "denied" 36 | case .authorized: 37 | return "authorized" 38 | default: 39 | return "unknown" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/EventKit/EventKitHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventKitHelper.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 1/11/25. 6 | // 7 | import Foundation 8 | import EventKit 9 | 10 | public final class EventKitHelper { 11 | 12 | @MainActor 13 | private static let eventStore = EKEventStore() 14 | 15 | // MARK: EVENTS 16 | 17 | /// Returns the current authorization status for accessing the user's calendar. 18 | /// 19 | /// - Returns: The current authorization status (`EKAuthorizationStatus`) for calendar events. 20 | @MainActor 21 | public static func getCalendarAccessStatus() -> EKAuthorizationStatus { 22 | EKEventStore.authorizationStatus(for: .event) 23 | } 24 | 25 | /// Requests access to the user's calendar. 26 | /// 27 | /// - Returns: A Boolean value indicating whether access to the calendar was granted. 28 | /// - Throws: An error if the access request fails. 29 | @MainActor 30 | public static func requestAccessToCalendar() async throws -> Bool { 31 | if #available(iOS 17, *) { 32 | return try await eventStore.requestFullAccessToEvents() 33 | } else { 34 | return try await eventStore.requestAccess(to: .event) 35 | } 36 | } 37 | 38 | /// Adds a new event to the user's calendar. 39 | /// 40 | /// - Parameters: 41 | /// - title: The title of the event. 42 | /// - description: An optional description of the event. 43 | /// - url: An optional URL associated with the event. 44 | /// - startDate: The start date and time of the event. 45 | /// - eventDuration: The duration of the event, specified using `EventDurationOption`. 46 | /// - alarms: An optional array of alarms to trigger before the event, specified using `EventAlermOption`. 47 | /// - recurring: The recurrence rule for the event, specified using `EventRecurrenceRule`. Defaults to `.never`. 48 | /// - **Example for "weekly" events**: Use `.weekly(weeks: 6, interval: 1)` to create events that occur every week for a total of 6 occurrences. 49 | /// - **Example for "bi-weekly" events**: Use `.weekly(weeks: 6, interval: 2)` to create events that occur every two weeks for a total of 6 occurrences. 50 | /// - Returns: The unique identifier for the event created in the calendar. Use this if you need to cancel or modify the event later. 51 | /// - Throws: An error if the event cannot be created or saved to the calendar. 52 | @MainActor 53 | public static func addEventToCalendar( 54 | title: String, 55 | description: String? = nil, 56 | url: URL? = nil, 57 | startDate: Date, 58 | eventDuration: EventDurationOption, 59 | alarms: [EventAlermOption]? = nil, 60 | recurring: EventRecurrenceRule = .never 61 | ) throws -> String { 62 | guard Thread.isMainThread else { 63 | throw EventKitError.notOnMainActor 64 | } 65 | 66 | let event = EKEvent(eventStore: eventStore) 67 | event.calendar = eventStore.defaultCalendarForNewEvents 68 | event.title = title 69 | event.notes = description 70 | event.url = url 71 | event.startDate = startDate 72 | 73 | switch eventDuration { 74 | case .hours(let count): 75 | event.endDate = Calendar.current.date(byAdding: .hour, value: count, to: startDate) 76 | case .minutes(let count): 77 | event.endDate = Calendar.current.date(byAdding: .minute, value: count, to: startDate) 78 | case .days(let count): 79 | event.endDate = Calendar.current.date(byAdding: .day, value: count, to: startDate) 80 | case .allDay: 81 | event.isAllDay = true 82 | event.endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) 83 | } 84 | 85 | if let alarms = alarms, !alarms.isEmpty { 86 | event.alarms = alarms.map { alarmOption in 87 | switch alarmOption { 88 | case .minutesBeforeEvent(let count): 89 | return EKAlarm(relativeOffset: TimeInterval(-count * 60)) 90 | case .hoursBeforeEvent(let count): 91 | return EKAlarm(relativeOffset: TimeInterval(-count * 60 * 60)) 92 | case .daysBeforeEvent(let count): 93 | return EKAlarm(relativeOffset: TimeInterval(-count * 24 * 60 * 60)) 94 | } 95 | } 96 | } 97 | 98 | switch recurring { 99 | case .never: 100 | break 101 | case .daily(let occurrenceCount, let interval): 102 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .daily, interval: interval, end: EKRecurrenceEnd(occurrenceCount: occurrenceCount)) 103 | event.addRecurrenceRule(recurrenceRule) 104 | case .weekly(let occurrenceCount, let interval): 105 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .weekly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: occurrenceCount)) 106 | event.addRecurrenceRule(recurrenceRule) 107 | case .monthly(let occurrenceCount, let interval): 108 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .monthly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: occurrenceCount)) 109 | event.addRecurrenceRule(recurrenceRule) 110 | case .yearly(let occurrenceCount, let interval): 111 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .yearly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: occurrenceCount)) 112 | event.addRecurrenceRule(recurrenceRule) 113 | } 114 | 115 | try eventStore.save(event, span: .futureEvents, commit: true) 116 | return event.eventIdentifier 117 | } 118 | 119 | /// Modifies an existing event in the user's calendar. 120 | /// 121 | /// - Parameters: 122 | /// - eventId: The unique identifier of the event to modify. 123 | /// - newTitle: An optional new title for the event. If `nil`, the title remains unchanged. 124 | /// - newDescription: An optional new description for the event. If `nil`, the description remains unchanged. 125 | /// - newUrl: An optional new URL associated with the event. If `nil`, the URL remains unchanged. 126 | /// - newStartDate: An optional new start date for the event. If `nil`, the start date remains unchanged. 127 | /// - newEventDuration: An optional new duration for the event, specified using `EventDurationOption`. If `nil`, the duration remains unchanged. 128 | /// - newAlarms: An optional array of new alarms to set for the event, specified using `EventAlermOption`. If `nil`, alarms remain unchanged. 129 | /// - newRecurring: An optional new recurrence rule for the event, specified using `EventRecurrenceRule`. If `nil`, the recurrence rules remain unchanged. 130 | /// - **Example for "weekly recurrence"**: Use `.weekly(weeks: 6, interval: 1)` for an event that recurs weekly for 6 occurrences. 131 | /// - **Example for "bi-weekly recurrence"**: Use `.weekly(weeks: 6, interval: 2)` for an event that recurs every two weeks for 6 occurrences. 132 | /// - Throws: An error if the event is not found or cannot be modified. 133 | @MainActor 134 | public static func modifyEventInCalendar( 135 | eventId: String, 136 | newTitle: String? = nil, 137 | newDescription: String? = nil, 138 | newUrl: URL? = nil, 139 | newStartDate: Date? = nil, 140 | newEventDuration: EventDurationOption? = nil, 141 | newAlarms: [EventAlermOption]? = nil, 142 | newRecurring: EventRecurrenceRule? = nil 143 | ) throws { 144 | // Fetch the existing event using its identifier 145 | guard let event = eventStore.event(withIdentifier: eventId) else { 146 | throw EventKitError.eventNotFound 147 | } 148 | 149 | // Modify event properties if new values are provided 150 | if let newTitle = newTitle { 151 | event.title = newTitle 152 | } 153 | if let newDescription = newDescription { 154 | event.notes = newDescription 155 | } 156 | if let newUrl = newUrl { 157 | event.url = newUrl 158 | } 159 | if let newStartDate = newStartDate { 160 | event.startDate = newStartDate 161 | 162 | // Update the end date if new duration is provided 163 | if let newEventDuration = newEventDuration { 164 | switch newEventDuration { 165 | case .hours(let count): 166 | event.endDate = Calendar.current.date(byAdding: .hour, value: count, to: newStartDate) 167 | case .minutes(let count): 168 | event.endDate = Calendar.current.date(byAdding: .minute, value: count, to: newStartDate) 169 | case .days(let count): 170 | event.endDate = Calendar.current.date(byAdding: .day, value: count, to: newStartDate) 171 | case .allDay: 172 | event.isAllDay = true 173 | event.endDate = Calendar.current.date(byAdding: .day, value: 1, to: newStartDate) 174 | } 175 | } 176 | } 177 | 178 | // Update alarms if provided 179 | if let newAlarms = newAlarms { 180 | event.alarms = newAlarms.map { alarmOption in 181 | switch alarmOption { 182 | case .minutesBeforeEvent(let count): 183 | return EKAlarm(relativeOffset: TimeInterval(-count * 60)) 184 | case .hoursBeforeEvent(let count): 185 | return EKAlarm(relativeOffset: TimeInterval(-count * 60 * 60)) 186 | case .daysBeforeEvent(let count): 187 | return EKAlarm(relativeOffset: TimeInterval(-count * 24 * 60 * 60)) 188 | } 189 | } 190 | } 191 | 192 | // Update recurrence rule if provided 193 | if let newRecurring = newRecurring { 194 | event.recurrenceRules = nil // Remove existing recurrence rules 195 | switch newRecurring { 196 | case .never: 197 | break 198 | case .daily(let days, let interval): 199 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .daily, interval: interval, end: EKRecurrenceEnd(occurrenceCount: days)) 200 | event.addRecurrenceRule(recurrenceRule) 201 | case .weekly(let weeks, let interval): 202 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .weekly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: weeks)) 203 | event.addRecurrenceRule(recurrenceRule) 204 | case .monthly(let months, let interval): 205 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .monthly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: months)) 206 | event.addRecurrenceRule(recurrenceRule) 207 | case .yearly(let years, let interval): 208 | let recurrenceRule = EKRecurrenceRule(recurrenceWith: .yearly, interval: interval, end: EKRecurrenceEnd(occurrenceCount: years)) 209 | event.addRecurrenceRule(recurrenceRule) 210 | } 211 | } 212 | 213 | try eventStore.save(event, span: .futureEvents, commit: true) 214 | } 215 | 216 | /// Removes an event from the user's calendar using its unique identifier. 217 | /// 218 | /// - Parameter eventId: The unique identifier of the event to remove. 219 | /// - Throws: An error if the event cannot be found or removed. 220 | @MainActor 221 | public static func removeEventFromCalendar(eventId: String) throws { 222 | guard let event = eventStore.event(withIdentifier: eventId) else { 223 | throw EventKitError.eventNotFound 224 | } 225 | 226 | try eventStore.remove(event, span: .futureEvents, commit: true) 227 | } 228 | 229 | public enum EventKitError: Error { 230 | case notOnMainActor, eventNotFound 231 | } 232 | 233 | public enum EventDurationOption { 234 | case hours(_ count: Int) 235 | case minutes(_ count: Int) 236 | case days(_ count: Int) 237 | case allDay 238 | } 239 | 240 | public enum EventAlermOption { 241 | case minutesBeforeEvent(_ count: Int) 242 | case hoursBeforeEvent(_ count: Int) 243 | case daysBeforeEvent(_ count: Int) 244 | } 245 | 246 | public enum EventRecurrenceRule { 247 | case never 248 | case daily(occurrenceCount: Int, interval: Int = 1) 249 | case weekly(occurrenceCount: Int, interval: Int = 1) 250 | case monthly(occurrenceCount: Int, interval: Int = 1) 251 | case yearly(occurrenceCount: Int, interval: Int = 1) 252 | } 253 | 254 | // MARK: REMINDERS 255 | 256 | /// Returns the current authorization status for accessing the user's reminders. 257 | /// 258 | /// - Returns: The current authorization status (`EKAuthorizationStatus`) for reminder events. 259 | @MainActor 260 | public static func getRemindersAccessStatus() -> EKAuthorizationStatus { 261 | EKEventStore.authorizationStatus(for: .reminder) 262 | } 263 | 264 | /// Requests access to the user's reminders. 265 | /// 266 | /// - Returns: A Boolean value indicating whether access to reminders was granted. 267 | /// - Throws: An error if the access request fails. 268 | @MainActor 269 | public static func requestAccessToReminders() async throws -> Bool { 270 | if #available(iOS 17, *) { 271 | return try await eventStore.requestFullAccessToReminders() 272 | } else { 273 | return try await eventStore.requestAccess(to: .reminder) 274 | } 275 | } 276 | 277 | /// Adds a new reminder to the user's reminders list. 278 | /// 279 | /// - Parameters: 280 | /// - title: The title of the reminder. 281 | /// - notes: An optional note associated with the reminder. 282 | /// - dueDate: An optional due date for the reminder. 283 | /// - alarms: An optional array of alarms for the reminder, specified using `EventAlermOption`. 284 | /// - url: An optional URL associated with the reminder. 285 | /// - Returns: The unique identifier for the reminder created. 286 | /// - Throws: An error if the reminder cannot be created or saved. 287 | @MainActor 288 | public static func addReminder( 289 | title: String, 290 | notes: String? = nil, 291 | dueDate: Date? = nil, 292 | alarms: [EventAlermOption]? = nil, 293 | url: URL? = nil 294 | ) throws -> String { 295 | let reminder = EKReminder(eventStore: eventStore) 296 | reminder.title = title 297 | reminder.notes = notes 298 | reminder.url = url 299 | 300 | if let dueDate = dueDate { 301 | reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: dueDate) 302 | } 303 | 304 | if let alarms = alarms, !alarms.isEmpty { 305 | reminder.alarms = alarms.map { alarmOption in 306 | switch alarmOption { 307 | case .minutesBeforeEvent(let count): 308 | return EKAlarm(relativeOffset: TimeInterval(-count * 60)) 309 | case .hoursBeforeEvent(let count): 310 | return EKAlarm(relativeOffset: TimeInterval(-count * 60 * 60)) 311 | case .daysBeforeEvent(let count): 312 | return EKAlarm(relativeOffset: TimeInterval(-count * 24 * 60 * 60)) 313 | } 314 | } 315 | } 316 | 317 | reminder.calendar = eventStore.defaultCalendarForNewReminders() 318 | 319 | try eventStore.save(reminder, commit: true) 320 | return reminder.calendarItemIdentifier 321 | } 322 | 323 | /// Modifies an existing reminder. 324 | /// 325 | /// - Parameters: 326 | /// - reminderId: The unique identifier of the reminder to modify. 327 | /// - newTitle: An optional new title for the reminder. 328 | /// - newNotes: An optional new note for the reminder. 329 | /// - newDueDate: An optional new due date for the reminder. 330 | /// - newAlarms: An optional array of new alarms for the reminder. 331 | /// - newUrl: An optional new URL associated with the reminder. 332 | /// - Throws: 333 | /// - `EventKitError.eventNotFound` if the reminder cannot be found. 334 | /// - `EventKitError.failedToModifyEvent` if the reminder cannot be modified. 335 | @MainActor 336 | public static func modifyReminder( 337 | reminderId: String, 338 | newTitle: String? = nil, 339 | newNotes: String? = nil, 340 | newDueDate: Date? = nil, 341 | newAlarms: [EventAlermOption]? = nil, 342 | newUrl: URL? = nil 343 | ) throws { 344 | guard let reminder = eventStore.calendarItem(withIdentifier: reminderId) as? EKReminder else { 345 | throw EventKitError.eventNotFound 346 | } 347 | 348 | if let newTitle = newTitle { 349 | reminder.title = newTitle 350 | } 351 | if let newNotes = newNotes { 352 | reminder.notes = newNotes 353 | } 354 | if let newDueDate = newDueDate { 355 | reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: newDueDate) 356 | } 357 | if let newAlarms = newAlarms { 358 | reminder.alarms = newAlarms.map { alarmOption in 359 | switch alarmOption { 360 | case .minutesBeforeEvent(let count): 361 | return EKAlarm(relativeOffset: TimeInterval(-count * 60)) 362 | case .hoursBeforeEvent(let count): 363 | return EKAlarm(relativeOffset: TimeInterval(-count * 60 * 60)) 364 | case .daysBeforeEvent(let count): 365 | return EKAlarm(relativeOffset: TimeInterval(-count * 24 * 60 * 60)) 366 | } 367 | } 368 | } 369 | if let newUrl = newUrl { 370 | reminder.url = newUrl 371 | } 372 | 373 | try eventStore.save(reminder, commit: true) 374 | } 375 | 376 | /// Removes a reminder from the user's reminders list. 377 | /// 378 | /// - Parameter reminderId: The unique identifier of the reminder to remove. 379 | /// - Throws: 380 | /// - `EventKitError.eventNotFound` if the reminder cannot be found. 381 | /// - `EventKitError.failedToModifyEvent` if the reminder cannot be removed. 382 | @MainActor 383 | public static func removeReminder(reminderId: String) throws { 384 | guard let reminder = eventStore.calendarItem(withIdentifier: reminderId) as? EKReminder else { 385 | throw EventKitError.eventNotFound 386 | } 387 | 388 | try eventStore.remove(reminder, commit: true) 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Extensions/BatteryState+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BatteryState+EXT.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/9/24. 6 | // 7 | import UIKit 8 | 9 | public extension UIDevice.BatteryState { 10 | var stringValue: String { 11 | switch self { 12 | case .unplugged: 13 | return "unplugged" 14 | case .charging: 15 | return "charging" 16 | case .full: 17 | return "full" 18 | default: 19 | return "unknown" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Extensions/MeasurementSystem+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeasurementSystem+EXT.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/9/24. 6 | // 7 | import UIKit 8 | 9 | public extension Locale.MeasurementSystem { 10 | var stringValue: String { 11 | switch self { 12 | case .us: 13 | return "us" 14 | case .uk: 15 | return "uk" 16 | case .metric: 17 | return "metric" 18 | default: 19 | return "unknown" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Extensions/ThermalState+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThermalState+EXT.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/9/24. 6 | // 7 | import UIKit 8 | 9 | public extension ProcessInfo.ThermalState { 10 | var stringValue: String { 11 | switch self { 12 | case .nominal: 13 | return "nominal" 14 | case .fair: 15 | return "fair" 16 | case .serious: 17 | return "serious" 18 | case .critical: 19 | return "critical" 20 | default: 21 | return "unknown" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Extensions/UIDeviceOrientation+EXT.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/9/24. 6 | // 7 | import UIKit 8 | 9 | public extension UIDeviceOrientation { 10 | var stringValue: String { 11 | switch self { 12 | case .portrait: 13 | return "portrait" 14 | case .portraitUpsideDown: 15 | return "portrait_upside_down" 16 | case .landscapeLeft: 17 | return "landscape_left" 18 | case .landscapeRight: 19 | return "landscape_right" 20 | case .faceUp: 21 | return "face_up" 22 | case .faceDown: 23 | return "face_down" 24 | default: 25 | return "unknown" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/LocalNotifications/AnyNotificationContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyNotificationContent.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/20/24. 6 | // 7 | import SwiftUI 8 | 9 | public struct AnyNotificationContent { 10 | let id: String 11 | let title: String 12 | let body: String? 13 | let sound: Bool 14 | let badge: Int? 15 | 16 | public init(id: String = UUID().uuidString, title: String, body: String? = nil, sound: Bool = true, badge: Int? = nil) { 17 | self.id = id 18 | self.title = title 19 | self.body = body 20 | self.sound = sound 21 | self.badge = badge 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/LocalNotifications/LocalNotifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/20/24. 6 | // 7 | import Foundation 8 | import UIKit 9 | import UserNotifications 10 | import CoreLocation 11 | 12 | public final class LocalNotifications { 13 | 14 | private static var instance: UNUserNotificationCenter { 15 | UNUserNotificationCenter.current() 16 | } 17 | 18 | /// Requests the user’s authorization to allow local and remote notifications for your app. 19 | @discardableResult 20 | public static func requestAuthorization(options: UNAuthorizationOptions = [.alert, .sound, .badge]) async throws -> Bool { 21 | try await instance.requestAuthorization(options: options) 22 | } 23 | 24 | public static func canRequestAuthorization() async -> Bool { 25 | let status = try? await getNotificationStatus() 26 | return status == .notDetermined 27 | } 28 | 29 | /// Retrieves the notification authorization settings for your app. 30 | /// 31 | /// - .authorized = User previously granted permission for notifications 32 | /// - .denied = User previously denied permission for notifications 33 | /// - .notDetermined = Notification permission hasn't been asked yet. 34 | /// - .provisional = The application is authorized to post non-interruptive user notifications (iOS 12.0+) 35 | /// - .ephemeral = The application is temporarily authorized to post notifications - available to App Clips only (iOS 14.0+) 36 | /// 37 | /// - Returns: User's authorization status 38 | public static func getNotificationStatus() async throws -> UNAuthorizationStatus { 39 | return await withCheckedContinuation({ continutation in 40 | instance.getNotificationSettings { settings in 41 | continutation.resume(returning: settings.authorizationStatus) 42 | return 43 | } 44 | }) 45 | } 46 | 47 | /// Set the number as the badge of the app icon on the Home screen. 48 | /// 49 | /// Set to 0 (zero) to hide the badge number. The default value of this property is 0. 50 | public static func setApplcationIconBadgeNumber(to int: Int) { 51 | instance.setBadgeCount(int) 52 | } 53 | 54 | /// Open the Settings App on user's device. 55 | /// 56 | /// If user has previously denied notification authorization, the OS prompt will not appear again. The user will need to manually turn notifications in Settings. 57 | @MainActor 58 | public static func openAppSettings() throws { 59 | guard let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) else { 60 | throw URLError(.badURL) 61 | } 62 | UIApplication.shared.open(url) 63 | } 64 | 65 | /// Schedule a local notification 66 | public static func scheduleNotification(content: AnyNotificationContent, trigger: NotificationTriggerOption) async throws { 67 | try await scheduleNotification( 68 | id: content.id, 69 | title: content.title, 70 | body: content.body, 71 | sound: content.sound, 72 | badge: content.badge, 73 | trigger: trigger) 74 | } 75 | 76 | /// Schedule a local notification 77 | public static func scheduleNotification(id: String = UUID().uuidString, title: String, body: String? = nil, sound: Bool = true, badge: Int? = nil, trigger: NotificationTriggerOption) async throws { 78 | let notificationContent = getNotificationContent(title: title, body: body, sound: sound, badge: badge) 79 | let notificationTrigger = getNotificationTrigger(option: trigger) 80 | try await addNotification(identifier: id, content: notificationContent, trigger: notificationTrigger) 81 | } 82 | 83 | /// Cancel all pending notifications (notifications that are in the queue and have not yet triggered) 84 | public static func removeAllPendingNotifications() { 85 | instance.removeAllPendingNotificationRequests() 86 | } 87 | 88 | /// Remove all delivered notifications (notifications that have previously triggered) 89 | public static func removeAllDeliveredNotifications() { 90 | instance.removeAllDeliveredNotifications() 91 | } 92 | 93 | /// Remove notifications by ID 94 | /// 95 | /// - Parameters: 96 | /// - ids: ID associated with scheduled notification. 97 | /// - pending: Cancel pending notifications (notifications that are in the queue and have not yet triggered) 98 | /// - delivered: Remove delivered notifications (notifications that have previously triggered) 99 | public static func removeNotifications(ids: [String], pending: Bool = true, delivered: Bool = true) { 100 | if pending { 101 | instance.removePendingNotificationRequests(withIdentifiers: ids) 102 | } 103 | if delivered { 104 | instance.removeDeliveredNotifications(withIdentifiers: ids) 105 | } 106 | } 107 | 108 | } 109 | 110 | // MARK: PRIVATE 111 | 112 | private extension LocalNotifications { 113 | 114 | private static func getNotificationContent(title: String, body: String?, sound: Bool, badge: Int?) -> UNNotificationContent { 115 | let content = UNMutableNotificationContent() 116 | content.title = title 117 | if let body { 118 | content.body = body 119 | } 120 | if sound { 121 | content.sound = .default 122 | } 123 | if let badge { 124 | content.badge = badge as NSNumber 125 | } 126 | return content 127 | } 128 | 129 | private static func getNotificationTrigger(option: NotificationTriggerOption) -> UNNotificationTrigger { 130 | switch option { 131 | case .date(date: let date, repeats: let repeats): 132 | let components = Calendar.current.dateComponents([.second, .minute, .hour, .day, .month, .year], from: date) 133 | return UNCalendarNotificationTrigger(dateMatching: components, repeats: repeats) 134 | case .time(timeInterval: let timeInterval, repeats: let repeats): 135 | return UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: repeats) 136 | 137 | #if !targetEnvironment(macCatalyst) 138 | case .location(coordinates: let coordinates, radius: let radius, notifyOnEntry: let notifyOnEntry, notifyOnExit: let notifyOnExit, repeats: let repeats): 139 | let region = CLCircularRegion(center: coordinates, radius: radius, identifier: UUID().uuidString) 140 | region.notifyOnEntry = notifyOnEntry 141 | region.notifyOnExit = notifyOnExit 142 | return UNLocationNotificationTrigger(region: region, repeats: repeats) 143 | #endif 144 | } 145 | } 146 | 147 | private static func addNotification(identifier: String?, content: UNNotificationContent, trigger: UNNotificationTrigger) async throws { 148 | let request = UNNotificationRequest( 149 | identifier: identifier ?? UUID().uuidString, 150 | content: content, 151 | trigger: trigger) 152 | 153 | try await instance.add(request) 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/LocalNotifications/NotificationTriggerOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationTriggerOption.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/20/24. 6 | // 7 | import SwiftUI 8 | import CoreLocation 9 | 10 | public enum NotificationTriggerOption { 11 | case date(date: Date, repeats: Bool) 12 | case time(timeInterval: TimeInterval, repeats: Bool) 13 | 14 | @available(macCatalyst, unavailable, message: "Location-based notifications are not available on Mac Catalyst.") 15 | case location(coordinates: CLLocationCoordinate2D, radius: CLLocationDistance, notifyOnEntry: Bool, notifyOnExit: Bool, repeats: Bool) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Ratings/AppStoreRatingsHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreRatingsHelper.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/27/24. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import StoreKit 10 | 11 | @MainActor 12 | public final class AppStoreRatingsHelper { 13 | 14 | /// The last time the ratings was requested. Default value is .distantPast. 15 | public static var lastRatingsRequestReviewDate: Date = UserDefaults.lastRatingsRequest 16 | 17 | public static func requestRatingsReview() { 18 | guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } 19 | 20 | if #available(iOS 18.0, *) { 21 | AppStore.requestReview(in: scene) 22 | } else { 23 | SKStoreReviewController.requestReview(in: scene) 24 | } 25 | 26 | lastRatingsRequestReviewDate = .now 27 | } 28 | 29 | } 30 | 31 | private extension UserDefaults { 32 | 33 | static let lastRequestKey = "last_ratings_request_date" 34 | 35 | /// Retrieves or saves the date of the last rating request 36 | static var lastRatingsRequest: Date { 37 | get { 38 | standard.object(forKey: lastRequestKey) as? Date ?? .distantPast 39 | } 40 | set { 41 | standard.set(newValue, forKey: lastRequestKey) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Utilities/Utilities+EventParameters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities+EventParameters.swift 3 | // SwiftfulUtilities 4 | // 5 | // Created by Nick Sarno on 10/9/24. 6 | // 7 | 8 | extension Utilities { 9 | 10 | /// A dictionary of various app and device parameters. 11 | public static var eventParameters: [String: Any] { 12 | let dict: [String: Any?] = [ 13 | "utility_app_version": appVersion ?? "unknown", 14 | "utility_build_number": buildNumber ?? "unknown", 15 | "utility_app_name": appName ?? "unknown", 16 | "utility_bundle_id": bundleIdentifier ?? "unknown", 17 | "utility_app_display_name": appDisplayName ?? "unknown", 18 | "utility_minimum_OS_version": minimumOSVersion, 19 | "utility_app_executable": appExecutable, 20 | "utility_app_development_region": appDevelopmentRegion, 21 | "utility_device_name": deviceName, 22 | "utility_system_name": systemName, 23 | "utility_system_version": systemVersion, 24 | "utility_model": model, 25 | "utility_localized_model": localizedModel, 26 | "utility_model_identifier": modelIdentifier, 27 | "utility_idfv": identifierForVendor, 28 | "utility_is_ipad": isiPad, 29 | "utility_is_iphone": isiPhone, 30 | "utility_device_orientation": deviceOrientation.stringValue, 31 | "utility_is_smaller_vertical_height": isSmallerVerticalHeight, 32 | "utility_screen_width": screenWidth, 33 | "utility_screen_height": screenHeight, 34 | "utility_screen_scale": screenScale, 35 | "utility_battery_level": batteryLevel, 36 | "utility_battery_state": batteryState.stringValue, 37 | "utility_is_portrait": isPortrait, 38 | "utility_is_landscape": isLandscape, 39 | "utility_has_notch": hasNotch, 40 | "utility_is_testflight": isTestFlight, 41 | "utility_is_debug": isDebug, 42 | "utility_is_dev_user": isDevUser, 43 | "utility_is_prod_user": isProdUser, 44 | "utility_user_type": userType.rawValue, 45 | "utility_is_low_power_enabled": isLowPowerModeEnabled, 46 | "utility_thermal_state": thermalState.stringValue, 47 | "utility_is_mac_catalyst": isMacCatalystApp, 48 | "utility_is_iOS_on_mac": isiOSAppOnMac, 49 | "utility_physical_memory_gb": physicalMemoryInGB, 50 | "utility_system_uptime_days": systemUptimeInDays, 51 | "utility_locale_country": userCountry, 52 | "utility_locale_language": userLanguage, 53 | "utility_locale_currency_code": userCurrencyCode, 54 | "utility_locale_currency_symbol": userCurrencySymbol, 55 | "utility_locale_measurement_system": measurementSystem.stringValue, 56 | "utility_locale_time_zone": userTimeZone, 57 | "utility_locale_calendar": userCalendar 58 | ] 59 | return dict.compactMapValues({ $0 }) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SwiftfulUtilities/Utilities/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @MainActor 5 | public struct Utilities { 6 | 7 | // MARK: Bundle.main 8 | 9 | /// The app's version number from the bundle (1.0.0) 10 | public static var appVersion: String? { 11 | return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 12 | } 13 | 14 | /// The app's build number from the bundle (3) 15 | public static var buildNumber: String? { 16 | return Bundle.main.infoDictionary?["CFBundleVersion"] as? String 17 | } 18 | 19 | /// The app's name from the bundle (MyApp) 20 | public static var appName: String? { 21 | return Bundle.main.infoDictionary?["CFBundleName"] as? String 22 | } 23 | 24 | /// The app's bundle identifier (com.organization.MyApp) 25 | public static var bundleIdentifier: String? { 26 | return Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String 27 | } 28 | 29 | /// The app's display name from the bundle (My App) 30 | public static var appDisplayName: String? { 31 | return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String 32 | } 33 | 34 | /// The minimum required OS version for the app (17.0) 35 | public static var minimumOSVersion: String? { 36 | return Bundle.main.infoDictionary?["MinimumOSVersion"] as? String 37 | } 38 | 39 | /// The executable name of the app (MyApp) 40 | public static var appExecutable: String? { 41 | return Bundle.main.infoDictionary?["CFBundleExecutable"] as? String 42 | } 43 | 44 | /// The app's development region setting (en) 45 | public static var appDevelopmentRegion: String? { 46 | return Bundle.main.infoDictionary?["CFBundleDevelopmentRegion"] as? String 47 | } 48 | 49 | /// The platforms supported by the app. 50 | public static var appSupportedPlatforms: [String]? { 51 | return Bundle.main.infoDictionary?["CFBundleSupportedPlatforms"] as? [String] 52 | } 53 | 54 | /// The version of the Info.plist file. 55 | public static var appInfoDictionaryVersion: String? { 56 | return Bundle.main.infoDictionary?["CFBundleInfoDictionaryVersion"] as? String 57 | } 58 | 59 | /// The icon files of the app, if available. 60 | public static var appIconFiles: [String]? { 61 | return Bundle.main.infoDictionary?["CFBundleIconFiles"] as? [String] 62 | } 63 | 64 | // MARK: UIDevice.current 65 | 66 | /// A Boolean value indicating whether the device is an iPad. 67 | public static var isiPad: Bool { 68 | UIDevice.current.userInterfaceIdiom == .pad 69 | } 70 | 71 | /// A Boolean value indicating whether the device is an iPhone. 72 | public static var isiPhone: Bool { 73 | UIDevice.current.userInterfaceIdiom == .phone 74 | } 75 | 76 | /// The name of the device. 77 | public static var deviceName: String { 78 | UIDevice.current.name 79 | } 80 | 81 | /// The system name of the device (e.g., "iOS"). 82 | public static var systemName: String { 83 | UIDevice.current.systemName 84 | } 85 | 86 | /// The system version of the device (17.0) 87 | public static var systemVersion: String { 88 | UIDevice.current.systemVersion 89 | } 90 | 91 | /// The model of the device (e.g., "iPhone"). 92 | public static var model: String { 93 | UIDevice.current.model 94 | } 95 | 96 | /// The localized version of the device model (iPhone) 97 | public static var localizedModel: String { 98 | UIDevice.current.localizedModel 99 | } 100 | 101 | /// The identifier for the vendor (IDFV) of the device. 102 | public static var identifierForVendor: String { 103 | UIDevice.current.identifierForVendor?.uuidString ?? "no_idfv" 104 | } 105 | 106 | /// The current battery level of the device. 107 | public static var batteryLevel: Double { 108 | Double(UIDevice.current.batteryLevel) 109 | } 110 | 111 | /// The current battery state of the device (charging, full, unplugged). 112 | public static var batteryState: UIDevice.BatteryState { 113 | UIDevice.current.batteryState 114 | } 115 | 116 | /// The physical orientation of the device (portrait, landscapeLeft, etc.) 117 | public static var deviceOrientation: UIDeviceOrientation { 118 | UIDevice.current.orientation 119 | } 120 | 121 | /// A Boolean value indicating whether the device is in portrait orientation. 122 | public static var isPortrait: Bool { 123 | UIDevice.current.orientation.isPortrait 124 | } 125 | 126 | /// A Boolean value indicating whether the device is in landscape orientation. 127 | public static var isLandscape: Bool { 128 | UIDevice.current.orientation.isLandscape 129 | } 130 | 131 | // MARK: UIScreen.main 132 | 133 | /// A Boolean value indicating whether the current vertical orientation of the device has a height less than 800px. 134 | public static var isSmallerVerticalHeight: Bool { 135 | let height = isLandscape ? UIScreen.main.bounds.width : UIScreen.main.bounds.height 136 | return height < 800 137 | } 138 | 139 | /// The width of the screen. 140 | public static var screenWidth: CGFloat { 141 | UIScreen.main.bounds.width 142 | } 143 | 144 | /// The height of the screen. 145 | public static var screenHeight: CGFloat { 146 | UIScreen.main.bounds.height 147 | } 148 | 149 | /// The scale factor of the screen. 150 | public static var screenScale: CGFloat { 151 | UIScreen.main.scale 152 | } 153 | 154 | // MARK: ProcessInfo.processInfo 155 | 156 | /// A Boolean value indicating if running SwiftUI Preview mode. 157 | public static var isXcodePreview: Bool { 158 | ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 159 | } 160 | 161 | /// A Boolean value indicating if UI Tests are running. 162 | public static var isUITesting: Bool { 163 | return ProcessInfo.processInfo.arguments.contains("UI_TESTING") 164 | } 165 | 166 | /// A Boolean value indicating if Unit Tests are running. 167 | public static var isUnitTesting: Bool { 168 | return NSClassFromString("XCTestCase") != nil 169 | } 170 | 171 | /// The name of the process. 172 | public static var processName: String { 173 | ProcessInfo.processInfo.processName 174 | } 175 | 176 | /// The unique identifier for the current process. 177 | public static var processIdentifier: Int { 178 | Int(ProcessInfo.processInfo.processIdentifier) 179 | } 180 | 181 | /// The environment variables of the current process. 182 | public static var launchEnvironmentVariables: [String: String] { 183 | ProcessInfo.processInfo.environment 184 | } 185 | 186 | /// The command-line arguments used to launch the app. 187 | public static var launchArguments: [String] { 188 | ProcessInfo.processInfo.arguments 189 | } 190 | 191 | /// A Boolean value indicating whether Low Power Mode is enabled. 192 | public static var isLowPowerModeEnabled: Bool { 193 | ProcessInfo.processInfo.isLowPowerModeEnabled 194 | } 195 | 196 | /// The thermal state of the device (e.g., nominal, fair, serious, critical). 197 | public static var thermalState: ProcessInfo.ThermalState { 198 | ProcessInfo.processInfo.thermalState 199 | } 200 | 201 | /// The physical memory (RAM) of the device in bytes. 202 | public static var physicalMemory: Int { 203 | Int(ProcessInfo.processInfo.physicalMemory) 204 | } 205 | 206 | /// The total physical memory (RAM) of the device in bytes, converted to a human-readable format. 207 | public static var physicalMemoryInGB: Double { 208 | let bytes = ProcessInfo.processInfo.physicalMemory 209 | return Double(bytes) / 1_073_741_824 // 1 GB = 1,073,741,824 bytes 210 | } 211 | 212 | /// The time interval since the system was booted. 213 | public static var systemUptime: Double { 214 | ProcessInfo.processInfo.systemUptime 215 | } 216 | 217 | /// The time interval since the system was booted, represented in days. 218 | public static var systemUptimeInDays: Double { 219 | let uptimeInSeconds = ProcessInfo.processInfo.systemUptime 220 | let uptimeInDays = uptimeInSeconds / 86400 // 1 day = 86,400 seconds 221 | return round(uptimeInDays * 100) / 100 // Rounds to 2 decimal places 222 | } 223 | 224 | /// A Boolean value indicating whether the app is running on Mac Catalyst. 225 | public static var isMacCatalystApp: Bool { 226 | ProcessInfo.processInfo.isMacCatalystApp 227 | } 228 | 229 | /// A Boolean value indicating whether the app is an iOS app running on a Mac. 230 | public static var isiOSAppOnMac: Bool { 231 | ProcessInfo.processInfo.isiOSAppOnMac 232 | } 233 | 234 | // MARK: Locale.current 235 | 236 | /// The user's country or region based on the current locale (US) 237 | public static var userCountry: String { 238 | Locale.current.region?.identifier ?? "unknown" 239 | } 240 | 241 | /// The user's language based on the current locale (en) 242 | public static var userLanguage: String { 243 | Locale.current.language.languageCode?.identifier ?? "unknown" 244 | } 245 | 246 | /// The user's currency code based on the current locale (e.g., "USD"). 247 | public static var userCurrencyCode: String { 248 | Locale.current.currency?.identifier ?? "unknown" 249 | } 250 | 251 | /// The user's currency symbol based on the current locale (e.g., "$"). 252 | public static var userCurrencySymbol: String { 253 | Locale.current.currencySymbol ?? "unknown" 254 | } 255 | 256 | /// A Boolean indicating whether the user's locale uses the metric system (us, uk, metric) 257 | public static var measurementSystem: Locale.MeasurementSystem { 258 | Locale.current.measurementSystem 259 | } 260 | 261 | /// The user's time zone identifier (e.g., "America/New_York"). 262 | public static var userTimeZone: String { 263 | TimeZone.current.identifier 264 | } 265 | 266 | /// The user's preferred calendar identifier (e.g., "gregorian"). 267 | public static var userCalendar: String { 268 | Locale.current.calendar.identifier.debugDescription 269 | } 270 | 271 | /// The user's collation identifier, if available, which determines sort order. 272 | public static var collationIdentifier: String { 273 | Locale.current.collation.identifier 274 | } 275 | 276 | // MARK: Other 277 | 278 | /// The device model identifier (e.g., "iPhone12,1", "arm64"). 279 | public static var modelIdentifier: String { 280 | var systemInfo = utsname() 281 | uname(&systemInfo) 282 | let machineMirror = Mirror(reflecting: systemInfo.machine) 283 | let identifier = machineMirror.children.reduce("", { identifier, element in 284 | guard let value = element.value as? Int8, value != 0 else { return identifier } 285 | return identifier + String(UnicodeScalar(UInt8(value))) 286 | }) 287 | return identifier 288 | } 289 | 290 | /// A Boolean value indicating whether the device has a notch. 291 | public static var hasNotch: Bool { 292 | if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { 293 | let keyWindow = windowScene.windows.first { $0.isKeyWindow } 294 | let bottom = keyWindow?.safeAreaInsets.bottom ?? 0 295 | return bottom > 0 296 | } 297 | return false 298 | } 299 | 300 | // MARK: UserType 301 | 302 | /// The different types of users based on the app's environment. 303 | public enum UserType: String { 304 | case debug 305 | case testFlight = "testflight" 306 | case appStore = "appstore" 307 | } 308 | 309 | /// A Boolean value indicating whether the app is running in TestFlight. 310 | public static var isTestFlight: Bool { 311 | guard let component = Bundle.main.appStoreReceiptURL?.lastPathComponent else { 312 | return false 313 | } 314 | return component == "sandboxReceipt" || component == "CoreSimulator" 315 | } 316 | 317 | /// A Boolean value indicating whether the app is running in a debug build. 318 | public static var isDebug: Bool { 319 | #if DEBUG 320 | return true 321 | #else 322 | return false 323 | #endif 324 | } 325 | 326 | /// A Boolean value indicating whether the user is a development user. 327 | public static var isDevUser: Bool { 328 | userType != .appStore 329 | } 330 | 331 | /// A Boolean value indicating whether the user is a production user. 332 | public static var isProdUser: Bool { 333 | userType == .appStore 334 | } 335 | 336 | /// The type of user based on the app's build and environment. 337 | public static var userType: UserType { 338 | if isDebug { 339 | return .debug 340 | } else if isTestFlight { 341 | return .testFlight 342 | } 343 | return .appStore 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /Tests/SwiftfulUtilitiesTests/SwiftfulUtilitiesTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import SwiftfulUtilities 3 | 4 | @Test func example() async throws { 5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 | } 7 | --------------------------------------------------------------------------------