diff --git a/Package.swift b/Package.swift index c45f04c..8a61557 100644 --- a/Package.swift +++ b/Package.swift @@ -10,24 +10,21 @@ let package = Package( .macOS(.v11), .macCatalyst(.v14), .tvOS(.v14), - .watchOS(.v7), + .watchOS(.v7) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "TypedAppStorage", - targets: ["TypedAppStorage"] - ) + targets: ["TypedAppStorage"]), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( - name: "TypedAppStorage" - ), + name: "TypedAppStorage"), .testTarget( name: "TypedAppStorageTests", - dependencies: ["TypedAppStorage"] - ), + dependencies: ["TypedAppStorage"]), ] ) diff --git a/Sources/TypedAppStorage/Documentation.docc/Documentation.md b/Sources/TypedAppStorage/Documentation.docc/Documentation.md index 089409b..7bbd3da 100644 --- a/Sources/TypedAppStorage/Documentation.docc/Documentation.md +++ b/Sources/TypedAppStorage/Documentation.docc/Documentation.md @@ -1,9 +1,9 @@ # ``TypedAppStorage`` -A type-safe way to save and read complex data structures from `@AppStorage` and `UserDefaults`. +A type-safe way to save and read complex data structures from `@AppStorage`. -- Use actual `@AppStorage` or `UserDefaults` underneath -- Support any `Codable & Sendable` data +- Use actual `@AppStorage` underneath +- Support any `Codable` data - Define the key in the data model ## Topics @@ -11,6 +11,5 @@ A type-safe way to save and read complex data structures from `@AppStorage` and ### Essentials - -- ``TypedAppStorageValue`` - ``TypedAppStorage`` -- ``TypedUserDefaults`` +- ``TypedAppStorageValue`` diff --git a/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md index abf93d8..b64a451 100644 --- a/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md +++ b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md @@ -69,46 +69,3 @@ In some cases it might make sense to specify a different default value than the ``` And you can specify a different store just like `@AppStorage`, if you're using things like App Groups. - -### Use outside SwiftUI Views - -When you're not in a SwiftUI context, you can use ``TypedUserDefaults`` to access the same type-safe storage: - -```swift -let typedDefaults = TypedUserDefaults.standard - -// Reading values -let currentFruit = typedDefaults.object(of: PreferredFruit.self) - -// Writing values -let newFruit = PreferredFruit(.moderate, .pear) -typedDefaults.set(newFruit) -``` - -Just like `@TypedAppStorage`, the key and default value are automatically inferred from the type's ``TypedAppStorageValue`` conformance. - -You can also override the key or default value if needed: - -```swift -// Use a different key -let specialFruit = typedDefaults.object( - of: PreferredFruit.self, - forKey: "specialPreferredFruit" -) - -// Use a different default value -let fruitWithCustomDefault = typedDefaults.object( - of: PreferredFruit.self, - defaultValue: .init(.somewhatStale, .banana) -) -``` - -And you can use a different UserDefaults store, just like with the SwiftUI property wrapper: - -```swift -let groupDefaults = TypedUserDefaults( - userDefaults: UserDefaults(suiteName: "group.com.example.app")! -) -let sharedFruit = groupDefaults.object(of: PreferredFruit.self) -``` - diff --git a/Sources/TypedAppStorage/TypedAppStorage.swift b/Sources/TypedAppStorage/TypedAppStorage.swift index 4b11e7b..062c8a7 100644 --- a/Sources/TypedAppStorage/TypedAppStorage.swift +++ b/Sources/TypedAppStorage/TypedAppStorage.swift @@ -3,7 +3,7 @@ import SwiftUI /// The protocol that typed app storage values must conform to. /// /// The most important requirement is conformance to `Codable`. Use ``TypedAppStorage`` in SwiftUI views to store and fetch conforming data. -public protocol TypedAppStorageValue: Codable, Sendable { +public protocol TypedAppStorageValue: Codable { /// The actual key under which this type of data is stored. static var appStorageKey: String { get } /// The default value to return, if there's no data under the specified ``appStorageKey``. @@ -18,49 +18,39 @@ public protocol TypedAppStorageValue: Codable, Sendable { public struct TypedAppStorage: DynamicProperty { private var appStorage: AppStorage private var initialValue: Value - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - + /// Store and fetch value from the defined store, with a predefined default value. /// /// This default value if defined is preferred over ``TypedAppStorageValue/defaultValue``. public init(wrappedValue: Value, store: UserDefaults? = nil) { initialValue = wrappedValue - let initialData = try? encoder.encode(wrappedValue) - let initialString = - (initialData == nil ? nil : String(data: initialData!, encoding: .utf8)) - ?? "" - appStorage = .init( - wrappedValue: initialString, - Value.appStorageKey, - store: store - ) + let initialData = try? JSONEncoder().encode(wrappedValue) + let initialString = (initialData == nil ? nil : String(data: initialData!, encoding: .utf8)) ?? "" + appStorage = .init(wrappedValue: initialString, Value.appStorageKey, store: store) } - + /// Store and fetch value from the defined store. /// /// ``TypedAppStorageValue/defaultValue`` is used if no value was previously saved. public init(store: UserDefaults? = nil) { self.init(wrappedValue: Value.defaultValue, store: store) } - + /// The wrapped ``TypedAppStorageValue``. public var wrappedValue: Value { get { - guard let data = appStorage.wrappedValue.data(using: .utf8) else { - return initialValue - } - return (try? decoder.decode(Value.self, from: data)) ?? initialValue + guard let data = appStorage.wrappedValue.data(using: .utf8) else { return initialValue } + return (try? JSONDecoder().decode(Value.self, from: data)) ?? initialValue } nonmutating set { guard - let newData = try? encoder.encode(newValue), + let newData = try? JSONEncoder().encode(newValue), let newString = String(data: newData, encoding: .utf8) else { return } appStorage.wrappedValue = newString } } - + /// A two-way binding of ``wrappedValue``. public var projectedValue: Binding { .init { diff --git a/Sources/TypedAppStorage/TypedUserDefaults.swift b/Sources/TypedAppStorage/TypedUserDefaults.swift deleted file mode 100644 index 360afab..0000000 --- a/Sources/TypedAppStorage/TypedUserDefaults.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -/// A type-safe wrapper around UserDefaults that supports JSON-based Codable encoding/decoding. -/// -/// `TypedUserDefaults` mimics Apple's UserDefaults API while providing type safety for complex data structures -/// that conform to ``TypedAppStorageValue``. Since ``TypedAppStorageValue`` includes the storage key and default value, -/// many parameters become optional in the API. -public class TypedUserDefaults { - /// The underlying UserDefaults store. - public let userDefaults: UserDefaults - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - /// The shared typed user defaults object. - public static let standard = TypedUserDefaults() - - /// Creates a TypedUserDefaults instance with the specified UserDefaults store. - /// - Parameter userDefaults: The underlying UserDefaults store. Defaults to UserDefaults.standard. - public init(userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults - } - - // MARK: - Reading Values - - /// Returns the typed object associated with the specified key. - /// - Parameters: - /// - type: The type of object to retrieve. - /// - key: The key to retrieve the value for. If nil, uses the type's appStorageKey. - /// - defaultValue: The default value to return if no object exists for the key. If nil, uses the type's default value. - /// - Returns: The typed object, or the type's default value if no object exists for the key. - public func object( - of type: T.Type, - forKey key: String? = nil, - defaultValue: T? = nil - ) -> T { - let storageKey = key ?? T.appStorageKey - - guard let jsonString = userDefaults.string(forKey: storageKey), - let data = jsonString.data(using: .utf8), - let decodedValue = try? decoder.decode(T.self, from: data) - else { - return defaultValue ?? T.defaultValue - } - - return decodedValue - } - - // MARK: - Writing Values - - /// Sets the value of the specified typed object for the given key. - /// - Parameters: - /// - value: The typed object to store. - /// - key: The key to store the value under. If nil, uses the type's appStorageKey. - public func set( - _ value: T, - forKey key: String? = nil - ) { - let storageKey = key ?? T.appStorageKey - - guard let data = try? encoder.encode(value), - let jsonString = String(data: data, encoding: .utf8) - else { - return - } - - userDefaults.set(jsonString, forKey: storageKey) - } - - // MARK: - Removing Values - - /// Removes the value associated with the specified key for the given type. - /// - Parameters: - /// - type: The type of object to remove. - /// - key: The key to remove the value for. If nil, uses the type's appStorageKey. - public func removeObject( - of type: T.Type, - forKey key: String? = nil - ) { - let storageKey = key ?? T.appStorageKey - userDefaults.removeObject(forKey: storageKey) - } - - // MARK: - UserDefaults Forwarding - - /// Synchronizes any changes made to the shared user defaults. - /// - Returns: true if the synchronization was successful, false otherwise. - @discardableResult - public func synchronize() -> Bool { - return userDefaults.synchronize() - } -} diff --git a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift index d63d405..1b0b10d 100644 --- a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift +++ b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift @@ -1,6 +1,5 @@ +import XCTest import SwiftUI -import Testing - @testable import TypedAppStorage struct PreferredFruit: TypedAppStorageValue, Equatable { @@ -10,13 +9,13 @@ struct PreferredFruit: TypedAppStorageValue, Equatable { enum Freshness: Codable { case veryFresh, moderate, somewhatStale } - + static var appStorageKey = "preferredFruit" static var defaultValue = PreferredFruit(.veryFresh, .apple) - + var fruit: Fruit var freshness: Freshness - + init(_ freshness: Freshness, _ fruit: Fruit) { self.fruit = fruit self.freshness = freshness @@ -25,11 +24,11 @@ struct PreferredFruit: TypedAppStorageValue, Equatable { struct TestArticle: View { @TypedAppStorage var preferredFruit: PreferredFruit - + func changePreferred(to newValue: PreferredFruit) { preferredFruit = newValue } - + var body: some View { Text("Test") } @@ -37,59 +36,47 @@ struct TestArticle: View { struct TestArticleWithADifferentDefault: View { @TypedAppStorage var preferredFruit: PreferredFruit = .init(.moderate, .pear) - + func changePreferred(to newValue: PreferredFruit) { preferredFruit = newValue } - + var body: some View { Text("Test") } } -@Suite("TypedAppStorage Tests", .serialized) -struct TypedAppStorageTests { - - init() { +final class TypedAppStorageTests: XCTestCase { + override func setUp() { UserDefaults.standard.removeObject(forKey: "preferredFruit") } - - @Test("Read default value") - func readDefaultValue() throws { - UserDefaults.standard.removeObject(forKey: "preferredFruit") + + func testReadDefaultValue() throws { let testArticle = TestArticle() - - #expect( - testArticle.preferredFruit == PreferredFruit(.veryFresh, .apple) - ) + + XCTAssertEqual(testArticle.preferredFruit, PreferredFruit(.veryFresh, .apple)) } - - @Test("Call-site default") - func callSiteDefault() throws { - UserDefaults.standard.removeObject(forKey: "preferredFruit") + + func testCallSiteDefault() throws { let testArticle = TestArticleWithADifferentDefault() - - #expect(testArticle.preferredFruit == .init(.moderate, .pear)) + + XCTAssertEqual(testArticle.preferredFruit, .init(.moderate, .pear)) } - - @Test("Save and read back") - func saveAndReadBack() throws { - UserDefaults.standard.removeObject(forKey: "preferredFruit") + + func testSaveAndReadBack() throws { let testArticle = TestArticle() - + testArticle.changePreferred(to: .init(.somewhatStale, .banana)) - - #expect(testArticle.preferredFruit == .init(.somewhatStale, .banana)) + + XCTAssertEqual(testArticle.preferredFruit, .init(.somewhatStale, .banana)) } - - @Test("Save and read elsewhere") - func saveAndReadElsewhere() throws { - UserDefaults.standard.removeObject(forKey: "preferredFruit") + + func testSaveAndReadElsewhere() throws { let articleA = TestArticle() let articleB = TestArticle() - + articleA.changePreferred(to: .init(.moderate, .banana)) - - #expect(articleB.preferredFruit == .init(.moderate, .banana)) + + XCTAssertEqual(articleB.preferredFruit, .init(.moderate, .banana)) } } diff --git a/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift b/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift deleted file mode 100644 index cbc815d..0000000 --- a/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import Testing - -@testable import TypedAppStorage - -// MARK: - Test Models - -struct TestUser: TypedAppStorageValue, Equatable { - static var appStorageKey = "testUser" - static var defaultValue = TestUser(name: "Anonymous", age: 0) - - let name: String - let age: Int -} - -struct TestSettings: TypedAppStorageValue, Equatable { - static var appStorageKey = "testSettings" - static var defaultValue = TestSettings(theme: .light, notifications: true) - - enum Theme: String, Codable { - case light, dark - } - - let theme: Theme - let notifications: Bool -} - -// MARK: - Tests - -@Suite("TypedUserDefaults Tests") -struct TypedUserDefaultsTests { - - // Helper to create a clean test environment - private func createTestDefaults() -> TypedUserDefaults { - let userDefaults = UserDefaults() - userDefaults.removePersistentDomain(forName: Bundle.main.bundleIdentifier ?? "test") - return TypedUserDefaults(userDefaults: userDefaults) - } - - @Test("Should return default value when no stored value exists") - func testDefaultValueRetrieval() { - let typedDefaults = createTestDefaults() - - let user = typedDefaults.object(of: TestUser.self) - - #expect(user == TestUser.defaultValue) - #expect(user.name == "Anonymous") - #expect(user.age == 0) - } - - @Test("Should store and retrieve typed objects correctly") - func testStoreAndRetrieve() { - let typedDefaults = createTestDefaults() - let testUser = TestUser(name: "John Doe", age: 30) - - // Store the value - typedDefaults.set(testUser) - - // Retrieve the value - let retrievedUser = typedDefaults.object(of: TestUser.self) - - #expect(retrievedUser == testUser) - #expect(retrievedUser.name == "John Doe") - #expect(retrievedUser.age == 30) - } - - @Test("Should handle custom keys and default values") - func testCustomKeysAndDefaults() { - let typedDefaults = createTestDefaults() - let customUser = TestUser(name: "Jane Smith", age: 25) - let customDefault = TestUser(name: "Custom Default", age: 99) - let customKey = "customUserKey" - - // Store with custom key - typedDefaults.set(customUser, forKey: customKey) - - // Retrieve with custom key and custom default - let retrievedUser = typedDefaults.object( - of: TestUser.self, forKey: customKey, defaultValue: customDefault) - - #expect(retrievedUser == customUser) - - // Test retrieval from non-existent key with custom default - let nonExistentUser = typedDefaults.object( - of: TestUser.self, forKey: "nonExistentKey", defaultValue: customDefault) - - #expect(nonExistentUser == customDefault) - #expect(nonExistentUser.name == "Custom Default") - #expect(nonExistentUser.age == 99) - } - - @Test("Should remove stored objects correctly") - func testRemoveObject() { - let typedDefaults = createTestDefaults() - let testUser = TestUser(name: "To Be Removed", age: 40) - - // Store the value - typedDefaults.set(testUser) - - // Verify it's stored - let storedUser = typedDefaults.object(of: TestUser.self) - #expect(storedUser == testUser) - - // Remove the value - typedDefaults.removeObject(of: TestUser.self) - - // Verify it returns default value after removal - let removedUser = typedDefaults.object(of: TestUser.self) - #expect(removedUser == TestUser.defaultValue) - #expect(removedUser.name == "Anonymous") - #expect(removedUser.age == 0) - } - - @Test("Should handle complex data types and synchronization") - func testComplexTypesAndSynchronization() { - let typedDefaults = createTestDefaults() - let testSettings = TestSettings(theme: .dark, notifications: false) - - // Store complex type - typedDefaults.set(testSettings) - - // Test synchronization - let syncResult = typedDefaults.synchronize() - #expect(syncResult == true) - - // Retrieve and verify complex type - let retrievedSettings = typedDefaults.object(of: TestSettings.self) - #expect(retrievedSettings == testSettings) - #expect(retrievedSettings.theme == .dark) - #expect(retrievedSettings.notifications == false) - - // Test with custom key for complex type - let customKey = "customSettings" - typedDefaults.set(testSettings, forKey: customKey) - - let customRetrievedSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey) - #expect(customRetrievedSettings == testSettings) - - // Remove with custom key - typedDefaults.removeObject(of: TestSettings.self, forKey: customKey) - - let removedCustomSettings = typedDefaults.object(of: TestSettings.self, forKey: customKey) - #expect(removedCustomSettings == TestSettings.defaultValue) - #expect(removedCustomSettings.theme == .light) - #expect(removedCustomSettings.notifications == true) - } -}