From 4c932cb336abf8a4faf360abb3338e2215e918a4 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 19:55:53 +0800 Subject: [PATCH 1/6] chore: format with swift format --- Package.swift | 11 +++-- Sources/TypedAppStorage/TypedAppStorage.swift | 22 ++++++---- .../TypedAppStorageTests.swift | 42 ++++++++++--------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/Package.swift b/Package.swift index 8a61557..c45f04c 100644 --- a/Package.swift +++ b/Package.swift @@ -10,21 +10,24 @@ 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/TypedAppStorage.swift b/Sources/TypedAppStorage/TypedAppStorage.swift index 062c8a7..97d241b 100644 --- a/Sources/TypedAppStorage/TypedAppStorage.swift +++ b/Sources/TypedAppStorage/TypedAppStorage.swift @@ -18,28 +18,36 @@ public protocol TypedAppStorageValue: Codable { public struct TypedAppStorage: DynamicProperty { private var appStorage: AppStorage private var initialValue: Value - + /// 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? JSONEncoder().encode(wrappedValue) - let initialString = (initialData == nil ? nil : String(data: initialData!, encoding: .utf8)) ?? "" - appStorage = .init(wrappedValue: initialString, Value.appStorageKey, store: store) + 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 } + guard let data = appStorage.wrappedValue.data(using: .utf8) else { + return initialValue + } return (try? JSONDecoder().decode(Value.self, from: data)) ?? initialValue } nonmutating set { @@ -50,7 +58,7 @@ public struct TypedAppStorage: DynamicProperty { appStorage.wrappedValue = newString } } - + /// A two-way binding of ``wrappedValue``. public var projectedValue: Binding { .init { diff --git a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift index 1b0b10d..c3be292 100644 --- a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift +++ b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift @@ -1,5 +1,6 @@ -import XCTest import SwiftUI +import XCTest + @testable import TypedAppStorage struct PreferredFruit: TypedAppStorageValue, Equatable { @@ -9,13 +10,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 @@ -24,11 +25,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") } @@ -36,11 +37,11 @@ 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") } @@ -50,33 +51,36 @@ final class TypedAppStorageTests: XCTestCase { override func setUp() { UserDefaults.standard.removeObject(forKey: "preferredFruit") } - + func testReadDefaultValue() throws { let testArticle = TestArticle() - - XCTAssertEqual(testArticle.preferredFruit, PreferredFruit(.veryFresh, .apple)) + + XCTAssertEqual( + testArticle.preferredFruit, + PreferredFruit(.veryFresh, .apple) + ) } - + func testCallSiteDefault() throws { let testArticle = TestArticleWithADifferentDefault() - + XCTAssertEqual(testArticle.preferredFruit, .init(.moderate, .pear)) } - + func testSaveAndReadBack() throws { let testArticle = TestArticle() - + testArticle.changePreferred(to: .init(.somewhatStale, .banana)) - + XCTAssertEqual(testArticle.preferredFruit, .init(.somewhatStale, .banana)) } - + func testSaveAndReadElsewhere() throws { let articleA = TestArticle() let articleB = TestArticle() - + articleA.changePreferred(to: .init(.moderate, .banana)) - + XCTAssertEqual(articleB.preferredFruit, .init(.moderate, .banana)) } } From 27d402605028d8d4f14457beca144c82b860454a Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 19:56:19 +0800 Subject: [PATCH 2/6] fix: add `Sendable` requirement to `TypedAppStorageValue` --- Sources/TypedAppStorage/TypedAppStorage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TypedAppStorage/TypedAppStorage.swift b/Sources/TypedAppStorage/TypedAppStorage.swift index 97d241b..2e780f2 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 { +public protocol TypedAppStorageValue: Codable, Sendable { /// 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``. From b3326032f67fbc83810eb0ecfad635d586a9f802 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 20:01:02 +0800 Subject: [PATCH 3/6] fix: Reuse JSONEncoder and JSONDecoder instances in TypedAppStorage --- Sources/TypedAppStorage/TypedAppStorage.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/TypedAppStorage/TypedAppStorage.swift b/Sources/TypedAppStorage/TypedAppStorage.swift index 2e780f2..4b11e7b 100644 --- a/Sources/TypedAppStorage/TypedAppStorage.swift +++ b/Sources/TypedAppStorage/TypedAppStorage.swift @@ -18,13 +18,15 @@ 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? JSONEncoder().encode(wrappedValue) + let initialData = try? encoder.encode(wrappedValue) let initialString = (initialData == nil ? nil : String(data: initialData!, encoding: .utf8)) ?? "" @@ -48,11 +50,11 @@ public struct TypedAppStorage: DynamicProperty { guard let data = appStorage.wrappedValue.data(using: .utf8) else { return initialValue } - return (try? JSONDecoder().decode(Value.self, from: data)) ?? initialValue + return (try? decoder.decode(Value.self, from: data)) ?? initialValue } nonmutating set { guard - let newData = try? JSONEncoder().encode(newValue), + let newData = try? encoder.encode(newValue), let newString = String(data: newData, encoding: .utf8) else { return } appStorage.wrappedValue = newString From 3649c706d93e16ee137ae1c729e81aa59beaa2d9 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 20:16:40 +0800 Subject: [PATCH 4/6] feat: Add TypedUserDefaults with tests. --- .../TypedAppStorage/TypedUserDefaults.swift | 91 +++++++++++ .../TypedUserDefaultsTests.swift | 147 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 Sources/TypedAppStorage/TypedUserDefaults.swift create mode 100644 Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift diff --git a/Sources/TypedAppStorage/TypedUserDefaults.swift b/Sources/TypedAppStorage/TypedUserDefaults.swift new file mode 100644 index 0000000..360afab --- /dev/null +++ b/Sources/TypedAppStorage/TypedUserDefaults.swift @@ -0,0 +1,91 @@ +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/TypedUserDefaultsTests.swift b/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift new file mode 100644 index 0000000..cbc815d --- /dev/null +++ b/Tests/TypedAppStorageTests/TypedUserDefaultsTests.swift @@ -0,0 +1,147 @@ +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) + } +} From b94a69419335dcb0d97840a2b5aa1a4f40fd4cb0 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 20:21:49 +0800 Subject: [PATCH 5/6] chore: Migrate tests to swift-testing and update assertions --- .../TypedAppStorageTests.swift | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift index c3be292..d63d405 100644 --- a/Tests/TypedAppStorageTests/TypedAppStorageTests.swift +++ b/Tests/TypedAppStorageTests/TypedAppStorageTests.swift @@ -1,5 +1,5 @@ import SwiftUI -import XCTest +import Testing @testable import TypedAppStorage @@ -47,40 +47,49 @@ struct TestArticleWithADifferentDefault: View { } } -final class TypedAppStorageTests: XCTestCase { - override func setUp() { +@Suite("TypedAppStorage Tests", .serialized) +struct TypedAppStorageTests { + + init() { UserDefaults.standard.removeObject(forKey: "preferredFruit") } - func testReadDefaultValue() throws { + @Test("Read default value") + func readDefaultValue() throws { + UserDefaults.standard.removeObject(forKey: "preferredFruit") let testArticle = TestArticle() - XCTAssertEqual( - testArticle.preferredFruit, - PreferredFruit(.veryFresh, .apple) + #expect( + testArticle.preferredFruit == PreferredFruit(.veryFresh, .apple) ) } - func testCallSiteDefault() throws { + @Test("Call-site default") + func callSiteDefault() throws { + UserDefaults.standard.removeObject(forKey: "preferredFruit") let testArticle = TestArticleWithADifferentDefault() - XCTAssertEqual(testArticle.preferredFruit, .init(.moderate, .pear)) + #expect(testArticle.preferredFruit == .init(.moderate, .pear)) } - func testSaveAndReadBack() throws { + @Test("Save and read back") + func saveAndReadBack() throws { + UserDefaults.standard.removeObject(forKey: "preferredFruit") let testArticle = TestArticle() testArticle.changePreferred(to: .init(.somewhatStale, .banana)) - XCTAssertEqual(testArticle.preferredFruit, .init(.somewhatStale, .banana)) + #expect(testArticle.preferredFruit == .init(.somewhatStale, .banana)) } - func testSaveAndReadElsewhere() throws { + @Test("Save and read elsewhere") + func saveAndReadElsewhere() throws { + UserDefaults.standard.removeObject(forKey: "preferredFruit") let articleA = TestArticle() let articleB = TestArticle() articleA.changePreferred(to: .init(.moderate, .banana)) - XCTAssertEqual(articleB.preferredFruit, .init(.moderate, .banana)) + #expect(articleB.preferredFruit == .init(.moderate, .banana)) } } From 4fa2f6153238b3a98acbb315e5a556ae924d9569 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sat, 14 Jun 2025 20:26:36 +0800 Subject: [PATCH 6/6] doc: Document TypedUserDefaults usage and update docs --- .../Documentation.docc/Documentation.md | 9 ++-- .../Documentation.docc/GettingStarted.md | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/Sources/TypedAppStorage/Documentation.docc/Documentation.md b/Sources/TypedAppStorage/Documentation.docc/Documentation.md index 7bbd3da..089409b 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`. +A type-safe way to save and read complex data structures from `@AppStorage` and `UserDefaults`. -- Use actual `@AppStorage` underneath -- Support any `Codable` data +- Use actual `@AppStorage` or `UserDefaults` underneath +- Support any `Codable & Sendable` data - Define the key in the data model ## Topics @@ -11,5 +11,6 @@ A type-safe way to save and read complex data structures from `@AppStorage`. ### Essentials - -- ``TypedAppStorage`` - ``TypedAppStorageValue`` +- ``TypedAppStorage`` +- ``TypedUserDefaults`` diff --git a/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md index b64a451..abf93d8 100644 --- a/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md +++ b/Sources/TypedAppStorage/Documentation.docc/GettingStarted.md @@ -69,3 +69,46 @@ 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) +``` +