diff --git a/.gitignore b/.gitignore index bbfe0c3..692caf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +Examples/SwiftDataExample/.build/ /Packages xcuserdata/ DerivedData/ diff --git a/Examples/SwiftDataExample/Package.swift b/Examples/SwiftDataExample/Package.swift new file mode 100644 index 0000000..a58ee63 --- /dev/null +++ b/Examples/SwiftDataExample/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "SwiftDataExample", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1) + ], + dependencies: [ + .package(name: "AppState", path: "../..") + ], + targets: [ + .executableTarget( + name: "SwiftDataExample", + dependencies: [ + .product(name: "AppState", package: "AppState") + ] + ) + ] +) diff --git a/Examples/SwiftDataExample/README.md b/Examples/SwiftDataExample/README.md new file mode 100644 index 0000000..f9e00ba --- /dev/null +++ b/Examples/SwiftDataExample/README.md @@ -0,0 +1,86 @@ +# SwiftData + AppState Example + +A small, self-contained SwiftPM executable that demonstrates AppState's SwiftData +integration. It shows how to register a SwiftData `ModelContainer` as an AppState +`Dependency`, expose a collection of `@Model` objects as an `Application.ModelState`, +and read/write that collection from both application-level call sites and the +`@ModelState` property wrapper. + +## What it shows + +- Registering an in-memory `ModelContainer` as an AppState dependency: + `Application.modelContainer`. +- Exposing a `ModelState` collection: `Application.todos`. +- Inserting models two ways: + - the `@ModelState` projected value: `$todos.insert(...)` + - the application-level state: `Application.modelState(\.todos).insert(...)` +- Reading the models (`Application.modelState(\.todos).models`), updating + `save()`, + `delete(_:)`, and clearing everything with `Application.modelState(\.todos).deleteAll()`. +- Using `@ModelState` from a view-model-style `ObservableObject` (`TodoStore`). + +Every step asserts the expected count with `precondition(...)`, so `swift run` +doubles as a smoke test. The example uses an in-memory store, so it is deterministic +and leaves nothing behind. + +## Requirements + +- macOS 14+ (SwiftData) +- Xcode 16+ / a Swift 6 toolchain + +SwiftData only builds on Apple platforms, which is why this lives in a nested package +rather than the root `AppState` package. + +## Running + +```sh +cd Examples/SwiftDataExample +swift run +``` + +You should see the todos being inserted, updated, deleted, and finally reset, ending +with `== Example completed successfully ==` and a `0` exit code. + +## Recommended reactive pattern for SwiftUI + +`@ModelState` is intended for view models, services, and other non-view code that +needs shared, dependency-injected access to your models. Its mutations are **not** +automatically broadcast to SwiftUI. For reactive views, use SwiftData's own `@Query` +while sharing the AppState-provided `ModelContainer`: + +```swift +import AppState +import SwiftData +import SwiftUI + +@main +struct TodoApp: App { + var body: some Scene { + WindowGroup { + TodoListView() + } + // Share the same container AppState manages, so @Query and @ModelState + // read and write through one source of truth. + .modelContainer(Application.dependency(\.modelContainer)) + } +} + +struct TodoListView: View { + // @Query drives the reactive view. + @Query private var todos: [TodoItem] + + // A view model using @ModelState for shared, non-view logic. + @StateObject private var store = TodoStore() + + var body: some View { + List(todos) { todo in + Text(todo.title) + } + .toolbar { + Button("Add") { store.add("New todo") } + } + } +} +``` + +In short: use `@Query` for reactive views, and `@ModelState` (or +`Application.modelState(_:)`) for view models and services. diff --git a/Examples/SwiftDataExample/Sources/SwiftDataExample/SwiftDataExample.swift b/Examples/SwiftDataExample/Sources/SwiftDataExample/SwiftDataExample.swift new file mode 100644 index 0000000..35a6742 --- /dev/null +++ b/Examples/SwiftDataExample/Sources/SwiftDataExample/SwiftDataExample.swift @@ -0,0 +1,149 @@ +import AppState +import Foundation + +#if canImport(SwiftData) +import SwiftData + +// MARK: - Model + +/// A simple SwiftData model persisted through an AppState-provided `ModelContainer`. +/// +/// The package's deployment target is macOS 14 / iOS 17 (see `Package.swift`), so no `@available` +/// annotations are needed here β€” SwiftData is unconditionally available. +@Model +final class TodoItem { + var title: String + var isDone: Bool + + init(title: String, isDone: Bool = false) { + self.title = title + self.isDone = isDone + } +} + +// MARK: - AppState wiring + +extension Application { + /// An in-memory `ModelContainer` registered as an AppState dependency. + /// + /// Using `isStoredInMemoryOnly: true` keeps the example deterministic and side-effect free, + /// so `swift run` can double as a smoke test in CI. + var modelContainer: Dependency { + modelContainer( + try! ModelContainer( + for: TodoItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + ) + } + + /// The shared collection of `TodoItem`s, backed by the `modelContainer` dependency. + var todos: ModelState { + modelState(container: \.modelContainer) + } +} + +// MARK: - View model / service usage + +/// Demonstrates the `@ModelState` property wrapper from a view-model-style `ObservableObject`. +/// +/// `@ModelState` is intended for view models, services, and other non-view code that needs +/// shared, dependency-injected access to your models. For reactive SwiftUI views, prefer +/// SwiftData's own `@Query` while sharing this same `ModelContainer` (see the README). +@MainActor +final class TodoStore: ObservableObject { + @ModelState(\.todos) var todos: [TodoItem] + + /// Adds a todo via the projected value's explicit `insert(_:)`. + func add(_ title: String) { + $todos.insert(TodoItem(title: title)) + } + + /// Persists any pending changes via the projected value's `save()`. + func save() { + $todos.save() + } +} + +// MARK: - Entry point + +@main +struct SwiftDataExample { + // `main()` is `@MainActor` because the backing `ModelContainer.mainContext` (and therefore + // every `ModelState` operation) is main-actor bound. + @MainActor + static func main() { + // Surface AppState's internal logging so the run is easy to follow. + Application.logging(isEnabled: true) + + print("== SwiftData + AppState example ==") + + // Start from a clean slate so repeated runs are deterministic. + Application.modelState(\.todos).deleteAll() + precondition(Application.modelState(\.todos).models.isEmpty, "Expected an empty store at start") + + // 1. Insert via the property-wrapper projected value (view-model style). + let store = TodoStore() + store.add("Buy milk") + print("After store.add: \(store.todos.count) todo(s)") + precondition(store.todos.count == 1, "Expected 1 todo after store.add") + + // 2. Insert more through the view model (its projected-value `insert`). + store.add("Walk the dog") + store.add("Write code") + print("After two more inserts: \(store.todos.count) todo(s)") + precondition(store.todos.count == 3, "Expected 3 todos") + + // 3. Insert directly through the application-level `ModelState`. + Application.modelState(\.todos).insert(TodoItem(title: "Read a book")) + print("After Application.modelState insert: \(Application.modelState(\.todos).models.count) todo(s)") + precondition(Application.modelState(\.todos).models.count == 4, "Expected 4 todos") + + // Fetch & print the current todos. + let current = Application.modelState(\.todos).models + print("Current todos:") + for todo in current { + print(" - [\(todo.isDone ? "x" : " ")] \(todo.title)") + } + + // 4. Mark one todo done and persist the change. + if let first = current.first { + first.isDone = true + Application.modelState(\.todos).save() + print("Marked \"\(first.title)\" as done and saved") + } + let doneCount = Application.modelState(\.todos).models.filter(\.isDone).count + precondition(doneCount == 1, "Expected exactly 1 completed todo") + + // 5. Delete one todo. + if let toDelete = Application.modelState(\.todos).models.last { + Application.modelState(\.todos).delete(toDelete) + print("Deleted \"\(toDelete.title)\"") + } + let remaining = Application.modelState(\.todos).models + print("Remaining todos:") + for todo in remaining { + print(" - [\(todo.isDone ? "x" : " ")] \(todo.title)") + } + precondition(remaining.count == 3, "Expected 3 todos after deletion") + + // 6. deleteAll() removes every model managed by the state. + Application.modelState(\.todos).deleteAll() + precondition(Application.modelState(\.todos).models.isEmpty, "Expected an empty store after deleteAll") + print("Store cleared; \(Application.modelState(\.todos).models.count) todo(s) remaining") + + print("== Example completed successfully ==") + exit(0) + } +} + +#else + +@main +struct SwiftDataExample { + static func main() { + print("SwiftData unavailable on this platform; nothing to demonstrate.") + } +} + +#endif diff --git a/README.md b/README.md index bd87c2b..73febc7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do > 🍎 Features marked with this symbol are specific to Apple platforms, as they rely on Apple technologies such as iCloud and the Keychain. +> Note: SwiftData support (`ModelState`) requires iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+. It is gated with `@available` and does not change AppState's base requirements. + ## Key Features **AppState** includes several powerful features to help manage state and dependencies: @@ -31,6 +33,7 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do - **State**: Centralized state management that allows you to encapsulate and broadcast changes across the app. - **StoredState**: Persistent state using `UserDefaults`, ideal for saving small amounts of data between app launches. - **FileState**: Persistent state stored using `FileManager`, useful for storing larger amounts of data securely on disk. +- 🍎 **SwiftData (ModelState)**: Manage SwiftData `@Model` objects through AppState by injecting a shared `ModelContainer` and reading models with `ModelState`. - 🍎 **SyncState**: Synchronize state across multiple devices using iCloud, ensuring consistency in user preferences and settings. - 🍎 **SecureState**: Store sensitive data securely using the Keychain, protecting user information such as tokens or passwords. - **Dependency Management**: Inject dependencies like network services or database clients across your app for better modularity and testing. @@ -85,6 +88,7 @@ Here’s a detailed breakdown of **AppState**'s documentation: - [Slicing State](documentation/en/usage-slice.md): Access and modify specific parts of the state. - [StoredState Usage Guide](documentation/en/usage-storedstate.md): How to persist lightweight data using `StoredState`. - [FileState Usage Guide](documentation/en/usage-filestate.md): Learn how to persist larger amounts of data securely on disk. +- 🍎 [ModelState Usage Guide](documentation/en/usage-modelstate.md): Manage SwiftData `@Model` objects through a shared `ModelContainer`. - [Keychain SecureState Usage](documentation/en/usage-securestate.md): Store sensitive data securely using the Keychain. - [iCloud Syncing with SyncState](documentation/en/usage-syncstate.md): Keep state synchronized across devices using iCloud. - [FAQ](documentation/en/faq.md): Answers to common questions when using **AppState**. diff --git a/Sources/AppState/Application/Types/Dependency/Application+ModelContainer.swift b/Sources/AppState/Application/Types/Dependency/Application+ModelContainer.swift new file mode 100644 index 0000000..c56451e --- /dev/null +++ b/Sources/AppState/Application/Types/Dependency/Application+ModelContainer.swift @@ -0,0 +1,111 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +public extension Application { + /// Retrieves the `ModelContext` associated with a `ModelContainer` dependency. + /// + /// SwiftData's `ModelContainer` is `Sendable` and can therefore be stored as a regular + /// AppState `Dependency`. Define one on an `Application` extension just like any other + /// dependency: + /// + /// ```swift + /// extension Application { + /// var modelContainer: Dependency { + /// dependency(makeModelContainer()) + /// } + /// } + /// + /// private func makeModelContainer() -> ModelContainer { + /// do { + /// return try ModelContainer(for: Item.self) + /// } catch { + /// fatalError("Failed to create the ModelContainer: \(error)") + /// } + /// } + /// ``` + /// + /// You can then access the shared, main-actor bound `ModelContext` anywhere in your app + /// (including view models and services that have no access to SwiftUI's `@Environment`): + /// + /// ```swift + /// let context = Application.modelContext(\.modelContainer) + /// ``` + /// + /// - Parameters: + /// - keyPath: The `KeyPath` referencing a `Dependency` defined on `Application`. + /// - fileID: The identifier of the file in which this function is called. Defaults to `#fileID`. + /// - function: The name of the declaration in which this function is called. Defaults to `#function`. + /// - line: The line number on which this function is called. Defaults to `#line`. + /// - column: The column number in which this function is called. Defaults to `#column`. + /// - Returns: The `mainContext` of the resolved `ModelContainer`. + @MainActor + static func modelContext( + _ keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ModelContext { + let container = dependency(keyPath, fileID, function, line, column) + + log( + debug: "πŸ—ƒοΈ Getting ModelContext from \(String(describing: keyPath))", + fileID: fileID, + function: function, + line: line, + column: column + ) + + return container.mainContext + } + + /// Defines and retrieves a `Dependency` from an autoclosure. + /// + /// This is a convenience for registering a `ModelContainer` as a dependency with an + /// automatically generated identifier derived from the call site. The autoclosure is + /// evaluated only once, the first time the dependency is accessed. + /// + /// ```swift + /// extension Application { + /// var modelContainer: Dependency { + /// modelContainer(makeModelContainer()) + /// } + /// } + /// + /// private func makeModelContainer() -> ModelContainer { + /// do { + /// return try ModelContainer(for: Item.self) + /// } catch { + /// fatalError("Failed to create the ModelContainer: \(error)") + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - container: An autoclosure that creates and returns the `ModelContainer`. Evaluated only if not cached. + /// - fileID: The calling file's identifier. Automatically captured. + /// - function: The calling function's name. Automatically captured. + /// - line: The line number of the call. Automatically captured. + /// - column: The column number of the call. Automatically captured. + /// - Returns: The `Dependency` instance. + func modelContainer( + _ container: @autoclosure () -> ModelContainer, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> Dependency { + dependency( + container(), + id: Application.codeID( + fileID: fileID, + function: function, + line: line, + column: column + ) + ) + } +} +#endif diff --git a/Sources/AppState/Application/Types/State/Application+ModelState.swift b/Sources/AppState/Application/Types/State/Application+ModelState.swift new file mode 100644 index 0000000..58c896e --- /dev/null +++ b/Sources/AppState/Application/Types/State/Application+ModelState.swift @@ -0,0 +1,298 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension Application { + /// `ModelState` exposes the SwiftData `@Model` objects matching a `FetchDescriptor` through the + /// application's scope. It is backed by a `ModelContainer` dependency and reads/writes through + /// that container's main-actor `ModelContext`. + /// + /// Unlike the other AppState state types, `ModelState` is **not** value-backed and does not store + /// anything in AppState's cache β€” SwiftData's `ModelContext` is the single source of truth. + /// Reading ``models`` performs a live fetch; mutate the store with ``insert(_:)``, + /// ``delete(_:)``, ``save()``, and ``deleteAll()``. + /// + /// - Note: Mutations are not automatically broadcast to SwiftUI. For reactive views use + /// SwiftData's own `@Query` together with the AppState-provided `ModelContainer`; reach for + /// `ModelState` (and the `@ModelState` property wrapper) from view models, services, and other + /// non-view code that needs shared, dependency-injected access to your models. + public struct ModelState { + public static var emoji: Character { "πŸ—ƒοΈ" } + + /// The `KeyPath` to the `ModelContainer` dependency that backs this state. + let containerKeyPath: KeyPath> + + /// A closure producing the `FetchDescriptor` used when reading ``models``. + private let fetchDescriptor: () -> FetchDescriptor + + /// The scope in which this state exists. + let scope: Scope + + /// The `ModelContext` derived from the backing `ModelContainer` dependency. + @MainActor + public var context: ModelContext { + Application.dependency(containerKeyPath).mainContext + } + + /// The models currently matching this state's `FetchDescriptor`. + /// + /// - Important: Reading this property performs a SwiftData **fetch on every access**. Do not + /// read it repeatedly in a hot path or directly inside a SwiftUI `body`; capture it once, or + /// use SwiftData's `@Query` for reactive views. On failure an empty array is returned and + /// the error is logged. + @MainActor + public var models: [Model] { + do { + return try context.fetch(fetchDescriptor()) + } catch { + log( + error: error, + message: "\(ModelState.emoji) ModelState Fetching", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + + return [] + } + } + + /** + Creates a new model state within a given scope. + + - Parameters: + - containerKeyPath: The `KeyPath` to the `ModelContainer` dependency that backs this state. + - fetchDescriptor: A closure producing the `FetchDescriptor` used to read the models. + - scope: The scope in which the state exists. + */ + init( + containerKeyPath: KeyPath>, + fetchDescriptor: @escaping () -> FetchDescriptor, + scope: Scope + ) { + self.containerKeyPath = containerKeyPath + self.fetchDescriptor = fetchDescriptor + self.scope = scope + } + + /// Inserts a model into the backing `ModelContext` and saves. + /// + /// - Parameter model: The model to insert. + @MainActor + public func insert(_ model: Model) { + let context = context + context.insert(model) + save(context: context, action: "Inserting") + } + + /// Deletes a model from the backing `ModelContext` and saves. + /// + /// - Parameter model: The model to delete. + @MainActor + public func delete(_ model: Model) { + let context = context + context.delete(model) + save(context: context, action: "Deleting") + } + + /// Persists any pending changes in the backing `ModelContext`. + @MainActor + public func save() { + save(context: context, action: "Saving") + } + + /// Deletes **every** model matching this state's `FetchDescriptor` and saves. + /// + /// - Warning: This permanently removes the matching objects from the persistent store. It is a + /// destructive operation; there is no `reset()`-style restoration of an initial value because + /// the store itself is the source of truth. + @MainActor + public func deleteAll() { + let context = context + + do { + let models = try context.fetch(fetchDescriptor()) + + for model in models { + context.delete(model) + } + + save(context: context, action: "Deleting") + } catch { + log( + error: error, + message: "\(ModelState.emoji) ModelState Deleting", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + } + } + + @MainActor + private func save(context: ModelContext, action: String) { + guard context.hasChanges else { return } + + do { + try context.save() + } catch { + log( + error: error, + message: "\(ModelState.emoji) ModelState \(action)", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + } + } + } +} + +// MARK: - ModelState Functions + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +public extension Application { + /** + Retrieves a `ModelState` instance from the shared `Application` using its `KeyPath`. + + This function provides access to the `ModelState` management object itself, which is backed by + a SwiftData `ModelContainer`. You can use it to read its `models` or perform mutations + (`insert`, `delete`, `save`, `deleteAll`). + + - Parameters: + - keyPath: The `KeyPath` referencing the desired `ModelState` property (e.g., `\.items`). + - fileID: The identifier of the file in which this function is called. Defaults to `#fileID`. + - function: The name of the declaration in which this function is called. Defaults to `#function`. + - line: The line number on which this function is called. Defaults to `#line`. + - column: The column number in which this function is called. Defaults to `#column`. + - Returns: The requested `ModelState` instance. + */ + @MainActor + static func modelState( + _ keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ModelState { + let modelState = shared.value(keyPath: keyPath) + + log( + debug: "πŸ—ƒοΈ Getting ModelState \(String(describing: keyPath))", + fileID: fileID, + function: function, + line: line, + column: column + ) + + return modelState + } + + /** + Defines and retrieves a `ModelState` instance backed by a `ModelContainer` dependency, + associated with a specific feature and identifier, using the supplied `FetchDescriptor`. + + - Parameters: + - container: The `KeyPath` to the `ModelContainer` dependency that backs this state. + - fetchDescriptor: An autoclosure providing the `FetchDescriptor` used to read the models. + - feature: A `String` namespacing the state, often corresponding to a feature module. Defaults to "App". + - id: A `String` uniquely identifying the state within its feature scope. + - Returns: The `ModelState` instance. + */ + func modelState( + container: KeyPath>, + fetchDescriptor: @escaping @autoclosure () -> FetchDescriptor, + feature: String = "App", + id: String + ) -> ModelState { + ModelState( + containerKeyPath: container, + fetchDescriptor: fetchDescriptor, + scope: Scope(name: feature, id: id) + ) + } + + /// Defines and retrieves a `ModelState` instance backed by a `ModelContainer` dependency, + /// associated with a specific feature and identifier, fetching all models of the type. + /// + /// - Parameters: + /// - container: The `KeyPath` to the `ModelContainer` dependency that backs this state. + /// - feature: A `String` namespacing the state. Defaults to "App". + /// - id: A `String` uniquely identifying the state within its feature scope. + /// - Returns: The `ModelState` instance. + func modelState( + container: KeyPath>, + feature: String = "App", + id: String + ) -> ModelState { + modelState( + container: container, + fetchDescriptor: FetchDescriptor(), + feature: feature, + id: id + ) + } + + /// Defines and retrieves a `ModelState` instance with an automatically generated + /// identifier derived from the call site's context, using the supplied `FetchDescriptor`. + /// + /// - Parameters: + /// - container: The `KeyPath` to the `ModelContainer` dependency that backs this state. + /// - fetchDescriptor: An autoclosure providing the `FetchDescriptor`. + /// - fileID: The calling file's identifier. Automatically captured. + /// - function: The calling function's name. Automatically captured. + /// - line: The line number of the call. Automatically captured. + /// - column: The column number of the call. Automatically captured. + /// - Returns: The `ModelState` instance. + func modelState( + container: KeyPath>, + fetchDescriptor: @escaping @autoclosure () -> FetchDescriptor, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ModelState { + modelState( + container: container, + fetchDescriptor: fetchDescriptor(), + id: Application.codeID( + fileID: fileID, + function: function, + line: line, + column: column + ) + ) + } + + /// Defines and retrieves a `ModelState` instance with an automatically generated + /// identifier derived from the call site's context, fetching all models of the type. + /// + /// - Parameters: + /// - container: The `KeyPath` to the `ModelContainer` dependency that backs this state. + /// - fileID: The calling file's identifier. Automatically captured. + /// - function: The calling function's name. Automatically captured. + /// - line: The line number of the call. Automatically captured. + /// - column: The column number of the call. Automatically captured. + /// - Returns: The `ModelState` instance. + func modelState( + container: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> ModelState { + modelState( + container: container, + fetchDescriptor: FetchDescriptor(), + fileID, + function, + line, + column + ) + } +} +#endif diff --git a/Sources/AppState/PropertyWrappers/State/ModelState.swift b/Sources/AppState/PropertyWrappers/State/ModelState.swift new file mode 100644 index 0000000..6d604e2 --- /dev/null +++ b/Sources/AppState/PropertyWrappers/State/ModelState.swift @@ -0,0 +1,81 @@ +#if canImport(SwiftData) +import Combine +import SwiftData +import SwiftUI + +/// `ModelState` is a property wrapper that exposes the SwiftData `@Model` objects matching a +/// `FetchDescriptor` from the `Application`'s scope. The models are read from and written to a +/// `ModelContainer` dependency. +/// +/// The wrapped value is **read-only** and performs a live fetch on access. Mutate the store through +/// the projected value, which exposes the underlying ``Application/ModelState`` and its +/// ``Application/ModelState/insert(_:)``, ``Application/ModelState/delete(_:)``, +/// ``Application/ModelState/save()``, and ``Application/ModelState/deleteAll()`` operations. +/// +/// - Note: Mutations made through `ModelState` are not automatically broadcast to SwiftUI. For +/// reactive views, use SwiftData's `@Query` together with the AppState-provided `ModelContainer`. +/// `ModelState` is best suited to view models, services, and other non-view code. +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +@propertyWrapper public struct ModelState { + /// Holds the singleton instance of `Application`. + @ObservedObject private var app: Application = Application.shared + + /// Path for accessing `ModelState` from Application. + private let keyPath: KeyPath> + + private let fileID: StaticString + private let function: StaticString + private let line: Int + private let column: Int + + /// The models currently matching this state's `FetchDescriptor`. + /// + /// Reading this performs a live SwiftData fetch. To mutate the store, use the projected value + /// (`$model.insert(_:)`, `$model.delete(_:)`, `$model.save()`, `$model.deleteAll()`). + @MainActor + public var wrappedValue: [Model] { + Application.modelState( + keyPath, + fileID, + function, + line, + column + ).models + } + + /// The underlying ``Application/ModelState``, exposing `insert`, `delete`, `save`, and `deleteAll`. + @MainActor + public var projectedValue: Application.ModelState { + Application.modelState( + keyPath, + fileID, + function, + line, + column + ) + } + + /** + Initializes the ModelState with a `keyPath` for accessing `ModelState` in Application. + + - Parameter keyPath: The `KeyPath` for accessing `ModelState` in Application. + */ + @MainActor + public init( + _ keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + self.keyPath = keyPath + self.fileID = fileID + self.function = function + self.line = line + self.column = column + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +extension ModelState: DynamicProperty { } +#endif diff --git a/Tests/AppStateTests/ModelStateTests.swift b/Tests/AppStateTests/ModelStateTests.swift new file mode 100644 index 0000000..066026f --- /dev/null +++ b/Tests/AppStateTests/ModelStateTests.swift @@ -0,0 +1,192 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData +import XCTest +@testable import AppState + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +@Model +final class TestItem { + var title: String + var value: Int + + init(title: String, value: Int) { + self.title = title + self.value = value + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +fileprivate extension Application { + var modelContainer: Dependency { + modelContainer( + try! ModelContainer( + for: TestItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + ) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } + + var sortedItems: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.value, order: .forward)] + ), + id: "sortedItems" + ) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +@MainActor +fileprivate struct ExampleModelValue { + @ModelState(\.items) var items +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +@MainActor +fileprivate class ExampleModelViewModel { + @ModelState(\.items) var items + + func addItem(title: String, value: Int) { + $items.insert(TestItem(title: title, value: value)) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +final class ModelStateTests: XCTestCase { + @MainActor + override func setUp() async throws { + Application.logging(isEnabled: true) + + Application.modelState(\.items).deleteAll() + XCTAssertTrue(Application.modelState(\.items).models.isEmpty) + } + + @MainActor + override func tearDown() async throws { + Application.modelState(\.items).deleteAll() + + let applicationDescription = Application.description + + Application.dependency(\.logger).debug("ModelStateTests \(applicationDescription)") + } + + @MainActor + func testModelContextDependency() async { + let context = Application.modelContext(\.modelContainer) + let sameContext = Application.modelContext(\.modelContainer) + + XCTAssertTrue(context === sameContext) + + let item = TestItem(title: "Direct", value: 42) + context.insert(item) + try? context.save() + + let fetched = try? context.fetch(FetchDescriptor()) + + XCTAssertEqual(fetched?.count, 1) + XCTAssertEqual(fetched?.first?.title, "Direct") + XCTAssertEqual(fetched?.first?.value, 42) + } + + @MainActor + func testInsertAndFetchThroughApplication() async { + let state = Application.modelState(\.items) + + XCTAssertTrue(state.models.isEmpty) + + state.insert(TestItem(title: "First", value: 1)) + state.insert(TestItem(title: "Second", value: 2)) + + let models = state.models + + XCTAssertEqual(models.count, 2) + XCTAssertTrue(models.contains { $0.title == "First" && $0.value == 1 }) + XCTAssertTrue(models.contains { $0.title == "Second" && $0.value == 2 }) + } + + @MainActor + func testPropertyWrapperReadAndProjectedInsert() async { + let example = ExampleModelValue() + + XCTAssertTrue(example.items.isEmpty) + + example.$items.insert(TestItem(title: "Wrapped", value: 7)) + + XCTAssertEqual(example.items.count, 1) + XCTAssertEqual(example.items.first?.title, "Wrapped") + XCTAssertEqual(example.items.first?.value, 7) + + let viewModel = ExampleModelViewModel() + + XCTAssertEqual(viewModel.items.count, 1) + + viewModel.addItem(title: "ViewModel", value: 9) + + XCTAssertEqual(viewModel.items.count, 2) + XCTAssertTrue(viewModel.items.contains { $0.title == "ViewModel" && $0.value == 9 }) + + XCTAssertEqual(Application.modelState(\.items).models.count, 2) + } + + @MainActor + func testProjectedValueCRUD() async { + let example = ExampleModelValue() + + let first = TestItem(title: "Alpha", value: 1) + let second = TestItem(title: "Beta", value: 2) + + example.$items.insert(first) + example.$items.insert(second) + + XCTAssertEqual(example.items.count, 2) + + example.$items.delete(first) + + XCTAssertEqual(example.items.count, 1) + XCTAssertEqual(example.items.first?.title, "Beta") + + second.value = 99 + example.$items.save() + + XCTAssertEqual(Application.modelState(\.items).models.first?.value, 99) + } + + @MainActor + func testDeleteAll() async { + let state = Application.modelState(\.items) + + state.insert(TestItem(title: "One", value: 1)) + state.insert(TestItem(title: "Two", value: 2)) + state.insert(TestItem(title: "Three", value: 3)) + + XCTAssertEqual(state.models.count, 3) + + state.deleteAll() + + XCTAssertTrue(Application.modelState(\.items).models.isEmpty) + } + + @MainActor + func testFetchDescriptorSorting() async { + let items = Application.modelState(\.items) + + items.insert(TestItem(title: "C", value: 30)) + items.insert(TestItem(title: "A", value: 10)) + items.insert(TestItem(title: "B", value: 20)) + + let sorted = Application.modelState(\.sortedItems) + let sortedModels = sorted.models + + XCTAssertEqual(sortedModels.count, 3) + XCTAssertEqual(sortedModels.map(\.value), [10, 20, 30]) + XCTAssertEqual(sortedModels.map(\.title), ["A", "B", "C"]) + } +} +#endif diff --git a/documentation/en/usage-modelstate.md b/documentation/en/usage-modelstate.md new file mode 100644 index 0000000..b14f9fa --- /dev/null +++ b/documentation/en/usage-modelstate.md @@ -0,0 +1,296 @@ +# ModelState Usage + +🍎 `ModelState` is a component of the **AppState** library that lets you manage SwiftData `@Model` objects through the application's scope. It injects a shared SwiftData `ModelContainer` as a dependency and reads from and writes to that container's `ModelContext`, giving view models, services, and other non-view code shared, dependency-injected access to your models. + +> 🍎 `ModelState` and the SwiftData `ModelContainer` dependency are specific to Apple platforms, as they rely on Apple's SwiftData framework. + +## Key Features + +- **Dependency-Injected Models**: Register a shared `ModelContainer` once and access its models anywhere in your app. +- **Main-Actor `ModelContext`**: Retrieve the container's `mainContext` from any code, including view models and services that have no access to SwiftUI's `@Environment`. +- **CRUD Convenience**: Read, insert, delete, save, and delete-all SwiftData models through a small, focused API. +- **SwiftData as the Source of Truth**: `ModelState` does not cache results in AppState's cache β€” SwiftData's `ModelContext` remains the single source of truth. + +## Requirements & Availability + +SwiftData features require newer platform versions than AppState's base requirements. All `ModelState` and `ModelContainer` APIs are gated behind `#if canImport(SwiftData)` and the following availability: + +- **iOS**: 17.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ +- **watchOS**: 10.0+ +- **visionOS**: 1.0+ + +On platforms or OS versions where SwiftData is unavailable, these APIs are not compiled in. + +## Registering the ModelContainer Dependency + +SwiftData's `ModelContainer` is `Sendable`, so it can be stored as a regular AppState `Dependency`. Define one on an `Application` extension using the `modelContainer(_:)` convenience, which registers the container with an automatically generated identifier and evaluates the autoclosure only once. Build the container through a helper that handles failures explicitly rather than force-trying: + +```swift +import AppState +import SwiftData + +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create the ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Accessing the ModelContext + +Once a `ModelContainer` dependency is defined, you can access the shared, main-actor bound `ModelContext` anywhere in your app: + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +This returns the `mainContext` of the resolved `ModelContainer`, so the same context is shared throughout your app. + +## Defining a ModelState + +Define a `ModelState` by extending the `Application` object and pointing it at the `ModelContainer` dependency that backs it. With no `FetchDescriptor`, the state matches all models of the given type: + +```swift +import AppState +import SwiftData + +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +You can also provide a custom `FetchDescriptor` (for filtering or sorting) and an explicit `id`: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## The @ModelState Property Wrapper + +The `@ModelState` property wrapper exposes a read-only collection of models from the `Application`'s scope. Mutate through the projected value (`$items`): + +```swift +import AppState +import SwiftData + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func addItem(title: String) { + $items.insert(Item(title: title)) + } +} +``` + +- **Reading** the wrapped value performs a fetch using the state's `FetchDescriptor`. The wrapped value is a read-only `[Model]` β€” you cannot assign to it. +- **Mutating** is done through the projected value: `$items.insert(...)`, `$items.delete(...)`, `$items.save()`, and `$items.deleteAll()`. + +> ⚠️ Reading the wrapped value performs a live SwiftData fetch on **every** read. Avoid reading it repeatedly in hot paths β€” capture the result in a local instead. + +### CRUD via the Projected Value + +The projected value (`$items`) exposes the underlying `Application.ModelState`, giving you explicit control over inserts, deletes, and saves: + +```swift +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } + + func remove(_ item: Item) { + $items.delete(item) + } + + func persistPendingChanges() { + $items.save() + } +} +``` + +## Reading and Mutating via Application.modelState + +You can also work with the `ModelState` directly through the `Application` type, without a property wrapper. This is convenient in services and other non-view code: + +```swift +@MainActor +func loadAndAppend() { + let state = Application.modelState(\.items) + + // Read the current models (performs a fetch on every access). + let current = state.models + + // Access the backing ModelContext directly if needed. + let context = state.context + + // Insert, delete, and save. + state.insert(Item(title: "New item")) + state.delete(current.first!) + state.save() +} +``` + +> ⚠️ `models` performs a live SwiftData fetch on **every** read. Capture it in a local when you need to use the result more than once instead of reading it repeatedly. + +The returned `ModelState` exposes: + +- `models`: a **read-only** property returning the models currently matching the state's `FetchDescriptor`. Every read performs a fresh fetch; there is no setter. +- `context`: the backing main-actor `ModelContext`. +- `insert(_:)`: inserts a model and saves. +- `delete(_:)`: deletes a model and saves. +- `save()`: persists any pending changes in the context. +- `deleteAll()`: deletes every model matching the state's `FetchDescriptor` and saves. + +## Deleting All Models + +To delete every model managed by a `ModelState`, use `deleteAll()`: + +```swift +Application.modelState(\.items).deleteAll() +``` + +This fetches every model matching the state's `FetchDescriptor`, deletes it, and saves the context. + +## When to Use ModelState vs SwiftData @Query + +Mutations made through `ModelState` and `@ModelState` are **not** automatically broadcast to SwiftUI. This is an intentional design choice: + +- **Use SwiftData's own `@Query` for reactive views.** `@Query` observes the `ModelContext` and automatically refreshes your view when the underlying data changes. Combine it with the AppState-provided `ModelContainer` so your views and your non-view code share the same container: + + ```swift + import SwiftData + import SwiftUI + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { item in + Text(item.title) + } + } + } + + // Inject the shared container into the SwiftUI environment. + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + ``` + +- **Use `ModelState` / `@ModelState` for view models, services, and other non-view code** that needs shared, dependency-injected access to your models. It is ideal where SwiftUI's `@Environment` and `@Query` are not available, or where you want to perform model operations outside of view code. + +Also note that the models collection is read-only β€” you cannot assign to it. Use `insert(_:)`, `delete(_:)`, or `deleteAll()` to mutate the underlying store. + +## End-to-End Example + +The following example shows a complete flow: a `@Model`, the `Application` extensions registering the container and the model state, and a view model that uses `@ModelState`. + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the SwiftData model. +@Model +final class TodoItem { + var title: String + var isComplete: Bool + + init(title: String, isComplete: Bool = false) { + self.title = title + self.isComplete = isComplete + } +} + +// 2. Register the shared ModelContainer and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create the ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } + + var todoItems: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "todoItems" + ) + } +} + +// 3. Use @ModelState from a view model. +@MainActor +final class TodoListViewModel: ObservableObject { + @ModelState(\.todoItems) var todoItems: [TodoItem] + + func add(title: String) { + $todoItems.insert(TodoItem(title: title)) + } + + func toggle(_ item: TodoItem) { + item.isComplete.toggle() + $todoItems.save() + } + + func remove(_ item: TodoItem) { + $todoItems.delete(item) + } + + func clearAll() { + $todoItems.deleteAll() + } +} +``` + +For a reactive list bound to the same data, drive the view with SwiftData's `@Query` while keeping mutations in the view model, as shown in the [When to Use ModelState vs SwiftData @Query](#when-to-use-modelstate-vs-swiftdata-query) section above. + +## Best Practices + +- **Reactive Views Use `@Query`**: Reserve SwiftData's `@Query` for views that need to update automatically, and share the AppState-provided `ModelContainer` with them. +- **Non-View Code Uses `ModelState`**: Use `@ModelState` and `Application.modelState` in view models, services, and background logic that need shared model access. +- **Explicit Mutation**: The models collection is read-only; use `insert(_:)`, `delete(_:)`, or `deleteAll()` to change the underlying store. +- **One Shared Container**: Register a single `ModelContainer` dependency and reference it from your model states and SwiftUI environment so everything reads and writes the same store. + +## Conclusion + +`ModelState` brings SwiftData into the **AppState** dependency-injection model, letting you share a single `ModelContainer` across your app and work with `@Model` objects from view models and services. For reactive UI, pair it with SwiftData's `@Query` and the same shared container. diff --git a/documentation/en/usage-overview.md b/documentation/en/usage-overview.md index 3a0a326..cc5372f 100644 --- a/documentation/en/usage-overview.md +++ b/documentation/en/usage-overview.md @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` manages SwiftData `@Model` objects through AppState by injecting a shared `ModelContainer`. It is intended for view models, services, and other non-view code; for reactive views, use SwiftData's `@Query` together with the AppState-provided `ModelContainer`. SwiftData features require iOS 17+ / macOS 14+. + +### Example + +```swift +import AppState +import SwiftData + +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create the ModelContainer: \(error)") + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +For more details, see the [ModelState Usage Guide](usage-modelstate.md). + ## SecureState `SecureState` stores sensitive data securely in the Keychain. @@ -206,6 +246,7 @@ struct SlicingView: View { After familiarizing yourself with the basic usage, you can explore more advanced topics: - Explore using **FileState** for persisting large amounts of data to files in the [FileState Usage Guide](usage-filestate.md). +- 🍎 Learn how to manage **SwiftData** models through AppState in the [ModelState Usage Guide](usage-modelstate.md). - Learn about **Constants** and how to use them for immutable values in your app's state in the [Constant Usage Guide](usage-constant.md). - Investigate how **Dependency** is used in AppState to handle shared services, and see examples in the [State Dependency Usage Guide](usage-state-dependency.md). - Delve deeper into **Advanced SwiftUI** techniques like using `ObservedDependency` for managing observable dependencies in views in the [ObservedDependency Usage Guide](usage-observeddependency.md).