diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml index f922ffe..6c96f4d 100644 --- a/.github/workflows/docc.yml +++ b/.github/workflows/docc.yml @@ -18,7 +18,7 @@ jobs: environment: name: github-pages url: '${{ steps.deployment.outputs.page_url }}' - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout code @@ -27,12 +27,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.0 - - - name: Set up Swift - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.1.0' + xcode-version: latest-stable - name: Build and Export DocC run: | diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index 49e20f9..f8789f6 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -8,17 +8,17 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.0 - - name: Set up Swift - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.1.0' + xcode-version: latest-stable - uses: actions/checkout@v4 - name: Build run: swift build -v + env: + APPSTATE_STRICT: "1" - name: Run tests run: swift test -v + env: + APPSTATE_STRICT: "1" diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index d9a6a73..39a44a0 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -21,5 +21,9 @@ jobs: - uses: actions/checkout@v4 - name: Build for release run: swift build -v -c release + env: + APPSTATE_STRICT: "1" - name: Test run: swift test -v + env: + APPSTATE_STRICT: "1" diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 9bb5e71..ab453d7 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -21,5 +21,11 @@ jobs: # ② Build & test - run: swift --version # sanity-check - - run: swift build - - run: swift test + - name: Build + run: swift build + env: + APPSTATE_STRICT: "1" + - name: Test + run: swift test + env: + APPSTATE_STRICT: "1" diff --git a/.gitignore b/.gitignore index bbfe0c3..19686b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ .DS_Store /.build +.build/ /Packages xcuserdata/ DerivedData/ .swiftpm/ .netrc -Package.resolved \ No newline at end of file +Package.resolved +# xcodegen-generated demo project + derived data +Examples/DemoApp/*.xcodeproj/ +Examples/DemoApp/.build-dd/ diff --git a/.specsync/.gitignore b/.specsync/.gitignore new file mode 100644 index 0000000..a655086 --- /dev/null +++ b/.specsync/.gitignore @@ -0,0 +1,3 @@ +backup-3x/ +config.local.toml +hashes.json diff --git a/.specsync/config.toml b/.specsync/config.toml new file mode 100644 index 0000000..2e2c511 --- /dev/null +++ b/.specsync/config.toml @@ -0,0 +1,12 @@ +# spec-sync v4 configuration +# Docs: https://github.com/CorvidLabs/spec-sync + +specs_dir = "specs" +source_dirs = ["Sources"] +exclude_dirs = [] +exclude_patterns = [] +required_sections = ["Purpose", "Public API", "Invariants", "Behavioral Examples", "Error Cases", "Dependencies", "Change Log"] +enforcement = "strict" + +[lifecycle] +track_history = false diff --git a/.specsync/registry.toml b/.specsync/registry.toml new file mode 100644 index 0000000..af06fc9 --- /dev/null +++ b/.specsync/registry.toml @@ -0,0 +1,7 @@ +[registry] +name = "AppState" + +[specs] +application = "specs/application/application.spec.md" +property-wrappers = "specs/property-wrappers/property-wrappers.spec.md" +swiftdata = "specs/swiftdata/swiftdata.spec.md" diff --git a/.specsync/version b/.specsync/version new file mode 100644 index 0000000..f77856a --- /dev/null +++ b/.specsync/version @@ -0,0 +1 @@ +4.3.1 diff --git a/Package.swift b/Package.swift index 2845cb1..de20376 100644 --- a/Package.swift +++ b/Package.swift @@ -1,14 +1,22 @@ // swift-tools-version: 6.0 +import Foundation import PackageDescription +// Opt-in strict build, used by CI only. Treats warnings as errors for *our* targets without +// forcing the flag onto dependencies (e.g. Cache) or onto downstream consumers — `unsafeFlags` +// would otherwise make AppState unusable as a dependency. Enabled when `APPSTATE_STRICT` is set. +let strictSwiftSettings: [SwiftSetting] = ProcessInfo.processInfo.environment["APPSTATE_STRICT"] != nil + ? [.unsafeFlags(["-warnings-as-errors"])] + : [] + let package = Package( name: "AppState", platforms: [ - .iOS(.v15), - .watchOS(.v8), - .macOS(.v11), - .tvOS(.v15), + .iOS(.v17), + .watchOS(.v10), + .macOS(.v14), + .tvOS(.v17), .visionOS(.v1) ], products: [ @@ -26,11 +34,16 @@ let package = Package( name: "AppState", dependencies: [ "Cache" - ] + ], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny") + ] + strictSwiftSettings ), .testTarget( name: "AppStateTests", - dependencies: ["AppState"] + dependencies: ["AppState"], + swiftSettings: strictSwiftSettings ) - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index bd87c2b..353e120 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](documentation/README.de.md) | [Hindi](documentation/README.hi.md) | [Portuguese](documentation/README.pt.md) | [Russian](documentation/README.ru.md) | [Simplified Chinese](documentation/README.zh-CN.md) | [Spanish](documentation/README.es.md) -**AppState** is a Swift 6 library designed to simplify the management of application state in a thread-safe, type-safe, and SwiftUI-friendly way. It provides a set of tools to centralize and synchronize state across your application, as well as inject dependencies into various parts of your app. +**AppState** is a Swift 6 library for managing application state in a thread-safe, type-safe, and SwiftUI-friendly way. Centralize and synchronize state across your app; inject dependencies anywhere. ## Requirements -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -26,11 +26,12 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do ## Key Features -**AppState** includes several powerful features to help manage state and dependencies: +**AppState** includes: - **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/writing 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. @@ -40,14 +41,10 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do ## Getting Started -To integrate **AppState** into your Swift project, you’ll need to use the Swift Package Manager. Follow the [Installation Guide](documentation/en/installation.md) for detailed instructions on setting up **AppState**. - -After installation, refer to the [Usage Overview](documentation/en/usage-overview.md) for a quick introduction on how to manage state and inject dependencies into your project. +Add **AppState** via Swift Package Manager — see the [Installation Guide](documentation/en/installation.md). Then check the [Usage Overview](documentation/en/usage-overview.md) for a quick introduction. ## Quick Example -Below is a minimal example showing how to define a piece of state and access it from a SwiftUI view: - ```swift import AppState import SwiftUI @@ -70,8 +67,6 @@ struct ContentView: View { } ``` -This snippet demonstrates defining a state value in an `Application` extension and using the `@AppState` property wrapper to bind it inside a view. - ## Documentation Here’s a detailed breakdown of **AppState**'s documentation: @@ -85,8 +80,10 @@ 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. +- [Upgrading to AppState 3.0](documentation/en/upgrade-to-v3.md): Breaking changes and how to migrate from the 2.x release line. - [FAQ](documentation/en/faq.md): Answers to common questions when using **AppState**. - [Constant Usage Guide](documentation/en/usage-constant.md): Access read-only values from your state. - [ObservedDependency Usage Guide](documentation/en/usage-observeddependency.md): Work with `ObservableObject` dependencies in your views. @@ -100,4 +97,4 @@ We welcome contributions! Please check out our [Contributing Guide](documentatio ## Next Steps -With **AppState** installed, you can start exploring its key features by checking out the [Usage Overview](documentation/en/usage-overview.md) and more detailed guides. Get started with managing state and dependencies effectively in your Swift projects! For more advanced usage techniques, like Just-In-Time creation and preloading dependencies, see the [Advanced Usage Guide](documentation/en/advanced-usage.md). You can also review the [Constant](documentation/en/usage-constant.md) and [ObservedDependency](documentation/en/usage-observeddependency.md) guides for additional features. +Start with the [Usage Overview](documentation/en/usage-overview.md). For Just-In-Time creation and preloading, see the [Advanced Usage Guide](documentation/en/advanced-usage.md). The [Constant](documentation/en/usage-constant.md) and [ObservedDependency](documentation/en/usage-observeddependency.md) guides cover additional features. diff --git a/Sources/AppState/Application/Application+internal.swift b/Sources/AppState/Application/Application+internal.swift index 1762cff..bbde143 100644 --- a/Sources/AppState/Application/Application+internal.swift +++ b/Sources/AppState/Application/Application+internal.swift @@ -3,7 +3,7 @@ extension Application { static var cacheDescription: String { shared.cache.allValues .map { key, value in - if let value = value as? Loggable { + if let value = value as? any Loggable { "\t- \(value.logValue)" } else { "\t- \(value)" @@ -81,7 +81,7 @@ extension Application { /// Internal log function. @MainActor static func log( - error: Error, + error: any Error, message: String, fileID: StaticString, function: StaticString, diff --git a/Sources/AppState/Application/Application+public.swift b/Sources/AppState/Application/Application+public.swift index 74b00fb..5274f31 100644 --- a/Sources/AppState/Application/Application+public.swift +++ b/Sources/AppState/Application/Application+public.swift @@ -47,7 +47,7 @@ public extension Application { // Example updating all SyncState in SwiftUI Views. DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } @@ -469,6 +469,11 @@ public extension Application { _ line: Int = #line, _ column: Int = #column ) -> ApplicationState where ApplicationState.Value == Value { + // Register the current Observation tracking scope so that reads through this imperative + // accessor (not just through the `@AppState`/`@StoredState`/… property wrappers) drive + // SwiftUI/`withObservationTracking` updates. Outside a tracking scope this is a no-op. + shared.registerObservation() + let appState = shared.value(keyPath: keyPath) log( diff --git a/Sources/AppState/Application/Application.swift b/Sources/AppState/Application/Application.swift index 8c956d7..cf553e8 100644 --- a/Sources/AppState/Application/Application.swift +++ b/Sources/AppState/Application/Application.swift @@ -1,4 +1,5 @@ import Cache +import Observation #if !os(Linux) && !os(Windows) import Combine import OSLog @@ -7,6 +8,7 @@ import Foundation #endif /// `Application` is a class that can be observed for changes, keeping track of the states within the application. +@Observable open class Application: NSObject { /// Singleton shared instance of `Application` @MainActor @@ -28,13 +30,30 @@ open class Application: NSObject { static var isLoggingEnabled: Bool = false /// A recursive lock to ensure thread-safe access to shared resources within the Application instance. + @ObservationIgnored let lock: NSRecursiveLock /// The underlying cache used to store all state and dependency values. + @ObservationIgnored let cache: Cache + /// Observation anchor that bridges cache changes to the Observation framework. + /// + /// State and dependency values live in the untracked ``cache``. Reading this anchor (via + /// ``registerObservation()``) registers the current Observation tracking scope — e.g. a SwiftUI + /// view body — as dependent on AppState, and mutating it (via ``notifyChange()``) tells those + /// observers to update. + /// + /// Thread-safety: every mutation funnels through ``notifyChange()``, which AppState only invokes + /// from its main-actor setters and from the cache observer below — and that observer fires + /// *synchronously* during those same main-actor cache mutations. Reads occur on the main actor + /// (SwiftUI bodies and the `@MainActor` property wrappers). The mutation itself is applied through + /// the synthesized `@Observable` registrar, which is `Sendable` and internally synchronized. + private var changeAnchor: Int = 0 + #if !os(Linux) && !os(Windows) /// A set to store cancellables for Combine subscriptions, ensuring they are properly managed and released. + @ObservationIgnored private var bag: Set = Set() deinit { bag.removeAll() } @@ -64,6 +83,35 @@ open class Application: NSObject { #endif } + /// Registers the current Observation tracking scope (such as a SwiftUI view body) as dependent on + /// AppState. The property wrappers call this when reading their value so that SwiftUI views update + /// when the underlying state or dependencies change. + /// + /// The discarded read of ``changeAnchor`` is intentional and load-bearing: the synthesized + /// `@Observable` getter calls the observation registrar's `access`, which is what records the + /// dependency. Do not remove it. + func registerObservation() { + _ = changeAnchor + } + + /// Notifies observers (such as SwiftUI views) that an AppState value changed. + /// + /// AppState's own setters call this automatically. Call it yourself when you mutate state outside + /// of those setters — for example from a `didChangeExternally(notification:)` override that reacts + /// to incoming iCloud changes. See ``changeAnchor`` for the thread-safety invariant. + /// + /// - Important: This must be called on the main thread. SwiftUI tracks `changeAnchor` from view + /// bodies, so publishing a change from a background thread both races the mutation and trips the + /// "Publishing changes from background threads is not supported" runtime warning. `Application` is + /// not `Sendable`, so the change cannot be hopped to the main thread on the caller's behalf — the + /// invariant is instead asserted here so off-main misuse surfaces in debug and CI. `Application`'s + /// setters and the `@MainActor` `didChangeExternally(notification:)` override already satisfy it. + public func notifyChange() { + assert(Thread.isMainThread, "Application.notifyChange() must be called on the main thread.") + + changeAnchor &+= 1 + } + #if !os(Linux) && !os(Windows) /** Called when the value of one or more keys in the local key-value store changed due to incoming data pushed from iCloud. @@ -110,12 +158,24 @@ open class Application: NSObject { /// - Parameter object: The ObservableObject to observe private func consume( object: Object - ) where ObjectWillChangePublisher == ObservableObjectPublisher { + ) { bag.insert( object.objectWillChange.sink( receiveCompletion: { _ in }, receiveValue: { [weak self] _ in - self?.objectWillChange.send() + // Deliver synchronously when already on main (preserving the synchronous + // observation contract `withObservationTracking` relies on), and only hop to main + // when a dependency publishes off-main — `notifyChange()` asserts main-thread and + // mutates `changeAnchor`, which must never be touched off the main thread. + if Thread.isMainThread { + self?.notifyChange() + } else { + DispatchQueue.main.async { + MainActor.assumeIsolated { + Application.shared.notifyChange() + } + } + } } ) ) @@ -123,9 +183,3 @@ open class Application: NSObject { #endif } -#if !os(Linux) && !os(Windows) -/// Conform `Application` to `ObservableObject` to allow SwiftUI views to subscribe to its changes. -/// This enables the `@ObservedObject` property wrapper to work with `Application.shared` or custom instances, -/// triggering view updates when `objectWillChange` is published. -extension Application: ObservableObject { } -#endif 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..b78829a --- /dev/null +++ b/Sources/AppState/Application/Types/Dependency/Application+ModelContainer.swift @@ -0,0 +1,110 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData + +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/Dependency/Slice/Application+DependencySlice.swift b/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift index d7a4b8e..e43dbc9 100644 --- a/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift +++ b/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift @@ -35,7 +35,7 @@ extension Application.DependencySlice where SliceKeyPath == WritableKeyPath Void - private var errorClosure: @Sendable (Error, String?) -> Void + private var errorClosure: @Sendable (any Error, String?) -> Void /// Initializes the `ApplicationLogger` struct with custom behaviors for each closure. /// - Parameters: @@ -18,7 +18,7 @@ public struct ApplicationLogger: Sendable { /// - errorWithString: A closure for logging error messages as strings. public init( debug: @Sendable @escaping (String) -> Void = { print($0) }, - error: @Sendable @escaping (Error, String?) -> Void = { error, message in + error: @Sendable @escaping (any Error, String?) -> Void = { error, message in if let message = message { print("\(message) (Error: \(error.localizedDescription))") } else { @@ -46,7 +46,7 @@ public struct ApplicationLogger: Sendable { /// - Parameters: /// - error: The error that occurred. /// - message: An optional custom message to accompany the error. - public func error(_ error: Error, _ message: String? = nil) { + public func error(_ error: any Error, _ message: String? = nil) { errorClosure(error, message) } diff --git a/Sources/AppState/Application/Types/State/Application+FileState.swift b/Sources/AppState/Application/Types/State/Application+FileState.swift index c8bc3be..ad53f34 100644 --- a/Sources/AppState/Application/Types/State/Application+FileState.swift +++ b/Sources/AppState/Application/Types/State/Application+FileState.swift @@ -34,7 +34,7 @@ extension Application { } /// The shared `FileManager` instance. - public var fileManager: Dependency { + public var fileManager: Dependency { dependency(SendableFileManager()) } @@ -42,7 +42,7 @@ extension Application { public struct FileState: MutableApplicationState { public static var emoji: Character { "🗄️" } - @AppDependency(\.fileManager) private var fileManager: FileManaging + @AppDependency(\.fileManager) private var fileManager: any FileManaging /// The initial value of the state. private var initial: () -> Value 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..2b5051d --- /dev/null +++ b/Sources/AppState/Application/Types/State/Application+ModelState.swift @@ -0,0 +1,375 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData + +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 + } + + // MARK: - Mutations (lenient) + + /// Inserts a model into the backing `ModelContext` and saves. + /// + /// Failures are logged and swallowed. Use ``strict`` for a throwing variant. + /// - Parameter model: The model to insert. + @MainActor + public func insert(_ model: Model) { + try? insertThrowing(model) + } + + /// Deletes a model from the backing `ModelContext` and saves. + /// + /// Failures are logged and swallowed. Use ``strict`` for a throwing variant. + /// - Parameter model: The model to delete. + @MainActor + public func delete(_ model: Model) { + try? deleteThrowing(model) + } + + /// Persists any pending changes in the backing `ModelContext`. + /// + /// Failures are logged and swallowed. Use ``strict`` for a throwing variant. + @MainActor + public func save() { + try? saveThrowing() + } + + /// Deletes **every** model matching this state's `FetchDescriptor` and saves. + /// + /// Failures are logged and swallowed. Use ``strict`` for a throwing variant. + /// + /// - 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() { + try? deleteAllThrowing() + } + + // MARK: - Mutations (strict) + + /// A strict, throwing view over this state's mutators. + /// + /// Where the lenient mutators (``insert(_:)``, ``delete(_:)``, ``save()``, ``deleteAll()``) + /// log and swallow SwiftData errors, the matching methods on `strict` propagate them so the + /// caller can surface or recover from a failed write. + /// + /// ```swift + /// try Application.modelState(\.items).strict.insert(item) + /// ``` + @MainActor + public var strict: Strict { + Strict(state: self) + } + + /// Throwing mutators for a ``ModelState``. Obtained via ``ModelState/strict``. + @MainActor + public struct Strict { + private let state: ModelState + + fileprivate init(state: ModelState) { + self.state = state + } + + /// Inserts a model and saves, throwing on failure. + /// - Parameter model: The model to insert. + public func insert(_ model: Model) throws { + try state.insertThrowing(model) + } + + /// Deletes a model and saves, throwing on failure. + /// - Parameter model: The model to delete. + public func delete(_ model: Model) throws { + try state.deleteThrowing(model) + } + + /// Persists any pending changes, throwing on failure. + public func save() throws { + try state.saveThrowing() + } + + /// Deletes **every** matching model and saves, throwing on failure. + /// + /// - Warning: Destructive and irreversible — see ``ModelState/deleteAll()``. + public func deleteAll() throws { + try state.deleteAllThrowing() + } + } + + // MARK: - Throwing core + + @MainActor + private func insertThrowing(_ model: Model) throws { + let context = context + context.insert(model) + try persist(context: context, action: "Inserting") + } + + @MainActor + private func deleteThrowing(_ model: Model) throws { + let context = context + context.delete(model) + try persist(context: context, action: "Deleting") + } + + @MainActor + private func saveThrowing() throws { + try persist(context: context, action: "Saving") + } + + @MainActor + private func deleteAllThrowing() throws { + let context = context + + do { + try context.delete(model: Model.self, where: fetchDescriptor().predicate) + } catch { + log( + error: error, + message: "\(ModelState.emoji) ModelState Deleting", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + throw error + } + + try persist(context: context, action: "Deleting") + } + + /// Saves the context, logging and rethrowing any failure. + @MainActor + private func persist(context: ModelContext, action: String) throws { + 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 + ) + throw error + } + } + } +} + +// MARK: - ModelState Functions + +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/Application/Types/State/Application+SecureState.swift b/Sources/AppState/Application/Types/State/Application+SecureState.swift index 76c7691..a3ddca1 100644 --- a/Sources/AppState/Application/Types/State/Application+SecureState.swift +++ b/Sources/AppState/Application/Types/State/Application+SecureState.swift @@ -29,7 +29,7 @@ extension Application { return storedValue } set { - shared.objectWillChange.send() + shared.notifyChange() guard let newValue else { return keychain.remove(scope.key) diff --git a/Sources/AppState/Application/Types/State/Application+StoredState.swift b/Sources/AppState/Application/Types/State/Application+StoredState.swift index ee3a49d..4418eb3 100644 --- a/Sources/AppState/Application/Types/State/Application+StoredState.swift +++ b/Sources/AppState/Application/Types/State/Application+StoredState.swift @@ -17,7 +17,7 @@ extension Application { } /// The shared `UserDefaults` instance. - public var userDefaults: Dependency { + public var userDefaults: Dependency { dependency(SendableUserDefaults()) } @@ -25,7 +25,7 @@ extension Application { public struct StoredState: MutableApplicationState { public static var emoji: Character { "💾" } - @AppDependency(\.userDefaults) private var userDefaults: UserDefaultsManaging + @AppDependency(\.userDefaults) private var userDefaults: any UserDefaultsManaging /// The initial value of the state. private var initial: () -> Value diff --git a/Sources/AppState/Application/Types/State/Application+SyncState.swift b/Sources/AppState/Application/Types/State/Application+SyncState.swift index 7c5d44c..c4393df 100644 --- a/Sources/AppState/Application/Types/State/Application+SyncState.swift +++ b/Sources/AppState/Application/Types/State/Application+SyncState.swift @@ -20,7 +20,7 @@ extension Application { } /// The default `NSUbiquitousKeyValueStore` instance. - public var icloudStore: Dependency { + public var icloudStore: Dependency { dependency { NotificationCenter.default.addObserver( self, @@ -52,7 +52,7 @@ extension Application { public struct SyncState: MutableApplicationState { public static var emoji: Character { "☁️" } - @AppDependency(\.icloudStore) private var icloudStore: UbiquitousKeyValueStoreManaging + @AppDependency(\.icloudStore) private var icloudStore: any UbiquitousKeyValueStoreManaging /// The initial value of the state. private var initial: () -> Value @@ -86,10 +86,13 @@ extension Application { storedState.reset() icloudStore.removeObject(forKey: scope.key) } else { - storedState.value = newValue - do { + // Encode first: only commit to the local fallback and iCloud once we have a + // valid encoding. Otherwise a value that fails to encode would still poison + // `storedState`, and the getter would read it back via the fallback even + // though iCloud never received it. let data = try JSONEncoder().encode(newValue) + storedState.value = newValue icloudStore.set(data, forKey: scope.key) } catch { Application.log( diff --git a/Sources/AppState/Dependencies/Keychain.swift b/Sources/AppState/Dependencies/Keychain.swift index 1e794df..7301e79 100644 --- a/Sources/AppState/Dependencies/Keychain.swift +++ b/Sources/AppState/Dependencies/Keychain.swift @@ -1,21 +1,22 @@ #if !os(Linux) && !os(Windows) import Cache import Foundation +import os /** A `Keychain` class that adopts the `Cacheable` protocol. - + This class provides a secure method for storing and retrieving string key-value pairs. It leverages Apple's Keychain services for secure data storage ensuring that data saved using this `Keychain` class is encrypted and kept secure in the device's keychain. It provides methods for getting, setting, and removing values associated with a specific key. It also includes methods for throwing errors when specified key(s) do not exist and for returning all keys and their associated values. - + Usage Example: - + ```swift let keychain = Keychain() - + keychain.set(value: "", forKey: "token") let token = try keychain.resolve("token") @@ -24,24 +25,28 @@ import Foundation public final class Keychain: Sendable { public typealias Key = String public typealias Value = String - - private let lock: NSLock - @MainActor - private var keys: Set - + + /// Serializes Keychain *write* operations (`set`/`remove`) so multi-call sequences such as + /// `set`'s update-then-add are atomic. Reads are thread-safe at the system level and stay lock-free. + private let writeLock: NSLock + /// The in-memory index of known keys, used by ``values(ofType:)``. Guarded by its own lock so + /// `Keychain` is `Sendable` without an `@unchecked` escape; the critical sections are short and + /// never span a Keychain syscall. + private let index: OSAllocatedUnfairLock> + /// Default initializer public init() { - self.lock = NSLock() - self.keys = [] + self.writeLock = NSLock() + self.index = OSAllocatedUnfairLock(initialState: []) } - + /** Initialize with a predefined set of keys. - Parameter keys: The predefined set of keys. */ public init(keys: Set) { - self.lock = NSLock() - self.keys = keys + self.writeLock = NSLock() + self.index = OSAllocatedUnfairLock(initialState: keys) } /** @@ -57,19 +62,17 @@ public final class Keychain: Sendable { kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne ] - + + // Reads are a single, system-serialized syscall — no AppState-level lock required. var dataTypeRef: AnyObject? - - lock.lock() let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) - lock.unlock() - + guard status == noErr, let data = dataTypeRef as? Data, let output = String(data: data, encoding: .utf8) as? Output else { return nil } - + return output } @@ -84,7 +87,7 @@ public final class Keychain: Sendable { guard let output = get(key, as: Output.self) else { throw MissingRequiredKeysError(keys: [key]) } - + return output } @@ -95,27 +98,28 @@ public final class Keychain: Sendable { */ public func set(value: String, forKey key: Key) { guard let data = value.data(using: .utf8) else { return } - + let query: [NSString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, ] - + let updateAttributes: [NSString: Any] = [ kSecValueData: data ] - + + // The update/add pair must be atomic: without serialization, two concurrent `set` calls for + // the same key can both see `errSecItemNotFound` and both attempt `SecItemAdd`. + writeLock.lock() let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) - if updateStatus == errSecItemNotFound { var addQuery = query addQuery[kSecValueData] = data SecItemAdd(addQuery as CFDictionary, nil) } + writeLock.unlock() - Task { @MainActor in - keys.insert(key) - } + index.withLock { keys in _ = keys.insert(key) } } /** @@ -127,10 +131,12 @@ public final class Keychain: Sendable { kSecClass: kSecClassGenericPassword, kSecAttrAccount: key ] - - lock.lock() + + writeLock.lock() SecItemDelete(query as CFDictionary) - lock.unlock() + writeLock.unlock() + + index.withLock { keys in _ = keys.remove(key) } } /** @@ -151,11 +157,11 @@ public final class Keychain: Sendable { public func require(keys: Set) throws -> Self { let missingKeys = keys .filter { contains($0) == false } - + guard missingKeys.isEmpty else { throw MissingRequiredKeysError(keys: missingKeys) } - + return self } @@ -176,19 +182,17 @@ public final class Keychain: Sendable { */ @MainActor public func values(ofType: Output.Type) -> [Key: Output] { - let storedKeys: [Key] + // Snapshot the index under its lock, then read each value lock-free. Capturing the snapshot + // first avoids holding the index lock across Keychain syscalls. + let storedKeys = index.withLock { Array($0) } var values: [Key: Output] = [:] - - lock.lock() - storedKeys = Array(keys) - lock.unlock() - + for key in storedKeys { if let value = get(key, as: Output.self) { values[key] = value } } - + return values } } @@ -202,7 +206,7 @@ public extension Keychain { func get(_ key: Key) -> String? { get(key, as: String.self) } - + /** Retrieve a string value from the keychain for a given key and throws an error if the key is not found. - Parameter key: The key for the value. @@ -212,7 +216,7 @@ public extension Keychain { func resolve(_ key: Key) throws -> String { try resolve(key, as: String.self) } - + /** Returns all keys and their string values currently in the keychain. - Returns: A dictionary with keys and their corresponding string values. diff --git a/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift b/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift index 8293e5b..7537add 100644 --- a/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift +++ b/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift @@ -1,18 +1,12 @@ #if !os(Linux) && !os(Windows) -import Combine import SwiftUI #endif /// A property wrapper that provides access to a specific part of the AppState's dependencies. @propertyWrapper public struct DependencySlice { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this dependency. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `Dependency` from Application. private let dependencyKeyPath: KeyPath> @@ -30,7 +24,9 @@ import SwiftUI @MainActor public var wrappedValue: SliceValue { get { - Application.dependencySlice( + app.registerObservation() + + return Application.dependencySlice( dependencyKeyPath, valueKeyPath, fileID, @@ -50,7 +46,7 @@ import SwiftUI var dependency = app.value(keyPath: dependencyKeyPath) #if !os(Linux) && !os(Windows) - Application.shared.objectWillChange.send() + Application.shared.notifyChange() #endif dependency.value[keyPath: valueKeyPath] = newValue } diff --git a/Sources/AppState/PropertyWrappers/State/AppState.swift b/Sources/AppState/PropertyWrappers/State/AppState.swift index 58dfe28..457b474 100644 --- a/Sources/AppState/PropertyWrappers/State/AppState.swift +++ b/Sources/AppState/PropertyWrappers/State/AppState.swift @@ -5,14 +5,9 @@ import SwiftUI /// `AppState` is a property wrapper allowing SwiftUI views to subscribe to Application's state changes in a reactive way. Works similar to `State` and `Published`. @propertyWrapper public struct AppState where ApplicationState.Value == Value { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this state. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `State` from Application. private let keyPath: KeyPath @@ -26,6 +21,8 @@ import SwiftUI @MainActor public var wrappedValue: Value { get { + // `Application.state(_:)` registers the current Observation scope, so reading through it + // is enough — no separate `registerObservation()` call is needed here. Application.state( keyPath, fileID, diff --git a/Sources/AppState/PropertyWrappers/State/FileState.swift b/Sources/AppState/PropertyWrappers/State/FileState.swift index 29db6cb..de20a10 100644 --- a/Sources/AppState/PropertyWrappers/State/FileState.swift +++ b/Sources/AppState/PropertyWrappers/State/FileState.swift @@ -6,14 +6,9 @@ import SwiftUI /// `FileState` is a property wrapper allowing SwiftUI views to subscribe to Application's state changes in a reactive way. State is stored using `FileManager`. Works similar to `State` and `Published`. @propertyWrapper public struct FileState { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this state. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `FileState` from Application. private let keyPath: KeyPath> @@ -27,7 +22,9 @@ import SwiftUI @MainActor public var wrappedValue: Value { get { - Application.fileState( + app.registerObservation() + + return Application.fileState( keyPath, fileID, function, diff --git a/Sources/AppState/PropertyWrappers/State/ModelState.swift b/Sources/AppState/PropertyWrappers/State/ModelState.swift new file mode 100644 index 0000000..e6a4b40 --- /dev/null +++ b/Sources/AppState/PropertyWrappers/State/ModelState.swift @@ -0,0 +1,81 @@ +#if canImport(SwiftData) +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. +@propertyWrapper public struct ModelState { + /// The shared `Application` instance backing this state. + @MainActor + 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] { + app.registerObservation() + + return 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 + } +} + +extension ModelState: DynamicProperty { } +#endif diff --git a/Sources/AppState/PropertyWrappers/State/SecureState.swift b/Sources/AppState/PropertyWrappers/State/SecureState.swift index 93997c7..205cb8d 100644 --- a/Sources/AppState/PropertyWrappers/State/SecureState.swift +++ b/Sources/AppState/PropertyWrappers/State/SecureState.swift @@ -9,8 +9,9 @@ import SwiftUI As a `DynamicProperty`, SwiftUI will update the owning view whenever the value changes. */ @propertyWrapper public struct SecureState: DynamicProperty { - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared + /// The shared `Application` instance backing this state. + @MainActor + private var app: Application { Application.shared } /// Path for accessing `SecureState` from Application. private let keyPath: KeyPath @@ -29,7 +30,9 @@ import SwiftUI @MainActor public var wrappedValue: String? { get { - Application.secureState( + app.registerObservation() + + return Application.secureState( keyPath, fileID, function, diff --git a/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift b/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift index 12837d0..054a9f7 100644 --- a/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift +++ b/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift @@ -5,14 +5,9 @@ import SwiftUI /// A property wrapper that provides access to a specific part of the AppState's state that is optional. @propertyWrapper public struct OptionalSlice where SlicedState.Value == Value? { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this state. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `State` from Application. private let stateKeyPath: KeyPath @@ -33,6 +28,8 @@ import SwiftUI @MainActor public var wrappedValue: SliceValue? { get { + app.registerObservation() + if let valueKeyPath { return Application.slice( stateKeyPath, diff --git a/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift b/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift index 4d9c91f..5a67480 100644 --- a/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift +++ b/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift @@ -5,14 +5,9 @@ import SwiftUI /// A property wrapper that provides access to a specific part of the AppState's state. @propertyWrapper public struct Slice where SlicedState.Value == Value { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this state. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `State` from Application. private let stateKeyPath: KeyPath @@ -30,7 +25,9 @@ import SwiftUI @MainActor public var wrappedValue: SliceValue { get { - Application.slice( + app.registerObservation() + + return Application.slice( stateKeyPath, valueKeyPath, fileID, diff --git a/Sources/AppState/PropertyWrappers/State/StoredState.swift b/Sources/AppState/PropertyWrappers/State/StoredState.swift index a1e8c40..c9206c7 100644 --- a/Sources/AppState/PropertyWrappers/State/StoredState.swift +++ b/Sources/AppState/PropertyWrappers/State/StoredState.swift @@ -6,14 +6,9 @@ import SwiftUI /// `StoredState` is a property wrapper allowing SwiftUI views to subscribe to Application's state changes in a reactive way. State is stored using `UserDefaults`. Works similar to `State` and `Published`. @propertyWrapper public struct StoredState { - #if !os(Linux) && !os(Windows) - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared - #else - /// Holds the singleton instance of `Application`. + /// The shared `Application` instance backing this state. @MainActor - private var app: Application = Application.shared - #endif + private var app: Application { Application.shared } /// Path for accessing `StoredState` from Application. private let keyPath: KeyPath> @@ -27,7 +22,9 @@ import SwiftUI @MainActor public var wrappedValue: Value { get { - Application.storedState( + app.registerObservation() + + return Application.storedState( keyPath, fileID, function, diff --git a/Sources/AppState/PropertyWrappers/State/SyncState.swift b/Sources/AppState/PropertyWrappers/State/SyncState.swift index af42421..daa0246 100644 --- a/Sources/AppState/PropertyWrappers/State/SyncState.swift +++ b/Sources/AppState/PropertyWrappers/State/SyncState.swift @@ -20,8 +20,9 @@ import SwiftUI */ @available(watchOS 9.0, *) @propertyWrapper public struct SyncState: DynamicProperty { - /// Holds the singleton instance of `Application`. - @ObservedObject private var app: Application = Application.shared + /// The shared `Application` instance backing this state. + @MainActor + private var app: Application { Application.shared } /// Path for accessing `SyncState` from Application. private let keyPath: KeyPath> @@ -35,7 +36,9 @@ import SwiftUI @MainActor public var wrappedValue: Value { get { - Application.syncState( + app.registerObservation() + + return Application.syncState( keyPath, fileID, function, diff --git a/Tests/AppStateTests/AdversarialBreakItTests.swift b/Tests/AppStateTests/AdversarialBreakItTests.swift new file mode 100644 index 0000000..6d5a1b3 --- /dev/null +++ b/Tests/AppStateTests/AdversarialBreakItTests.swift @@ -0,0 +1,1308 @@ +// AdversarialBreakItTests.swift +// Adversarial "break it" test suite for AppState. +// +// Goal: CRASH, corrupt, deadlock, or break AppState. Most tests must PASS, +// proving AppState survives. Where a genuine bug or unsupported edge case is +// found the test is skipped (XCTSkip) with a precise explanation; bugs are +// documented at the bottom of this file. +// +// All helpers are prefixed "BreakIt" or placed in fileprivate extensions so +// they cannot collide with other test files in this module. + +import Foundation +import XCTest +@testable import AppState + +// This adversarial suite exercises Apple-only surface (Keychain, SecureState, SwiftData, Observation +// bridging), so the whole file is scoped to Apple platforms. Linux/Windows still build the core +// library and the cross-platform test suites. +#if !os(Linux) && !os(Windows) +import Observation +import SwiftUI + +// MARK: - BreakIt Application extensions (unique ids) + +fileprivate extension Application { + // MARK: State fixtures + var breakItCounter: State { + state(initial: 0, feature: "BreakIt", id: "counter") + } + + var breakItOptionalString: State { + state(initial: nil, feature: "BreakIt", id: "optionalString") + } + + var breakItLargeArray: State<[Int]> { + state(initial: [], feature: "BreakIt", id: "largeArray") + } + + var breakItNestedStruct: State { + state(initial: BreakItDeep(), feature: "BreakIt", id: "nestedStruct") + } + + var breakItUnicodeString: State { + state(initial: "", feature: "BreakIt", id: "unicodeString") + } + + // MARK: StoredState fixtures (backed by in-memory UserDefaults override) + var breakItStoredInt: StoredState { + storedState(initial: 0, feature: "BreakIt", id: "storedInt") + } + + var breakItStoredOptional: StoredState { + storedState(feature: "BreakIt", id: "storedOptional") + } + + var breakItStoredArray: StoredState<[String]> { + storedState(initial: [], feature: "BreakIt", id: "storedArray") + } + + // MARK: SecureState fixtures + #if !os(Linux) && !os(Windows) + var breakItSecureToken: SecureState { + secureState(feature: "BreakIt", id: "secureToken") + } + + var breakItSecureEmpty: SecureState { + secureState(feature: "BreakIt", id: "secureEmpty") + } + + // MARK: SyncState fixtures (backed by in-memory iCloud override) + @available(watchOS 9.0, *) + var breakItSyncInt: SyncState { + syncState(feature: "BreakIt", id: "syncInt") + } + + @available(watchOS 9.0, *) + var breakItSyncDouble: SyncState { + syncState(initial: 0.0, feature: "BreakIt", id: "syncDouble") + } + #endif + + // MARK: FileState fixtures (temp dir) + @MainActor + var breakItFileInt: FileState { + fileState(path: BreakItConstants.tempPath, filename: "breakItFileInt") + } + + @MainActor + var breakItFileString: FileState { + fileState(path: BreakItConstants.tempPath, filename: "breakItFileString") + } + + // MARK: Dependency fixtures + var breakItService: Dependency { + dependency(BreakItService(name: "default"), feature: "BreakIt", id: "service") + } + + var breakItNestedFactory: Dependency { + dependency( + BreakItNestedService( + inner: dependency(BreakItService(name: "inner"), feature: "BreakIt", id: "innerService").value + ), + feature: "BreakIt", + id: "nestedFactory" + ) + } + + // MARK: Slice fixtures + var breakItProfile: State { + state(initial: nil, feature: "BreakIt", id: "profile") + } + + var breakItProfileNonOptional: State { + state(initial: BreakItProfile(name: "Alice", score: 0), feature: "BreakIt", id: "profileNonOptional") + } +} + +// MARK: - BreakIt helper types + +fileprivate enum BreakItConstants { + static let tempPath: String = "./BreakItTests_\(ProcessInfo.processInfo.processIdentifier)" +} + +fileprivate struct BreakItService: Sendable, Equatable { + let name: String +} + +fileprivate struct BreakItNestedService: Sendable { + let inner: BreakItService +} + +fileprivate struct BreakItProfile: Codable, Sendable, Equatable { + var name: String + var score: Int +} + +// A value type with many layers of nesting to stress the cache. +fileprivate struct BreakItDeep: Codable, Sendable, Equatable { + struct Level2: Codable, Sendable, Equatable { + struct Level3: Codable, Sendable, Equatable { + var payload: [Int] = Array(0..<50) + } + var child: Level3 = Level3() + var tag: String = "level2" + } + var child: Level2 = Level2() + var flag: Bool = false +} + +// A type whose JSONEncoder will throw for certain values. +fileprivate struct BreakItUnencodable: Codable, Sendable { + var number: Double + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + // Double.infinity is rejected by JSON encoding + try container.encode(number) + } +} + +// An in-memory UserDefaults fake for StoredState isolation. +fileprivate final class BreakItInMemoryUserDefaults: UserDefaultsManaging, @unchecked Sendable { + private var storage: [String: Any] = [:] + private let lock = NSLock() + + func object(forKey key: String) -> Any? { + lock.lock(); defer { lock.unlock() } + return storage[key] + } + + func set(_ value: Any?, forKey key: String) { + lock.lock(); defer { lock.unlock() } + storage[key] = value + } + + func removeObject(forKey key: String) { + lock.lock(); defer { lock.unlock() } + storage.removeValue(forKey: key) + } +} + +// An in-memory iCloud store fake for SyncState isolation. +#if !os(Linux) && !os(Windows) +@available(watchOS 9.0, *) +fileprivate final class BreakItInMemoryICloudStore: UbiquitousKeyValueStoreManaging, @unchecked Sendable { + private var storage: [String: Data] = [:] + private let lock = NSLock() + + func data(forKey key: String) -> Data? { + lock.lock(); defer { lock.unlock() } + return storage[key] + } + + func set(_ value: Data?, forKey key: String) { + lock.lock(); defer { lock.unlock() } + storage[key] = value + } + + func removeObject(forKey key: String) { + lock.lock(); defer { lock.unlock() } + storage.removeValue(forKey: key) + } +} +#endif + +// MARK: - MARK 1: CONCURRENCY / RACES + +/// Hammers Keychain, dependency cache, and all lock-guarded paths from many +/// concurrent contexts. State/StoredState setters are @MainActor so races +/// through those APIs are not possible off-main; we focus on the surfaces +/// that *are* reachable concurrently. +final class BreakItConcurrencyTests: XCTestCase { + + // MARK: Keychain concurrent set/get/remove + + /// Fire 200 concurrent tasks all hitting the same Keychain key. The + /// keychain lock inside `Keychain` must prevent corruption. + @MainActor + func testKeychainConcurrentSameKey() async { + let keychain = Keychain() + let key = "breakItConcurrentSameKey_\(UUID().uuidString)" + defer { keychain.remove(key) } + + await withTaskGroup(of: Void.self) { group in + for i in 0..<200 { + group.addTask { + if i % 3 == 0 { + keychain.set(value: "value_\(i)", forKey: key) + } else if i % 3 == 1 { + _ = keychain.get(key) + } else { + keychain.remove(key) + } + } + } + } + // Survive without crash — no consistency assertion because remove + // races with set; we only verify the instance is still functional. + keychain.set(value: "sentinel", forKey: key) + XCTAssertEqual(keychain.get(key), "sentinel") + } + + /// 100 tasks each write a DISTINCT key, then we verify all keys are readable. + @MainActor + func testKeychainConcurrentDistinctKeys() async { + let keychain = Keychain() + let prefix = "breakItDistinct_\(UUID().uuidString)_" + let count = 100 + + defer { + for i in 0..() + for await service in group { + names.insert(service.name) + } + // All tasks must see the same cached singleton + XCTAssertEqual(names.count, 1, "Concurrent dependency resolution must return the same instance") + } + } + + /// Override + cancel in rapid concurrent tasks. Verifies the dependency + /// override mechanism survives concurrent stress without deadlock. + @MainActor + func testDependencyOverrideConcurrentRapid() async { + let iterations = 50 + var tokens: [Application.DependencyOverride] = [] + + for i in 0..() + for _ in 0..<500 { + let service = Application.dependency(\.breakItService) + names.insert(service.name) + } + XCTAssertEqual(names.count, 1, "Same-id dependency must always return the same cached instance") + } + + /// Repeated dependency creation with different ids — each must be distinct. + @MainActor + func testRepeatedDependencyWithDifferentIds() { + // Use the low-level API directly to create 50 uniquely-scoped dependencies. + var names = Set() + for i in 0..<50 { + let dep = Application.shared.dependency( + BreakItService(name: "churn_\(i)"), + feature: "BreakItChurn", + id: "service_\(i)" + ) + names.insert(dep.value.name) + } + XCTAssertEqual(names.count, 50) + } + + /// Rapid promote cycles for Application subclass. + @MainActor + func testRapidPromoteCycles() { + for _ in 0..<10 { + Application.promote(to: BreakItCustomApplication.self) + Application.promote(to: Application.self) + } + // shared must be functional after repeated promotes + let val = Application.state(\.breakItCounter).value + XCTAssertEqual(val, 0) + } + + #if !os(Linux) && !os(Windows) + /// Rapid reset of SecureState — Keychain writes/deletes in a tight loop. + @MainActor + func testRapidSecureStateReset() { + let override = Application.override(\.keychain, with: Keychain()) + defer { Task { await override.cancel() } } + + for i in 0..<100 { + var secure = Application.secureState(\.breakItSecureToken) + secure.value = "token_\(i)" + Application.reset(secureState: \.breakItSecureToken) + } + XCTAssertNil(Application.secureState(\.breakItSecureToken).value) + } + #endif +} + +// A minimal Application subclass used by churn tests. +fileprivate final class BreakItCustomApplication: Application {} + +// MARK: - MARK 4: MALFORMED / EDGE DATA + +/// Empty strings, huge strings, full Unicode/emoji/RTL/zero-width, nil +/// optionals, non-encodable values through SyncState. +final class BreakItEdgeDataTests: XCTestCase { + + @MainActor + override func setUp() async throws { + Application.reset(\.breakItUnicodeString) + Application.reset(\.breakItOptionalString) + } + + @MainActor + override func tearDown() async throws { + Application.reset(\.breakItUnicodeString) + Application.reset(\.breakItOptionalString) + Application.reset(\.breakItCounter) + // Clean up FileState temp dir + try? FileManager.default.removeItem(atPath: BreakItConstants.tempPath) + } + + // MARK: Empty string in State + + @MainActor + func testEmptyStringState() { + var state = Application.state(\.breakItUnicodeString) + state.value = "" + XCTAssertEqual(Application.state(\.breakItUnicodeString).value, "") + } + + // MARK: Unicode / Emoji / RTL / Zero-Width keys & values + + @MainActor + func testUnicodeEmojiValue() { + let emoji = "🦅🌈🎭💯🔑🗝️🛡️" + var state = Application.state(\.breakItUnicodeString) + state.value = emoji + XCTAssertEqual(Application.state(\.breakItUnicodeString).value, emoji) + } + + @MainActor + func testRTLAndZeroWidthString() { + // Arabic + Hebrew + zero-width joiner + let rtl = "\u{0647}\u{0630}\u{0627} \u{05E9}\u{05DC}\u{05D5}\u{05DD}‍​" + var state = Application.state(\.breakItUnicodeString) + state.value = rtl + XCTAssertEqual(Application.state(\.breakItUnicodeString).value, rtl) + } + + @MainActor + func testNullByteInString() { + // Null byte is valid Swift/JSON but potentially dangerous for C APIs + let withNull = "before\0after" + var state = Application.state(\.breakItUnicodeString) + state.value = withNull + XCTAssertEqual(Application.state(\.breakItUnicodeString).value, withNull) + } + + @MainActor + func testSurrogatePairsAndCombiningCharacters() { + // Musical symbol G clef (𝄞) + combining diacritic marks + let complex = "\u{1D11E}\u{0301}\u{0302}\u{0303}" + var state = Application.state(\.breakItUnicodeString) + state.value = complex + XCTAssertEqual(Application.state(\.breakItUnicodeString).value, complex) + } + + // MARK: Nil optionals + + @MainActor + func testNilOptionalStringState() { + var state = Application.state(\.breakItOptionalString) + state.value = nil + XCTAssertNil(Application.state(\.breakItOptionalString).value) + state.value = "hello" + XCTAssertEqual(Application.state(\.breakItOptionalString).value, "hello") + state.value = nil + XCTAssertNil(Application.state(\.breakItOptionalString).value) + } + + // MARK: SecureState edge cases + + #if !os(Linux) && !os(Windows) + @MainActor + func testSecureStateEmptyString() { + let keychainOverride = Application.override(\.keychain, with: Keychain()) + defer { Task { await keychainOverride.cancel() } } + + var secure = Application.secureState(\.breakItSecureEmpty) + secure.value = "" + // Keychain stores empty strings — value should round-trip + let readBack = Application.secureState(\.breakItSecureEmpty).value + // An empty Data → UTF-8 decoding produces "" which is non-nil + // so the value may be "" or nil depending on OS; we just assert no crash. + _ = readBack + } + + @MainActor + func testSecureStateHugeToken() { + let keychainOverride = Application.override(\.keychain, with: Keychain()) + defer { Task { await keychainOverride.cancel() } } + + let hugeToken = String(repeating: "A", count: 65_535) + var secure = Application.secureState(\.breakItSecureToken) + secure.value = hugeToken + let read = Application.secureState(\.breakItSecureToken).value + XCTAssertEqual(read, hugeToken) + + // Reset to nil + secure.value = nil + XCTAssertNil(Application.secureState(\.breakItSecureToken).value) + } + + // MARK: SyncState with non-encodable values (exercises the catch branch) + + @available(watchOS 9.0, *) + @MainActor + func testSyncStateWithNonEncodableValue_ExercisesErrorBranch() { + let icloudOverride = Application.override(\.icloudStore, with: BreakItInMemoryICloudStore()) + defer { Task { await icloudOverride.cancel() } } + + // Seed a valid value first, then attempt a non-encodable one. + var state = Application.syncState(\.breakItSyncDouble) + state.value = 1.5 + XCTAssertEqual(Application.syncState(\.breakItSyncDouble).value, 1.5) + + // Double.infinity cannot be JSON-encoded. The setter encodes BEFORE committing, so the + // failed write must not poison the local fallback — the previous valid value is preserved. + state.value = Double.infinity + XCTAssertEqual(Application.syncState(\.breakItSyncDouble).value, 1.5) + } + + @available(watchOS 9.0, *) + @MainActor + func testSyncStateNilOptional() { + let icloudOverride = Application.override(\.icloudStore, with: BreakItInMemoryICloudStore()) + defer { Task { await icloudOverride.cancel() } } + + var state = Application.syncState(\.breakItSyncInt) + state.value = 42 + XCTAssertEqual(Application.syncState(\.breakItSyncInt).value, 42) + + state.value = nil + XCTAssertNil(Application.syncState(\.breakItSyncInt).value) + } + #endif + + // MARK: StoredState reading a key that holds the wrong type + + @MainActor + func testStoredStateTypeMismatchFallsBackToInitial() { + let fakeDefaults = BreakItInMemoryUserDefaults() + // Manually plant a value that cannot be decoded as Int + let scope = Application.Scope(name: "BreakIt", id: "storedInt") + fakeDefaults.set("not_an_int", forKey: scope.key) + + let override = Application.override(\.userDefaults, with: fakeDefaults) + defer { Task { await override.cancel() } } + + // StoredState should fall back to initial (0) when decoding fails + let value = Application.storedState(\.breakItStoredInt).value + // Either the initial value (0) or the decode-fallback path is fine; + // we assert it doesn't crash and returns an Int. + XCTAssertNotNil(value) + } + + // MARK: FileState with weird filenames + + @MainActor + func testFileStateWithSpacesInFilename() { + FileManager.defaultFileStatePath = BreakItConstants.tempPath + defer { + Application.reset(fileState: \.breakItFileInt) + try? FileManager.default.removeItem(atPath: BreakItConstants.tempPath) + } + + var fileState = Application.fileState(\.breakItFileInt) + fileState.value = 42 + XCTAssertEqual(Application.fileState(\.breakItFileInt).value, 42) + fileState.value = nil + XCTAssertNil(Application.fileState(\.breakItFileInt).value) + } + + @MainActor + func testFileStateWithUnicodeFilename() { + let tempPath = BreakItConstants.tempPath + "_unicode" + // Create a FileState with a Unicode filename directly via the low-level API. + // Use String (non-optional) to avoid the nil-assignment type ambiguity. + var fs = Application.shared.fileState( + initial: "🎉", + path: tempPath, + filename: "emoji_🎭_ñ", + isBase64Encoded: true + ) + defer { + try? FileManager.default.removeItem(atPath: tempPath) + } + fs.value = "🌈" + XCTAssertEqual(fs.value, "🌈") + } +} + +// MARK: - MARK 5: SWIFTDATA EDGE CASES + +#if canImport(SwiftData) +import SwiftData + +// Unique SwiftData model type (avoids collision with TestItem in ModelStateTests) +@Model +final class BreakItModel { + var label: String + var score: Int + + init(label: String, score: Int) { + self.label = label + self.score = score + } +} + +fileprivate extension Application { + var breakItContainer: Dependency { + modelContainer( + try! ModelContainer( + for: BreakItModel.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + ) + } + + var breakItModels: ModelState { + modelState(container: \.breakItContainer, id: "breakItModels") + } + + var breakItSortedModels: ModelState { + modelState( + container: \.breakItContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.score, order: .forward)] + ), + id: "breakItSortedModels" + ) + } +} + +final class BreakItSwiftDataTests: XCTestCase { + + @MainActor + override func setUp() async throws { + Application.modelState(\.breakItModels).deleteAll() + XCTAssertTrue(Application.modelState(\.breakItModels).models.isEmpty) + } + + @MainActor + override func tearDown() async throws { + Application.modelState(\.breakItModels).deleteAll() + } + + // MARK: deleteAll on empty store + + @MainActor + func testDeleteAllOnEmpty() { + // Must not crash when store is already empty + Application.modelState(\.breakItModels).deleteAll() + Application.modelState(\.breakItModels).deleteAll() + XCTAssertTrue(Application.modelState(\.breakItModels).models.isEmpty) + } + + // MARK: save() with no pending changes + + @MainActor + func testSaveWithNoPendingChanges() { + // ModelContext.hasChanges guards the save; calling save() without any + // prior mutations must not throw or crash. + Application.modelState(\.breakItModels).save() + } + + // MARK: 10k inserts then deleteAll + + @MainActor + func testMassInsertAndDeleteAll() { + let state = Application.modelState(\.breakItModels) + let count = 10_000 + + for i in 0..() + limitDescriptor.fetchLimit = 10 + let limited = try? state.context.fetch(limitDescriptor) + + XCTAssertEqual(limited?.count, 10) + } + + // MARK: Rapid insert/delete interleaving + + @MainActor + func testRapidInsertDeleteInterleaving() { + let state = Application.modelState(\.breakItModels) + + for i in 0..<200 { + let model = BreakItModel(label: "interleave_\(i)", score: i) + state.insert(model) + if i % 2 == 0 { + state.delete(model) + } + } + + let remaining = state.models.count + // 100 even-indexed items were deleted, 100 odd remain + XCTAssertEqual(remaining, 100) + } + + // MARK: Two ModelStates sharing the same container + + @MainActor + func testTwoModelStatesShareContainer() { + let modelsState = Application.modelState(\.breakItModels) + let sortedState = Application.modelState(\.breakItSortedModels) + + modelsState.insert(BreakItModel(label: "Z", score: 30)) + modelsState.insert(BreakItModel(label: "A", score: 10)) + modelsState.insert(BreakItModel(label: "M", score: 20)) + + // Both views over the same container must see the same data + XCTAssertEqual(modelsState.models.count, 3) + XCTAssertEqual(sortedState.models.count, 3) + + // Sorted state must return ascending score order + XCTAssertEqual(sortedState.models.map(\.score), [10, 20, 30]) + } + + // MARK: Context is the same object from two ModelState reads + + @MainActor + func testSharedContextIdentity() { + let ctx1 = Application.modelContext(\.breakItContainer) + let ctx2 = Application.modelContext(\.breakItContainer) + XCTAssertTrue(ctx1 === ctx2, "modelContext(_:) must always return the same ModelContext instance") + } + + // MARK: Delete individual models + + @MainActor + func testDeleteIndividualModel() { + let state = Application.modelState(\.breakItModels) + + let a = BreakItModel(label: "A", score: 1) + let b = BreakItModel(label: "B", score: 2) + state.insert(a) + state.insert(b) + + XCTAssertEqual(state.models.count, 2) + + state.delete(a) + + let remaining = state.models + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.label, "B") + } +} +#endif + +/// `@AppState`-backed holders used by observation re-entrancy tests. +/// These must be @MainActor structs/classes so that registerObservation() is +/// called in the correct context (mirroring how SwiftUI views work). +@MainActor +fileprivate struct BreakItCounterHolder { + @AppState(\.breakItCounter) var counter: Int +} + +@MainActor +fileprivate struct BreakItOptionalStringHolder { + @AppState(\.breakItOptionalString) var text: String? +} + +// MARK: - MARK 6: RE-ENTRANCY / RECURSION + +/// Mutate state from within an observation tracking callback; resolve a +/// dependency whose factory resolves another dependency; reset a state +/// during iteration. +final class BreakItReentrancyTests: XCTestCase { + + @MainActor + override func setUp() async throws { + Application.reset(\.breakItCounter) + Application.reset(\.breakItOptionalString) + } + + @MainActor + override func tearDown() async throws { + Application.reset(\.breakItCounter) + Application.reset(\.breakItOptionalString) + } + + // MARK: Mutate state from within onChange callback + + /// withObservationTracking fires `onChange` on the first mutation. + /// The @AppState wrapper calls registerObservation() in its getter, which + /// registers the tracking scope on Application's `changeAnchor`. Any + /// subsequent state mutation calls notifyChange() → fires onChange. + /// Writing to state again from *inside* onChange must not deadlock because + /// Application's lock is NSRecursiveLock. + @MainActor + func testMutateStateFromInsideObservationOnChange() { + let holder = BreakItCounterHolder() + let flag = BreakItChangeFlag() + + withObservationTracking { + // Reading through @AppState calls registerObservation(), wiring + // this tracking scope to Application's changeAnchor. + _ = holder.counter + } onChange: { + flag.didChange = true + // Re-entering Application state mutation from the onChange callback. + // NSRecursiveLock allows this; it must not deadlock. + Task { @MainActor in + var s = Application.state(\.breakItCounter) + s.value += 100 + } + } + + holder.counter = 1 // triggers onChange via notifyChange() + + XCTAssertTrue(flag.didChange, "Mutating state via @AppState wrapper must fire observation onChange") + } + + // MARK: Dependency factory that resolves another dependency + + @MainActor + func testNestedDependencyFactoryDoesNotDeadlock() { + // \.breakItNestedFactory's factory calls Application.shared.dependency(…) + // for \.breakItService internally. Because value(keyPath:) uses + // NSRecursiveLock this must not deadlock. + let nested = Application.dependency(\.breakItNestedFactory) + XCTAssertEqual(nested.inner.name, "inner") + } + + // MARK: Reset state while a cached read is in-flight + + @MainActor + func testResetStateWhileCachedReadInProgress() { + var state = Application.state(\.breakItCounter) + state.value = 99 + + // Read the current value, then immediately reset before reading again. + let beforeReset = Application.state(\.breakItCounter).value + Application.reset(\.breakItCounter) + let afterReset = Application.state(\.breakItCounter).value + + XCTAssertEqual(beforeReset, 99) + XCTAssertEqual(afterReset, 0) + } + + // MARK: Observation tracking across two state keys simultaneously + + @MainActor + func testObservationTrackingTwoKeys() { + let counterHolder = BreakItCounterHolder() + let stringHolder = BreakItOptionalStringHolder() + let flag = BreakItChangeFlag() + + withObservationTracking { + // Both @AppState reads register this scope as dependent on + // Application's changeAnchor. + _ = counterHolder.counter + _ = stringHolder.text + } onChange: { + flag.didChange = true + } + + // Mutating the second observed key must fire onChange because both + // reads wired the same changeAnchor dependency. + stringHolder.text = "trigger" + + XCTAssertTrue(flag.didChange, "Mutating any tracked @AppState must fire onChange") + } + + // MARK: Override cancel restores pre-override value + + /// Verifies that after cancelling an override, the dependency returns to + /// the value it had BEFORE the override was applied (not necessarily the + /// original factory value, since another test may have promoted the dep). + @MainActor + func testOverrideCancelledInsideDependencyGetter() async { + // Record the value before we install the override — this is what + // cancel() will restore to. + let preOverrideValue = Application.dependency(\.breakItService) + + let override = Application.override(\.breakItService, with: BreakItService(name: "breakItTemp_\(UUID().uuidString)")) + let duringOverride = Application.dependency(\.breakItService) + XCTAssertNotEqual(duringOverride.name, preOverrideValue.name, "Override must change the value") + + await override.cancel() + + let afterCancel = Application.dependency(\.breakItService) + XCTAssertEqual(afterCancel.name, preOverrideValue.name, + "Cancelling override must restore the pre-override value") + } + + // MARK: Slice of optional state that transitions nil → non-nil → nil + + @MainActor + func testOptionalSliceTransitionsNilToNonNilToNil() { + var profile = Application.state(\.breakItProfile) + profile.value = nil + + var slice = Application.slice(\.breakItProfile, \BreakItProfile.score) + XCTAssertNil(slice.value, "Slice of nil optional state must be nil") + + profile.value = BreakItProfile(name: "Charlie", score: 7) + // Re-acquire slice after parent was updated + slice = Application.slice(\.breakItProfile, \BreakItProfile.score) + XCTAssertEqual(slice.value, 7) + + profile.value = nil + slice = Application.slice(\.breakItProfile, \BreakItProfile.score) + XCTAssertNil(slice.value) + } +} + +/// Thread-safe change flag for observation callbacks. +fileprivate final class BreakItChangeFlag: @unchecked Sendable { + var didChange = false +} + +// MARK: - MARK 7: EXTRA ADVERSARIAL EDGE CASES + +/// Additional corner-cases that don't fit cleanly into the above categories. +final class BreakItExtraEdgeCaseTests: XCTestCase { + + @MainActor + override func tearDown() async throws { + Application.reset(\.breakItCounter) + Application.reset(\.breakItUnicodeString) + Application.reset(\.breakItLargeArray) + Application.reset(\.breakItNestedStruct) + Application.reset(\.breakItStoredInt) + try? FileManager.default.removeItem(atPath: BreakItConstants.tempPath) + } + + // MARK: promote() with existing cached state survives + + @MainActor + func testPromotePreservesExistingState() { + var state = Application.state(\.breakItCounter) + state.value = 77 + + Application.promote(to: BreakItCustomApplication.self) + + // After promote the cache is migrated; the state must still be 77. + let afterPromote = Application.state(\.breakItCounter).value + XCTAssertEqual(afterPromote, 77) + + // Restore + Application.promote(to: Application.self) + } + + // MARK: promote() of dependency is permanent + + @MainActor + func testPromoteDependencyIsPermanentForSession() { + Application.promote(\.breakItService, with: BreakItService(name: "promoted")) + let service = Application.dependency(\.breakItService) + XCTAssertEqual(service.name, "promoted") + } + + // MARK: Description never crashes regardless of content + + @MainActor + func testDescriptionDoesNotCrashWithLargeState() { + var state = Application.state(\.breakItLargeArray) + state.value = Array(0..<1_000) + let desc = Application.description + XCTAssertFalse(desc.isEmpty) + } + + // MARK: codeID returns stable string for same call site + + func testCodeIDStability() { + let id1 = Application.codeID(fileID: "a/b.swift", function: "foo()", line: 10, column: 5) + let id2 = Application.codeID(fileID: "a/b.swift", function: "foo()", line: 10, column: 5) + XCTAssertEqual(id1, id2) + XCTAssertFalse(id1.isEmpty) + } + + // MARK: StoredState with nil initial then set then reset + + @MainActor + func testStoredStateNilInitialSetThenReset() { + let inMemory = BreakItInMemoryUserDefaults() + let override = Application.override(\.userDefaults, with: inMemory) + defer { Task { await override.cancel() } } + + var stored = Application.storedState(\.breakItStoredOptional) + XCTAssertNil(stored.value) + + stored.value = "hello" + XCTAssertEqual(Application.storedState(\.breakItStoredOptional).value, "hello") + + Application.reset(storedState: \.breakItStoredOptional) + XCTAssertNil(Application.storedState(\.breakItStoredOptional).value) + } + + // MARK: FileState reset idempotence + + @MainActor + func testFileStateResetIdempotence() { + FileManager.defaultFileStatePath = BreakItConstants.tempPath + var fileState = Application.fileState(\.breakItFileInt) + fileState.value = 1 + Application.reset(fileState: \.breakItFileInt) + Application.reset(fileState: \.breakItFileInt) // second reset on nil state + XCTAssertNil(Application.fileState(\.breakItFileInt).value) + } + + // MARK: State value is struct with many fields + + @MainActor + func testLargeStructRoundTrip() { + var state = Application.state(\.breakItNestedStruct) + var deep = BreakItDeep() + deep.child.tag = "modified" + deep.flag = true + state.value = deep + + let read = Application.state(\.breakItNestedStruct).value + XCTAssertEqual(read.child.tag, "modified") + XCTAssertTrue(read.flag) + } + + // MARK: Multiple independent Keychain instances don't interfere + + func testIndependentKeychainInstancesDoNotInterfere() { + let kc1 = Keychain() + let kc2 = Keychain() + + let key = "BreakItIsolation_\(UUID().uuidString)" + defer { + kc1.remove(key) + kc2.remove(key) + } + + kc1.set(value: "fromKC1", forKey: key) + // kc2 shares the underlying system keychain; it can read the same key + let read = kc2.get(key) + XCTAssertEqual(read, "fromKC1", "Both Keychain instances share the underlying system store") + + kc2.remove(key) + XCTAssertNil(kc1.get(key), "After kc2 removes the key kc1 must also see nil") + } + + // MARK: Application.logging toggle doesn't affect state correctness + + @MainActor + func testLoggingToggleDoesNotAffectStateCorrectness() { + Application.logging(isEnabled: false) + var state = Application.state(\.breakItCounter) + state.value = 55 + Application.logging(isEnabled: true) + XCTAssertEqual(Application.state(\.breakItCounter).value, 55) + } + + // MARK: writable slice updates underlying state + + @MainActor + func testWritableSliceUpdatesUnderlyingState() { + var profileState = Application.state(\.breakItProfileNonOptional) + profileState.value = BreakItProfile(name: "Dave", score: 0) + + var nameSlice = Application.slice(\.breakItProfileNonOptional, \BreakItProfile.name) + nameSlice.value = "Eve" + + XCTAssertEqual(Application.state(\.breakItProfileNonOptional).value.name, "Eve") + } + + // MARK: read-only slice cannot mutate (compile-time safety — runtime check) + + @MainActor + func testReadOnlySliceReadsCorrectly() { + var profileState = Application.state(\.breakItProfileNonOptional) + profileState.value = BreakItProfile(name: "Frank", score: 42) + + let readSlice = Application.slice(\.breakItProfileNonOptional, \BreakItProfile.score as KeyPath) + XCTAssertEqual(readSlice.value, 42) + } +} + +// MARK: - BUG DOCUMENTATION +// +// The following limitations/findings were uncovered during adversarial testing: +// +// 1. Keychain.set(value:forKey:) is NOT guarded by NSLock for the SecItemUpdate/SecItemAdd +// sequence — only SecItemCopyMatching (get) and SecItemDelete (remove) lock. +// Under extreme concurrent same-key set() stress, two writers can interleave between +// the SecItemUpdate check and the SecItemAdd, both adding duplicate items. +// Apple's Security framework silently ignores the second add (errSecDuplicateItem), +// so the data is not corrupted but the locking is inconsistent. +// Minimal repro: testKeychainConcurrentSameKey — survives because the Keychain API +// tolerates the race, not because the lock prevents it. +// +// 2. Keychain.values(ofType:) acquires the lock to copy the in-memory `keys` Set but +// then calls get() (which re-acquires the lock) outside the lock, creating a TOCTOU +// window where a key can be removed between the copy and the get. Not a crash, but +// a logical race. Tested via testKeychainConcurrentDistinctKeys — no crash observed. +// +// 3. Application.StoredState value getter does NOT cache the UserDefaults decode result +// in the Application cache when `shared.cache.get(…)` misses but +// `userDefaults.object(forKey:)` hits. The value is decoded on every read until a +// setter is called. This is a performance issue rather than a correctness bug but +// may be surprising. +// +// 4. SyncState.value setter calls `storedState.value = newValue` BEFORE the +// JSONEncoder().encode(newValue) call. If the encode throws (e.g., Double.infinity), +// the storedState is already updated with the un-encodable value while iCloud is not. +// On the next read, the icloudStore lookup fails → falls back to storedState, which +// returns Double.infinity. This means the "current" value is the un-encodable value +// even though iCloud never received it. Documented in testSyncStateWithNonEncodableValue. +// +// 5. Keychain.keys is @MainActor but Keychain.set(value:forKey:) is not @MainActor. +// The Task { @MainActor in keys.insert(key) } dispatch means keys.contains() can +// lag behind the actual keychain state until the next run loop turn. Not tested here +// as values() is @MainActor but the window exists. + +#endif diff --git a/Tests/AppStateTests/BugFixRegressionTests.swift b/Tests/AppStateTests/BugFixRegressionTests.swift new file mode 100644 index 0000000..4d8e2d8 --- /dev/null +++ b/Tests/AppStateTests/BugFixRegressionTests.swift @@ -0,0 +1,88 @@ +import XCTest + +@testable import AppState + +#if !os(Linux) && !os(Windows) +import Observation +#endif + +// MARK: - Fixtures + +extension Application { + /// A counter used to verify the imperative state accessor registers observation. + fileprivate var regressionObservationCounter: State { + state(initial: 0, feature: "Regression", id: "observationCounter") + } +} + +// MARK: - BugFixRegressionTests + +/// Regression tests pinning the behavior of bugs surfaced by the adversarial suite and since fixed. +/// +/// Each test documents the *fixed* contract so the bug cannot silently return. +final class BugFixRegressionTests: XCTestCase { + + // MARK: - Keychain index integrity (remove updates the in-memory key index) + + #if !os(Linux) && !os(Windows) + @MainActor + func testKeychainRemoveUpdatesValuesIndex() throws { + let keychain = Keychain() + let key = "regression.keychain.\(UUID().uuidString)" + defer { keychain.remove(key) } + + keychain.set(value: "secret", forKey: key) + XCTAssertEqual(keychain.values()[key], "secret") + + // Previously `remove` deleted the Keychain item but never updated the in-memory `keys` + // index, so `values()` kept reporting the removed key. It must now be gone from both. + keychain.remove(key) + XCTAssertNil(keychain.values()[key]) + XCTAssertFalse(keychain.contains(key)) + } + + @MainActor + func testKeychainSetUpdatesValuesIndexSynchronously() { + let keychain = Keychain() + let key = "regression.keychain.sync.\(UUID().uuidString)" + defer { keychain.remove(key) } + + // Previously the index was updated via a fire-and-forget `Task`, so `values()` called + // immediately after `set` on the same actor could miss the new key. It is now synchronous. + keychain.set(value: "token", forKey: key) + XCTAssertEqual(keychain.values()[key], "token") + } + #endif + + // MARK: - Observation: the imperative state accessor registers observation + + #if !os(Linux) && !os(Windows) + @MainActor + func testImperativeStateAccessorRegistersObservation() { + Application.reset(\.regressionObservationCounter) + + final class Flag: @unchecked Sendable { + var didChange = false + } + let flag = Flag() + + withObservationTracking { + // Reading through the imperative `Application.state(_:).value` accessor — not a property + // wrapper — must register the tracking scope so the change is observed. + _ = Application.state(\.regressionObservationCounter).value + } onChange: { + flag.didChange = true + } + + var state = Application.state(\.regressionObservationCounter) + state.value = 1 + + XCTAssertTrue( + flag.didChange, + "Application.state(_:).value must register observation so withObservationTracking fires." + ) + + Application.reset(\.regressionObservationCounter) + } + #endif +} diff --git a/Tests/AppStateTests/ModelStateCoverageTests.swift b/Tests/AppStateTests/ModelStateCoverageTests.swift new file mode 100644 index 0000000..5beccc5 --- /dev/null +++ b/Tests/AppStateTests/ModelStateCoverageTests.swift @@ -0,0 +1,644 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData +import XCTest +@testable import AppState + +// MARK: - Model Types + +/// Uniquely named @Model types to avoid collisions with ModelStateTests.swift. + +@Model +fileprivate final class MSCoverageNote { + var title: String + var body: String + var priority: Int + + init(title: String, body: String, priority: Int) { + self.title = title + self.body = body + self.priority = priority + } +} + +@Model +fileprivate final class MSCoverageTag { + var name: String + var color: String + + init(name: String, color: String) { + self.name = name + self.color = color + } +} + +/// A second, isolated model type used only in the secondary container tests, +/// so the secondary container never holds MSCoverageNote schema. +@Model +fileprivate final class MSCoverageEvent { + var label: String + + init(label: String) { + self.label = label + } +} + +// MARK: - Application Extensions + +fileprivate extension Application { + + // MARK: Primary container (MSCoverageNote + MSCoverageTag) + + /// Routes through `modelContainer(_:)` — auto-id container. + var mscPrimaryContainer: Dependency { + modelContainer( + try! ModelContainer( + for: MSCoverageNote.self, MSCoverageTag.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + ) + } + + // MARK: Secondary container (MSCoverageEvent only — isolated from primary) + + /// A completely separate ModelContainer to verify multi-container isolation. + var mscSecondaryContainer: Dependency { + modelContainer( + try! ModelContainer( + for: MSCoverageEvent.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + ) + } + + // MARK: ModelState overloads coverage + + /// Overload 1: `modelState(container:)` — auto-id, default FetchDescriptor. + /// This exercises the auto-id + default-descriptor overload path. + var mscNotes: Application.ModelState { + modelState(container: \.mscPrimaryContainer) + } + + /// Overload 2: `modelState(container:fetchDescriptor:)` — auto-id, explicit FetchDescriptor. + /// Uses a sort descriptor to exercise the FetchDescriptor-carrying auto-id overload. + var mscNotesByPriority: Application.ModelState { + modelState( + container: \.mscPrimaryContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.priority, order: .forward)] + ) + ) + } + + /// Overload 3: `modelState(container:feature:id:)` — explicit feature + id, default descriptor. + /// THIS OVERLOAD WAS PREVIOUSLY UNCOVERED — it has no FetchDescriptor parameter. + var mscNotesExplicitFeatureID: Application.ModelState { + modelState( + container: \.mscPrimaryContainer, + feature: "MSCoverageFeature", + id: "mscNotesExplicitFeatureID" + ) + } + + /// Overload 4: `modelState(container:fetchDescriptor:feature:id:)` — explicit feature + id + descriptor. + var mscNotesScopedWithDescriptor: Application.ModelState { + modelState( + container: \.mscPrimaryContainer, + fetchDescriptor: FetchDescriptor( + predicate: #Predicate { $0.priority > 5 } + ), + feature: "MSCoverageFeature", + id: "mscNotesScopedWithDescriptor" + ) + } + + /// Tags — uses the primary container, different model type. + var mscTags: Application.ModelState { + modelState(container: \.mscPrimaryContainer) + } + + /// Secondary-container state for isolation tests. + var mscEvents: Application.ModelState { + modelState(container: \.mscSecondaryContainer) + } + + /// A fetch-limited state for FetchDescriptor limit coverage. + var mscNotesLimited: Application.ModelState { + modelState( + container: \.mscPrimaryContainer, + fetchDescriptor: { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 2 + return descriptor + }(), + feature: "MSCoverageFeature", + id: "mscNotesLimited" + ) + } +} + +// MARK: - Property Wrapper Helpers + +@MainActor +fileprivate struct MSCoverageNoteReader { + @ModelState(\.mscNotes) var notes +} + +@MainActor +fileprivate final class MSCoverageNoteViewModel { + @ModelState(\.mscNotes) var notes + + func addNote(title: String, body: String, priority: Int) { + $notes.insert(MSCoverageNote(title: title, body: body, priority: priority)) + } + + func removeNote(_ note: MSCoverageNote) { + $notes.delete(note) + } + + func persistChanges() { + $notes.save() + } +} + +// MARK: - Test Suite + +final class ModelStateCoverageTests: XCTestCase { + + // MARK: - setUp / tearDown + + @MainActor + override func setUp() async throws { + Application.logging(isEnabled: true) + Application.modelState(\.mscNotes).deleteAll() + Application.modelState(\.mscTags).deleteAll() + Application.modelState(\.mscEvents).deleteAll() + XCTAssertTrue(Application.modelState(\.mscNotes).models.isEmpty) + XCTAssertTrue(Application.modelState(\.mscTags).models.isEmpty) + XCTAssertTrue(Application.modelState(\.mscEvents).models.isEmpty) + } + + @MainActor + override func tearDown() async throws { + Application.modelState(\.mscNotes).deleteAll() + Application.modelState(\.mscTags).deleteAll() + Application.modelState(\.mscEvents).deleteAll() + } + + // MARK: - Emoji + + /// `ModelState.emoji` was previously uncovered (line 20). + @MainActor + func testEmoji() { + let emoji = Application.ModelState.emoji + XCTAssertEqual(emoji, "🗃️") + } + + // MARK: - modelState overloads + + /// Overload: `modelState(container:)` — auto-id, default descriptor (Overload 1). + @MainActor + func testOverloadAutoIDDefaultDescriptor() { + let state = Application.modelState(\.mscNotes) + XCTAssertTrue(state.models.isEmpty) + + state.insert(MSCoverageNote(title: "Auto-ID", body: "default descriptor", priority: 1)) + XCTAssertEqual(state.models.count, 1) + } + + /// Overload: `modelState(container:fetchDescriptor:)` — auto-id, explicit descriptor (Overload 2). + @MainActor + func testOverloadAutoIDExplicitDescriptor() { + let baseState = Application.modelState(\.mscNotes) + baseState.insert(MSCoverageNote(title: "Z", body: "", priority: 30)) + baseState.insert(MSCoverageNote(title: "A", body: "", priority: 10)) + baseState.insert(MSCoverageNote(title: "M", body: "", priority: 20)) + + let sortedState = Application.modelState(\.mscNotesByPriority) + let sorted = sortedState.models + + XCTAssertEqual(sorted.count, 3) + XCTAssertEqual(sorted.map(\.priority), [10, 20, 30]) + } + + /// Overload: `modelState(container:feature:id:)` — explicit feature + id, default descriptor (Overload 3). + /// THIS WAS THE PREVIOUSLY UNCOVERED OVERLOAD. + @MainActor + func testOverloadExplicitFeatureIDDefaultDescriptor() { + let state = Application.modelState(\.mscNotesExplicitFeatureID) + + XCTAssertTrue(state.models.isEmpty) + + state.insert(MSCoverageNote(title: "FeatureID", body: "explicit overload", priority: 5)) + + // Same container — also visible through mscNotes + XCTAssertEqual(state.models.count, 1) + XCTAssertEqual(Application.modelState(\.mscNotes).models.count, 1) + } + + /// Overload: `modelState(container:fetchDescriptor:feature:id:)` — explicit feature + id + descriptor (Overload 4). + @MainActor + func testOverloadExplicitFeatureIDWithDescriptor() { + let baseState = Application.modelState(\.mscNotes) + baseState.insert(MSCoverageNote(title: "Low", body: "", priority: 3)) + baseState.insert(MSCoverageNote(title: "High", body: "", priority: 9)) + + let filteredState = Application.modelState(\.mscNotesScopedWithDescriptor) + let filtered = filteredState.models + + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered.first?.title, "High") + } + + // MARK: - modelContainer / modelContext + + /// `modelContainer(_:)` produces a stable dependency; same key path returns the same container instance. + @MainActor + func testModelContainerSameContainerIdentity() { + let context1 = Application.modelContext(\.mscPrimaryContainer) + let context2 = Application.modelContext(\.mscPrimaryContainer) + XCTAssertTrue(context1 === context2, "modelContext must return the same mainContext instance") + } + + /// `modelContext` shares the main context with ModelState's `context` property. + @MainActor + func testModelContextMatchesModelStateContext() { + let directContext = Application.modelContext(\.mscPrimaryContainer) + let stateContext = Application.modelState(\.mscNotes).context + XCTAssertTrue(directContext === stateContext) + } + + /// Inserting through the raw context is reflected in ModelState.models. + @MainActor + func testModelContextDirectInsertReflectedInModelState() { + let ctx = Application.modelContext(\.mscPrimaryContainer) + ctx.insert(MSCoverageNote(title: "DirectCtx", body: "via context", priority: 1)) + try? ctx.save() + + let notes = Application.modelState(\.mscNotes).models + XCTAssertEqual(notes.count, 1) + XCTAssertEqual(notes.first?.title, "DirectCtx") + } + + // MARK: - insert + + @MainActor + func testInsertSingleModel() { + let state = Application.modelState(\.mscNotes) + state.insert(MSCoverageNote(title: "InsertOne", body: "body", priority: 1)) + + XCTAssertEqual(state.models.count, 1) + XCTAssertEqual(state.models.first?.title, "InsertOne") + } + + @MainActor + func testInsertMultipleModels() { + let state = Application.modelState(\.mscNotes) + state.insert(MSCoverageNote(title: "First", body: "", priority: 1)) + state.insert(MSCoverageNote(title: "Second", body: "", priority: 2)) + state.insert(MSCoverageNote(title: "Third", body: "", priority: 3)) + + XCTAssertEqual(state.models.count, 3) + XCTAssertTrue(state.models.contains { $0.title == "First" }) + XCTAssertTrue(state.models.contains { $0.title == "Second" }) + XCTAssertTrue(state.models.contains { $0.title == "Third" }) + } + + // MARK: - delete + + @MainActor + func testDeleteExistingModel() { + let state = Application.modelState(\.mscNotes) + let note = MSCoverageNote(title: "ToDelete", body: "", priority: 1) + state.insert(note) + XCTAssertEqual(state.models.count, 1) + + state.delete(note) + XCTAssertTrue(state.models.isEmpty) + } + + @MainActor + func testDeleteOneOfManyModels() { + let state = Application.modelState(\.mscNotes) + let keep = MSCoverageNote(title: "Keep", body: "", priority: 1) + let remove = MSCoverageNote(title: "Remove", body: "", priority: 2) + state.insert(keep) + state.insert(remove) + + XCTAssertEqual(state.models.count, 2) + state.delete(remove) + + let remaining = state.models + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.title, "Keep") + } + + // MARK: - save (guard early-return path) + + /// Calling `save()` with no pending changes exercises the `guard context.hasChanges else { return }` + /// early-return path, which was previously an uncovered branch. + @MainActor + func testSaveWithNoPendingChangesEarlyReturn() { + let state = Application.modelState(\.mscNotes) + // No inserts — context has no changes. save() must complete without error. + state.save() + XCTAssertTrue(state.models.isEmpty) + } + + /// Calling `save()` after a mutation exercises the normal (non-early-return) save path. + @MainActor + func testSaveWithPendingChanges() { + let state = Application.modelState(\.mscNotes) + let note = MSCoverageNote(title: "SaveTest", body: "needs saving", priority: 7) + state.insert(note) + + // Mutate without going through insert (which already saves). + // Re-read via models, then explicitly save. + let fetched = state.models.first! + fetched.priority = 99 + state.save() + + XCTAssertEqual(state.models.first?.priority, 99) + } + + // MARK: - deleteAll + + @MainActor + func testDeleteAllWhenPopulated() { + let state = Application.modelState(\.mscNotes) + state.insert(MSCoverageNote(title: "N1", body: "", priority: 1)) + state.insert(MSCoverageNote(title: "N2", body: "", priority: 2)) + state.insert(MSCoverageNote(title: "N3", body: "", priority: 3)) + + XCTAssertEqual(state.models.count, 3) + state.deleteAll() + XCTAssertTrue(state.models.isEmpty) + } + + /// Calling `deleteAll()` on an already-empty store should be a no-op (exercises the + /// `save(context:action:)` guard branch where `!context.hasChanges`). + @MainActor + func testDeleteAllWhenAlreadyEmpty() { + let state = Application.modelState(\.mscNotes) + XCTAssertTrue(state.models.isEmpty) + + // Should not crash or error. + state.deleteAll() + XCTAssertTrue(state.models.isEmpty) + } + + // MARK: - FetchDescriptor behaviors + + /// Sorting via FetchDescriptor is applied on every `models` access. + @MainActor + func testFetchDescriptorSortsResults() { + let baseState = Application.modelState(\.mscNotes) + baseState.insert(MSCoverageNote(title: "Beta", body: "", priority: 2)) + baseState.insert(MSCoverageNote(title: "Alpha", body: "", priority: 1)) + baseState.insert(MSCoverageNote(title: "Gamma", body: "", priority: 3)) + + let sorted = Application.modelState(\.mscNotesByPriority).models + XCTAssertEqual(sorted.map(\.priority), [1, 2, 3]) + XCTAssertEqual(sorted.map(\.title), ["Alpha", "Beta", "Gamma"]) + } + + /// `#Predicate` filtering returns only matching models. + @MainActor + func testFetchDescriptorPredicateFiltering() { + let baseState = Application.modelState(\.mscNotes) + baseState.insert(MSCoverageNote(title: "Low1", body: "", priority: 1)) + baseState.insert(MSCoverageNote(title: "Low2", body: "", priority: 4)) + baseState.insert(MSCoverageNote(title: "High1", body: "", priority: 8)) + baseState.insert(MSCoverageNote(title: "High2", body: "", priority: 10)) + + let filtered = Application.modelState(\.mscNotesScopedWithDescriptor).models + XCTAssertEqual(filtered.count, 2) + XCTAssertTrue(filtered.allSatisfy { $0.priority > 5 }) + } + + /// `fetchLimit` caps the number of returned models. + @MainActor + func testFetchDescriptorFetchLimit() { + let baseState = Application.modelState(\.mscNotes) + for index in 1...5 { + baseState.insert(MSCoverageNote(title: "Note\(index)", body: "", priority: index)) + } + + let limited = Application.modelState(\.mscNotesLimited).models + XCTAssertLessThanOrEqual(limited.count, 2) + } + + // MARK: - Multi-container isolation + + /// Two distinct ModelContainer dependencies must not share data. + @MainActor + func testMultiContainerIsolation() { + let notes = Application.modelState(\.mscNotes) + let events = Application.modelState(\.mscEvents) + + notes.insert(MSCoverageNote(title: "NoteA", body: "", priority: 1)) + events.insert(MSCoverageEvent(label: "EventA")) + + XCTAssertEqual(notes.models.count, 1) + XCTAssertEqual(events.models.count, 1) + + notes.deleteAll() + XCTAssertTrue(notes.models.isEmpty) + // Events in the secondary container must be unaffected. + XCTAssertEqual(events.models.count, 1) + } + + // MARK: - Feature / id scoping + + /// Two ModelStates with different feature+id values are distinct scopes. + @MainActor + func testDifferentFeatureIDsAreDistinctScopes() { + let stateA = Application.modelState(\.mscNotesExplicitFeatureID) + let stateB = Application.modelState(\.mscNotesScopedWithDescriptor) + + // stateA — feature "MSCoverageFeature", id "mscNotesExplicitFeatureID" + // stateB — feature "MSCoverageFeature", id "mscNotesScopedWithDescriptor" + // Both have different scope ids; the scopes must differ. + XCTAssertNotEqual(stateA.scope.id, stateB.scope.id) + } + + /// Two ModelStates on the same container share persisted data. + @MainActor + func testSameContainerSharedData() { + let state1 = Application.modelState(\.mscNotes) + let state2 = Application.modelState(\.mscNotesByPriority) + + state1.insert(MSCoverageNote(title: "Shared", body: "", priority: 1)) + + // Both states read from the same container; state2 must see the inserted note. + XCTAssertEqual(state2.models.count, 1) + XCTAssertEqual(state2.models.first?.title, "Shared") + } + + // MARK: - Multiple model types on same container + + @MainActor + func testMultipleModelTypesOnSameContainer() { + let notes = Application.modelState(\.mscNotes) + let tags = Application.modelState(\.mscTags) + + notes.insert(MSCoverageNote(title: "NoteX", body: "", priority: 1)) + tags.insert(MSCoverageTag(name: "TagX", color: "red")) + + XCTAssertEqual(notes.models.count, 1) + XCTAssertEqual(tags.models.count, 1) + + notes.deleteAll() + XCTAssertTrue(notes.models.isEmpty) + // Tags must be unaffected by deleting notes. + XCTAssertEqual(tags.models.count, 1) + } + + // MARK: - @ModelState property wrapper + + /// `wrappedValue` returns the live-fetched models array. + @MainActor + func testPropertyWrapperWrappedValue() { + let reader = MSCoverageNoteReader() + XCTAssertTrue(reader.notes.isEmpty) + + Application.modelState(\.mscNotes).insert( + MSCoverageNote(title: "WrapperNote", body: "", priority: 3) + ) + + XCTAssertEqual(reader.notes.count, 1) + XCTAssertEqual(reader.notes.first?.title, "WrapperNote") + } + + /// `projectedValue` exposes the underlying `Application.ModelState` for mutations. + @MainActor + func testPropertyWrapperProjectedValueInsert() { + let reader = MSCoverageNoteReader() + reader.$notes.insert(MSCoverageNote(title: "Projected", body: "", priority: 5)) + + XCTAssertEqual(reader.notes.count, 1) + XCTAssertEqual(reader.notes.first?.title, "Projected") + } + + @MainActor + func testPropertyWrapperProjectedValueDelete() { + let reader = MSCoverageNoteReader() + let note = MSCoverageNote(title: "DeleteMe", body: "", priority: 1) + reader.$notes.insert(note) + XCTAssertEqual(reader.notes.count, 1) + + reader.$notes.delete(note) + XCTAssertTrue(reader.notes.isEmpty) + } + + @MainActor + func testPropertyWrapperProjectedValueSave() { + let reader = MSCoverageNoteReader() + let note = MSCoverageNote(title: "SaveMe", body: "", priority: 1) + reader.$notes.insert(note) + + let fetched = reader.notes.first! + fetched.body = "updated" + reader.$notes.save() + + XCTAssertEqual(reader.notes.first?.body, "updated") + } + + @MainActor + func testPropertyWrapperProjectedValueDeleteAll() { + let reader = MSCoverageNoteReader() + reader.$notes.insert(MSCoverageNote(title: "A", body: "", priority: 1)) + reader.$notes.insert(MSCoverageNote(title: "B", body: "", priority: 2)) + XCTAssertEqual(reader.notes.count, 2) + + reader.$notes.deleteAll() + XCTAssertTrue(reader.notes.isEmpty) + } + + /// @ModelState used from a class (@MainActor class ViewModel). + @MainActor + func testPropertyWrapperFromViewModelClass() { + let viewModel = MSCoverageNoteViewModel() + XCTAssertTrue(viewModel.notes.isEmpty) + + viewModel.addNote(title: "VM-Note1", body: "first", priority: 1) + viewModel.addNote(title: "VM-Note2", body: "second", priority: 2) + + XCTAssertEqual(viewModel.notes.count, 2) + + let target = viewModel.notes.first { $0.title == "VM-Note1" }! + viewModel.removeNote(target) + + XCTAssertEqual(viewModel.notes.count, 1) + XCTAssertEqual(viewModel.notes.first?.title, "VM-Note2") + } + + /// Changes made through the ViewModel are visible through the bare Application.modelState. + @MainActor + func testViewModelChangesReflectedInApplicationModelState() { + let viewModel = MSCoverageNoteViewModel() + viewModel.addNote(title: "Shared-VM", body: "", priority: 42) + + let direct = Application.modelState(\.mscNotes).models + XCTAssertEqual(direct.count, 1) + XCTAssertEqual(direct.first?.title, "Shared-VM") + XCTAssertEqual(direct.first?.priority, 42) + } + + /// `persistChanges` on the viewModel exercises the public `save()` method + /// (ensuring the non-early-return path is hit when changes are pending). + @MainActor + func testPropertyWrapperSaveWithPendingChanges() { + let viewModel = MSCoverageNoteViewModel() + viewModel.addNote(title: "ModifyMe", body: "original", priority: 1) + + let note = viewModel.notes.first! + note.body = "modified" + viewModel.persistChanges() + + XCTAssertEqual(Application.modelState(\.mscNotes).models.first?.body, "modified") + } + + /// Calling `persistChanges()` when there are no pending changes exercises + /// the `guard context.hasChanges else { return }` early-return path via `save()`. + @MainActor + func testPropertyWrapperSaveNoOpWhenNoPendingChanges() { + let viewModel = MSCoverageNoteViewModel() + // Nothing inserted; context has no changes. + viewModel.persistChanges() + XCTAssertTrue(viewModel.notes.isEmpty) + } + + // MARK: - Scope properties + + @MainActor + func testModelStateScopeProperties() { + let state = Application.modelState(\.mscNotesExplicitFeatureID) + XCTAssertEqual(state.scope.name, "MSCoverageFeature") + XCTAssertEqual(state.scope.id, "mscNotesExplicitFeatureID") + } + + @MainActor + func testModelStateScopeDefaultFeatureName() { + // The default feature for the explicit-id-no-feature overload uses "App". + // mscNotesScopedWithDescriptor uses "MSCoverageFeature" explicitly — + // use a direct call to verify the default-feature path. + let state: Application.ModelState = Application.shared.modelState( + container: \.mscPrimaryContainer, + id: "mscDefaultFeatureScope" + ) + XCTAssertEqual(state.scope.name, "App") + XCTAssertEqual(state.scope.id, "mscDefaultFeatureScope") + } + + // MARK: - containerKeyPath + + @MainActor + func testContainerKeyPathProperty() { + let state = Application.modelState(\.mscNotes) + let context1 = Application.dependency(state.containerKeyPath).mainContext + let context2 = Application.modelState(\.mscNotes).context + XCTAssertTrue(context1 === context2) + } +} +#endif diff --git a/Tests/AppStateTests/ModelStateTests.swift b/Tests/AppStateTests/ModelStateTests.swift new file mode 100644 index 0000000..6544067 --- /dev/null +++ b/Tests/AppStateTests/ModelStateTests.swift @@ -0,0 +1,219 @@ +#if canImport(SwiftData) +import Foundation +import SwiftData +import XCTest +@testable import AppState + +@Model +final class TestItem { + var title: String + var value: Int + + init(title: String, value: Int) { + self.title = title + self.value = value + } +} + +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" + ) + } +} + +@MainActor +fileprivate struct ExampleModelValue { + @ModelState(\.items) var items +} + +@MainActor +fileprivate class ExampleModelViewModel { + @ModelState(\.items) var items + + func addItem(title: String, value: Int) { + $items.insert(TestItem(title: title, value: value)) + } +} + +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"]) + } + + // MARK: - Strict (throwing) API + + @MainActor + func testStrictMutatorsPerformCRUD() throws { + let items = Application.modelState(\.items) + + let first = TestItem(title: "first", value: 1) + try items.strict.insert(first) + try items.strict.insert(TestItem(title: "second", value: 2)) + XCTAssertEqual(items.models.count, 2) + + first.value = 99 + try items.strict.save() + XCTAssertEqual(items.models.first(where: { $0.title == "first" })?.value, 99) + + try items.strict.delete(first) + XCTAssertEqual(items.models.map(\.title), ["second"]) + + try items.strict.deleteAll() + XCTAssertTrue(items.models.isEmpty) + } + + @MainActor + func testLenientAndStrictShareTheSameStore() throws { + let items = Application.modelState(\.items) + + items.insert(TestItem(title: "lenient", value: 1)) + try items.strict.insert(TestItem(title: "strict", value: 2)) + + XCTAssertEqual(Set(items.models.map(\.title)), ["lenient", "strict"]) + } +} +#endif diff --git a/Tests/AppStateTests/ObservationBridgeTests.swift b/Tests/AppStateTests/ObservationBridgeTests.swift new file mode 100644 index 0000000..9375fe8 --- /dev/null +++ b/Tests/AppStateTests/ObservationBridgeTests.swift @@ -0,0 +1,638 @@ +#if !os(Linux) && !os(Windows) +import Foundation +import Observation +import XCTest +@testable import AppState + +// MARK: - Application state extensions (ObsBridge-prefixed to avoid collisions) + +fileprivate extension Application { + var obsBridgeCounter: State { + state(initial: 0, id: "obsBridgeCounter") + } + + var obsBridgeStoredInt: StoredState { + storedState(initial: 0, id: "obsBridgeStoredInt") + } + + @available(watchOS 9.0, *) + var obsBridgeSyncString: SyncState { + syncState(initial: "bridge", id: "obsBridgeSyncString") + } + + @MainActor + var obsBridgeFileString: FileState { + fileState(path: "./ObsBridgeTests", filename: "obsBridgeFileString") + } + + var obsBridgeSecureToken: SecureState { + secureState(feature: "ObsBridgeTests", id: "obsBridgeSecureToken") + } + + var obsBridgePoint: State { + state(initial: ObsBridgePoint(x: 0, y: 0), id: "obsBridgePoint") + } + + var obsBridgeOptionalPoint: State { + state(initial: nil, id: "obsBridgeOptionalPoint") + } + + var obsBridgeMathService: Dependency { + dependency(ObsBridgeMathService(), id: "obsBridgeMathService") + } + + var obsBridgeOptionalRecord: State { + state(initial: nil, id: "obsBridgeOptionalRecord") + } +} + +// MARK: - Supporting types + +private struct ObsBridgePoint: Equatable, Codable, Sendable { + var x: Int + var y: Int +} + +/// A record with an optional field, used to exercise the `optionalValueKeyPath` path +/// in `OptionalConstant.wrappedValue` — specifically the `return nil` branch when +/// the nested optional is nil. +private struct ObsBridgeRecord: Equatable, Codable, Sendable { + var nickname: String? + var score: Int +} + +/// Using a class so DependencySlice mutations (which modify the reference in place) persist. +@MainActor +private final class ObsBridgeMathService: Sendable { + var multiplier: Int = 3 +} + +// MARK: - ChangeFlag helper + +/// A `Sendable` mutable flag that an `@Sendable` `onChange` closure can write to. +private final class ObsBridgeChangeFlag: @unchecked Sendable { + var didChange: Bool = false + var changeCount: Int = 0 + func fire() { didChange = true; changeCount += 1 } + func reset() { didChange = false; changeCount = 0 } +} + +// MARK: - Property wrapper holders for observation tracking + +@MainActor +private struct ObsBridgeAppStateHolder { + @AppState(\.obsBridgeCounter) var counter: Int +} + +@MainActor +private struct ObsBridgeStoredStateHolder { + @StoredState(\.obsBridgeStoredInt) var value: Int +} + +@available(watchOS 9.0, *) +@MainActor +private struct ObsBridgeSyncStateHolder { + @SyncState(\.obsBridgeSyncString) var label: String +} + +@MainActor +private struct ObsBridgeFileStateHolder { + @FileState(\.obsBridgeFileString) var content: String? +} + +@MainActor +private struct ObsBridgeSecureStateHolder { + @SecureState(\.obsBridgeSecureToken) var token: String? +} + +@MainActor +private struct ObsBridgeSliceHolder { + @Slice(\.obsBridgePoint, \.x) var xCoord: Int +} + +@MainActor +private struct ObsBridgeOptionalSliceHolder { + @OptionalSlice(\.obsBridgeOptionalPoint, \.x) var optX: Int? +} + +@MainActor +private struct ObsBridgeDependencySliceHolder { + @DependencySlice(\.obsBridgeMathService, \.multiplier) var multiplier: Int +} + +/// Uses the `optionalValueKeyPath` form of `OptionalConstant` so the `return nil` +/// branch (line 43 of OptionalConstant.swift) can be exercised when the nested +/// optional field is nil. +@MainActor +private struct ObsBridgeOptionalConstantHolder { + @OptionalConstant(\.obsBridgeOptionalRecord, \.nickname) var nickname: String? +} + +// MARK: - ObservationBridgeTests + +/// Verifies the Observation bridge for every state-style property wrapper: +/// reading registers an observation dependency, mutating fires the `onChange` closure, +/// and reading without mutation does not fire. +/// +/// This extends the coverage of `ObservationTests.swift` without duplicating its logic. +@MainActor +final class ObservationBridgeTests: XCTestCase { + + // MARK: - Overrides + + private var userDefaultsOverride: Application.DependencyOverride? + private var icloudOverride: Application.DependencyOverride? + private var mathServiceOverride: Application.DependencyOverride? + + // MARK: - Lifecycle + + override func setUp() async throws { + try await super.setUp() + Application.logging(isEnabled: false) + + userDefaultsOverride = Application.override( + \.userDefaults, + with: ObsBridgeInMemoryUserDefaults() as UserDefaultsManaging + ) + + if #available(watchOS 9.0, *) { + icloudOverride = Application.override( + \.icloudStore, + with: ObsBridgeInMemoryKeyValueStore() as UbiquitousKeyValueStoreManaging + ) + } + + // Provide a fresh service instance so DependencySlice mutations don't bleed across tests. + mathServiceOverride = Application.override(\.obsBridgeMathService, with: ObsBridgeMathService()) + + Application.reset(\.obsBridgeCounter) + Application.reset(storedState: \.obsBridgeStoredInt) + Application.reset(secureState: \.obsBridgeSecureToken) + Application.reset(fileState: \.obsBridgeFileString) + Application.reset(\.obsBridgePoint) + Application.reset(\.obsBridgeOptionalPoint) + Application.reset(\.obsBridgeOptionalRecord) + + if #available(watchOS 9.0, *) { + Application.reset(syncState: \.obsBridgeSyncString) + } + + FileManager.defaultFileStatePath = "./ObsBridgeTests" + } + + override func tearDown() async throws { + Application.reset(\.obsBridgeCounter) + Application.reset(storedState: \.obsBridgeStoredInt) + Application.reset(secureState: \.obsBridgeSecureToken) + Application.reset(fileState: \.obsBridgeFileString) + Application.reset(\.obsBridgePoint) + Application.reset(\.obsBridgeOptionalPoint) + Application.reset(\.obsBridgeOptionalRecord) + + if #available(watchOS 9.0, *) { + Application.reset(syncState: \.obsBridgeSyncString) + } + + try? Application.dependency(\.fileManager).removeItem(atPath: "./ObsBridgeTests") + + await mathServiceOverride?.cancel() + mathServiceOverride = nil + await icloudOverride?.cancel() + icloudOverride = nil + await userDefaultsOverride?.cancel() + userDefaultsOverride = nil + + try await super.tearDown() + } + + // MARK: - @AppState observation bridge + + func testAppStateMutationFiresObservation() { + let holder = ObsBridgeAppStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.counter + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange, "No mutation yet — observer should not have fired") + + holder.counter = 10 + + XCTAssertTrue(flag.didChange, "Mutation should fire the observer") + XCTAssertEqual(holder.counter, 10) + } + + func testAppStateReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.state(\.obsBridgeCounter).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange, "Read without mutation must not fire the observer") + } + + // MARK: - @StoredState observation bridge + + func testStoredStateMutationFiresObservation() { + let holder = ObsBridgeStoredStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.value = 99 + + XCTAssertTrue(flag.didChange, "StoredState mutation should fire the observer") + XCTAssertEqual(holder.value, 99) + } + + func testStoredStateReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.storedState(\.obsBridgeStoredInt).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - @SyncState observation bridge + + @available(watchOS 9.0, *) + func testSyncStateMutationFiresObservation() { + let holder = ObsBridgeSyncStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.label + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.label = "updated" + + XCTAssertTrue(flag.didChange, "SyncState mutation should fire the observer") + XCTAssertEqual(holder.label, "updated") + } + + @available(watchOS 9.0, *) + func testSyncStateReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.syncState(\.obsBridgeSyncString).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - @FileState observation bridge + + func testFileStateMutationFiresObservation() { + let holder = ObsBridgeFileStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.content + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.content = "file-written" + + XCTAssertTrue(flag.didChange, "FileState mutation should fire the observer") + XCTAssertEqual(holder.content, "file-written") + } + + func testFileStateReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.fileState(\.obsBridgeFileString).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - @SecureState observation bridge + + func testSecureStateMutationFiresObservation() { + let holder = ObsBridgeSecureStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.token + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.token = "secure-value" + + XCTAssertTrue(flag.didChange, "SecureState mutation should fire the observer") + XCTAssertEqual(holder.token, "secure-value") + } + + func testSecureStateReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.secureState(\.obsBridgeSecureToken).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - @Slice observation bridge + + func testSliceMutationFiresObservation() { + let holder = ObsBridgeSliceHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.xCoord + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.xCoord = 55 + + XCTAssertTrue(flag.didChange, "Slice mutation should fire the observer") + XCTAssertEqual(Application.state(\.obsBridgePoint).value.x, 55) + } + + func testSliceReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.slice(\.obsBridgePoint, \.x as WritableKeyPath).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - @OptionalSlice observation bridge + + func testOptionalSliceMutationFiresObservationWhenParentIsSet() { + var pointState = Application.state(\.obsBridgeOptionalPoint) + pointState.value = ObsBridgePoint(x: 0, y: 0) + + let holder = ObsBridgeOptionalSliceHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.optX + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.optX = 12 + + XCTAssertTrue(flag.didChange, "OptionalSlice mutation should fire the observer") + XCTAssertEqual(Application.state(\.obsBridgeOptionalPoint).value?.x, 12) + } + + // MARK: - @OptionalConstant nil path coverage + + /// Exercises the `return nil` guard-else branch inside `OptionalConstant.wrappedValue`. + /// + /// The branch at `OptionalConstant.swift` line 43 is taken when the *outer* optional state + /// value is `nil` — i.e. `State.value == nil`. In that case + /// `Application.slice(stateKeyPath, optionalValueKeyPath).value` returns `nil` (a nil + /// `SliceValue??`), the `guard let slicedValue` condition fails, and `return nil` executes. + func testOptionalConstantReturnsNilWhenOuterStateIsNil() { + // Ensure the outer optional state is nil so the guard fails. + Application.reset(\.obsBridgeOptionalRecord) + + let holder = ObsBridgeOptionalConstantHolder() + + XCTAssertNil(holder.nickname, "OptionalConstant must return nil when the outer state is nil") + } + + /// Verifies that `OptionalConstant` correctly returns the value when both optionals are non-nil. + func testOptionalConstantReturnsValueWhenBothOptionalsAreSet() { + var state = Application.state(\.obsBridgeOptionalRecord) + state.value = ObsBridgeRecord(nickname: "Leif", score: 10) + + let holder = ObsBridgeOptionalConstantHolder() + + XCTAssertEqual(holder.nickname, "Leif") + } + + /// Verifies that `OptionalConstant` returns nil when outer state is non-nil but inner field is nil. + func testOptionalConstantReturnsNilWhenInnerOptionalFieldIsNil() { + var state = Application.state(\.obsBridgeOptionalRecord) + state.value = ObsBridgeRecord(nickname: nil, score: 42) + + let holder = ObsBridgeOptionalConstantHolder() + + // The guard succeeds (outer is Some) but `slicedValue` is `String?.none` = nil. + XCTAssertNil(holder.nickname, "OptionalConstant with nil inner optional must return nil") + } + + // MARK: - @DependencySlice observation bridge + + func testDependencySliceMutationFiresObservation() { + let holder = ObsBridgeDependencySliceHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.multiplier + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + holder.multiplier = 6 + + XCTAssertTrue(flag.didChange, "DependencySlice mutation should fire the observer") + XCTAssertEqual(Application.dependency(\.obsBridgeMathService).multiplier, 6) + } + + func testDependencySliceReadWithoutMutationDoesNotFire() { + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = Application.dependencySlice(\.obsBridgeMathService, \.multiplier as WritableKeyPath).value + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + } + + // MARK: - Multiple independent observers + + func testMultipleIndependentObserversAllFire() { + let holder = ObsBridgeAppStateHolder() + let flag1 = ObsBridgeChangeFlag() + let flag2 = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.counter + } onChange: { + flag1.fire() + } + + withObservationTracking { + _ = holder.counter + } onChange: { + flag2.fire() + } + + holder.counter = 77 + + XCTAssertTrue(flag1.didChange, "First observer should fire") + XCTAssertTrue(flag2.didChange, "Second observer should fire") + } + + func testSubsequentObservationsTrackIndependently() { + let holder = ObsBridgeAppStateHolder() + let firstFlag = ObsBridgeChangeFlag() + let secondFlag = ObsBridgeChangeFlag() + + // Register first observer + withObservationTracking { + _ = holder.counter + } onChange: { + firstFlag.fire() + } + + holder.counter = 1 + XCTAssertTrue(firstFlag.didChange) + + // First observer has been consumed; register a fresh one + withObservationTracking { + _ = holder.counter + } onChange: { + secondFlag.fire() + } + + XCTAssertFalse(secondFlag.didChange, "Second observer should not fire yet") + + holder.counter = 2 + XCTAssertTrue(secondFlag.didChange, "Second observer fires after second mutation") + } + + // MARK: - Application.notifyChange() directly + + /// Calling `notifyChange()` on the main thread bumps `changeAnchor`, which fires + /// any registered observation observers — the same mechanism SwiftUI uses. + func testNotifyChangeDirectlyBumpsObservers() { + let holder = ObsBridgeAppStateHolder() + let flag = ObsBridgeChangeFlag() + + withObservationTracking { + _ = holder.counter + } onChange: { + flag.fire() + } + + XCTAssertFalse(flag.didChange) + + Application.shared.notifyChange() + + XCTAssertTrue(flag.didChange, "notifyChange() must fire registered observers") + } + + func testNotifyChangeCanBeCalledRepeatedly() { + // Just verifying it does not crash or assert on repeated main-thread calls. + for _ in 0..<5 { + Application.shared.notifyChange() + } + } + + // MARK: - didChangeExternally(notification:) + + /// Exercises the `didChangeExternally(notification:)` path so the body is covered. + /// The method only logs — verifying it does not throw or crash is sufficient. + @available(watchOS 9.0, *) + func testDidChangeExternallyDoesNotCrash() { + let notification = Notification( + name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: NSUbiquitousKeyValueStore.default, + userInfo: [ + NSUbiquitousKeyValueStoreChangeReasonKey: NSUbiquitousKeyValueStoreServerChange, + NSUbiquitousKeyValueStoreChangedKeysKey: ["someKey"] + ] + ) + + // Call on the main actor (the invariant the method requires). + Application.shared.didChangeExternally(notification: notification) + } + + /// Also verify a custom `Application` subclass can override `didChangeExternally`. + @available(watchOS 9.0, *) + func testCustomApplicationSubclassCanOverrideDidChangeExternally() { + final class ObsBridgeCustomApplication: Application { + @MainActor + var externalChangeReceived: Bool = false + + @MainActor + override func didChangeExternally(notification: Notification) { + externalChangeReceived = true + super.didChangeExternally(notification: notification) + } + } + + let customApp = ObsBridgeCustomApplication() + let notification = Notification( + name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: NSUbiquitousKeyValueStore.default + ) + + customApp.didChangeExternally(notification: notification) + + XCTAssertTrue(customApp.externalChangeReceived) + } +} + +// MARK: - In-memory test doubles (ObsBridge-prefixed) + +private final class ObsBridgeInMemoryUserDefaults: UserDefaultsManaging, @unchecked Sendable { + private var storage: [String: Any] = [:] + func object(forKey key: String) -> Any? { storage[key] } + func set(_ value: Any?, forKey key: String) { storage[key] = value } + func removeObject(forKey key: String) { storage.removeValue(forKey: key) } +} + +@available(watchOS 9.0, *) +private final class ObsBridgeInMemoryKeyValueStore: UbiquitousKeyValueStoreManaging, @unchecked Sendable { + private var storage: [String: Data] = [:] + func data(forKey key: String) -> Data? { storage[key] } + func set(_ value: Data?, forKey key: String) { storage[key] = value } + func removeObject(forKey key: String) { storage.removeValue(forKey: key) } +} + +#endif // !os(Linux) && !os(Windows) diff --git a/Tests/AppStateTests/ObservationTests.swift b/Tests/AppStateTests/ObservationTests.swift new file mode 100644 index 0000000..e4c250a --- /dev/null +++ b/Tests/AppStateTests/ObservationTests.swift @@ -0,0 +1,74 @@ +#if !os(Linux) && !os(Windows) +import Observation +import XCTest +@testable import AppState + +fileprivate extension Application { + var observationCounter: Application.State { + state(initial: 0) + } +} + +@MainActor +fileprivate struct ObservationCounterHolder { + @AppState(\.observationCounter) var count: Int +} + +/// Verifies the Observation bridge that backs SwiftUI reactivity: reading a property wrapper +/// registers an observation dependency on `Application`, and mutating the underlying state notifies +/// observers. This exercises the same `registerObservation()` / `notifyChange()` mechanism SwiftUI +/// relies on, without requiring a running SwiftUI view. +final class ObservationTests: XCTestCase { + /// A `Sendable` flag the `@Sendable` `onChange` closure can write to. + private final class ChangeFlag: @unchecked Sendable { + var didChange = false + } + + @MainActor + override func setUp() async throws { + Application.reset(\.observationCounter) + } + + @MainActor + override func tearDown() async throws { + Application.reset(\.observationCounter) + } + + @MainActor + func testMutatingStateNotifiesObservers() { + let holder = ObservationCounterHolder() + let flag = ChangeFlag() + + withObservationTracking { + // Reading the wrapped value calls `registerObservation()`, registering this tracking + // scope as dependent on AppState — exactly what happens inside a SwiftUI view body. + _ = holder.count + } onChange: { + flag.didChange = true + } + + XCTAssertFalse(flag.didChange) + + // Mutating the state writes through the cache, which bumps the observation anchor and should + // synchronously fire the registered `onChange`. + holder.count = 1 + + XCTAssertTrue(flag.didChange, "Expected an observation change when the state was mutated") + XCTAssertEqual(holder.count, 1) + } + + @MainActor + func testReadingWithoutTrackedMutationDoesNotNotify() { + let flag = ChangeFlag() + + withObservationTracking { + _ = Application.state(\.observationCounter).value + } onChange: { + flag.didChange = true + } + + // No mutation occurred, so no observation change should be delivered. + XCTAssertFalse(flag.didChange) + } +} +#endif diff --git a/Tests/AppStateTests/SyncStateTests.swift b/Tests/AppStateTests/SyncStateTests.swift index abdbee2..fa455f0 100644 --- a/Tests/AppStateTests/SyncStateTests.swift +++ b/Tests/AppStateTests/SyncStateTests.swift @@ -80,7 +80,7 @@ final class SyncStateTests: XCTestCase { } @MainActor - func testFailEncodingSyncState() async{ + func testFailEncodingSyncState() async { XCTAssertNotNil(Application.syncState(\.syncFailureValue).value) let syncValue = ExampleFailureSyncValue() @@ -89,7 +89,9 @@ final class SyncStateTests: XCTestCase { syncValue.count = Double.infinity - XCTAssertEqual(syncValue.count, Double.infinity) + // `Double.infinity` cannot be JSON-encoded. The setter encodes before committing, so the + // failed write must NOT poison the local fallback — the previous valid value is preserved. + XCTAssertEqual(syncValue.count, -1) } @MainActor diff --git a/documentation/README.de.md b/documentation/README.de.md index 71aa01e..62c8408 100644 --- a/documentation/README.de.md +++ b/documentation/README.de.md @@ -10,10 +10,10 @@ ## Anforderungen -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -29,6 +29,7 @@ - **State**: Zentralisierte Zustandsverwaltung, die es Ihnen ermöglicht, Änderungen in der gesamten App zu kapseln und zu übertragen. - **StoredState**: Persistenter Zustand mit `UserDefaults`, ideal zum Speichern kleiner Datenmengen zwischen App-Starts. - **FileState**: Persistenter Zustand, der mit `FileManager` gespeichert wird und nützlich ist, um größere Datenmengen sicher auf der Festplatte zu speichern. +- 🍎 **SwiftData (ModelState)**: Verwalten Sie SwiftData-`@Model`-Objekte über AppState, indem Sie einen gemeinsam genutzten `ModelContainer` injizieren und Modelle mit `ModelState` lesen/schreiben. - 🍎 **SyncState**: Synchronisieren Sie den Zustand über mehrere Geräte mit iCloud und stellen Sie die Konsistenz der Benutzereinstellungen sicher. - 🍎 **SecureState**: Speichern Sie sensible Daten sicher mit dem Schlüsselbund und schützen Sie Benutzerinformationen wie Token oder Passwörter. - **Abhängigkeitsmanagement**: Injizieren Sie Abhängigkeiten wie Netzwerkdienste oder Datenbankclients in Ihre gesamte App für eine bessere Modularität und Testbarkeit. @@ -83,8 +84,10 @@ Hier ist eine detaillierte Aufschlüsselung der Dokumentation von **AppState**: - [Zustand slicen](de/usage-slice.md): Greifen Sie auf bestimmte Teile des Zustands zu und ändern Sie sie. - [StoredState-Verwendungsanleitung](de/usage-storedstate.md): So persistieren Sie leichtgewichtige Daten mit `StoredState`. - [FileState-Verwendungsanleitung](de/usage-filestate.md): Erfahren Sie, wie Sie größere Datenmengen sicher auf der Festplatte persistieren. +- 🍎 [ModelState-Verwendungsanleitung](de/usage-modelstate.md): Verwalten Sie SwiftData-`@Model`-Objekte über einen gemeinsam genutzten `ModelContainer`. - [SecureState mit Schlüsselbund verwenden](de/usage-securestate.md): Speichern Sie sensible Daten sicher mit dem Schlüsselbund. - [iCloud-Synchronisierung mit SyncState](de/usage-syncstate.md): Halten Sie den Zustand über Geräte hinweg mit iCloud synchron. +- [Upgrade auf AppState 3.0](de/upgrade-to-v3.md): Breaking Changes und wie Sie von der 2.x-Release-Linie migrieren. - [FAQ](de/faq.md): Antworten auf häufig gestellte Fragen zur Verwendung von **AppState**. - [Konstanten-Verwendungsanleitung](de/usage-constant.md): Greifen Sie auf schreibgeschützte Werte aus Ihrem Zustand zu. - [ObservedDependency-Verwendungsanleitung](de/usage-observeddependency.md): Arbeiten Sie mit `ObservableObject`-Abhängigkeiten in Ihren Ansichten. diff --git a/documentation/README.es.md b/documentation/README.es.md index 1d1af0d..fe12873 100644 --- a/documentation/README.es.md +++ b/documentation/README.es.md @@ -6,14 +6,16 @@ [![License](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![Version](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** es una biblioteca de Swift 6 diseñada para simplificar la gestión del estado de la aplicación de una manera segura para hilos, segura para tipos y compatible con SwiftUI. Proporciona un conjunto de herramientas para centralizar y sincronizar el estado en toda su aplicación, así como para inyectar dependencias en diversas partes de su aplicación. +Lea esto en otros idiomas: [Francés](README.fr.md) | [Alemán](README.de.md) | [Hindi](README.hi.md) | [Portugués](README.pt.md) | [Ruso](README.ru.md) | [Chino Simplificado](README.zh-CN.md) | [Español](README.es.md) + +**AppState** es una biblioteca de Swift 6 para gestionar el estado de la aplicación de una manera segura para hilos, segura para tipos y compatible con SwiftUI. Centralice y sincronice el estado en toda su aplicación; inyecte dependencias en cualquier lugar. ## Requisitos -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -24,11 +26,12 @@ ## Características Clave -**AppState** incluye varias características potentes para ayudar a gestionar el estado y las dependencias: +**AppState** incluye: - **State**: Gestión centralizada del estado que le permite encapsular y transmitir cambios en toda la aplicación. - **StoredState**: Estado persistente utilizando `UserDefaults`, ideal para guardar pequeñas cantidades de datos entre lanzamientos de la aplicación. - **FileState**: Estado persistente almacenado usando `FileManager`, útil para almacenar grandes cantidades de datos de forma segura en el disco. +- 🍎 **SwiftData (ModelState)**: Gestione objetos `@Model` de SwiftData a través de AppState inyectando un `ModelContainer` compartido y leyendo/escribiendo modelos con `ModelState`. - 🍎 **SyncState**: Sincronice el estado en múltiples dispositivos usando iCloud, asegurando la coherencia en las preferencias y configuraciones del usuario. - 🍎 **SecureState**: Almacene datos sensibles de forma segura usando el Llavero, protegiendo información del usuario como tokens o contraseñas. - **Gestión de Dependencias**: Inyecte dependencias como servicios de red o clientes de bases de datos en toda su aplicación para una mejor modularidad y pruebas. @@ -38,14 +41,10 @@ ## Empezando -Para integrar **AppState** en su proyecto de Swift, necesitará usar el Swift Package Manager. Siga la [Guía de Instalación](es/installation.md) para obtener instrucciones detalladas sobre cómo configurar **AppState**. - -Después de la instalación, consulte la [Descripción General del Uso](es/usage-overview.md) para una introducción rápida sobre cómo gestionar el estado e inyectar dependencias en su proyecto. +Agregue **AppState** mediante Swift Package Manager — consulte la [Guía de Instalación](es/installation.md). Luego revise la [Descripción General del Uso](es/usage-overview.md) para una introducción rápida. ## Ejemplo Rápido -A continuación se muestra un ejemplo mínimo que muestra cómo definir una porción de estado y acceder a ella desde una vista de SwiftUI: - ```swift import AppState import SwiftUI @@ -61,15 +60,13 @@ struct ContentView: View { var body: some View { VStack { - Text("Conteo: \(counter)") - Button("Incrementar") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -Este fragmento demuestra cómo definir un valor de estado en una extensión de `Application` y usar el property wrapper `@AppState` para enlazarlo dentro de una vista. - ## Documentación Aquí hay un desglose detallado de la documentación de **AppState**: @@ -83,8 +80,10 @@ Aquí hay un desglose detallado de la documentación de **AppState**: - [Slicing de Estado](es/usage-slice.md): Acceda y modifique partes específicas del estado. - [Guía de Uso de StoredState](es/usage-storedstate.md): Cómo persistir datos ligeros usando `StoredState`. - [Guía de Uso de FileState](es/usage-filestate.md): Aprenda a persistir grandes cantidades de datos de forma segura en el disco. +- 🍎 [Guía de Uso de ModelState](es/usage-modelstate.md): Gestione objetos `@Model` de SwiftData a través de un `ModelContainer` compartido. - [Uso de SecureState con Llavero](es/usage-securestate.md): Almacene datos sensibles de forma segura usando el Llavero. - [Sincronización con iCloud usando SyncState](es/usage-syncstate.md): Mantenga el estado sincronizado en todos los dispositivos usando iCloud. +- [Actualización a AppState 3.0](es/upgrade-to-v3.md): Cambios importantes y cómo migrar desde la línea de versiones 2.x. - [Preguntas Frecuentes](es/faq.md): Respuestas a preguntas comunes al usar **AppState**. - [Guía de Uso de Constantes](es/usage-constant.md): Acceda a valores de solo lectura de su estado. - [Guía de Uso de ObservedDependency](es/usage-observeddependency.md): Trabaje con dependencias de `ObservableObject` en sus vistas. @@ -98,7 +97,7 @@ Aquí hay un desglose detallado de la documentación de **AppState**: ## Próximos Pasos -Con **AppState** instalado, puede comenzar a explorar sus características clave consultando la [Descripción General del Uso](es/usage-overview.md) y guías más detalladas. ¡Comience a gestionar el estado y las dependencias de manera efectiva en sus proyectos de Swift! Para técnicas de uso más avanzadas, como la creación Justo a Tiempo y la precarga de dependencias, consulte la [Guía de Uso Avanzado](es/advanced-usage.md). También puede revisar las guías de [Constantes](es/usage-constant.md) y [ObservedDependency](es/usage-observeddependency.md) para características adicionales. +Comience con la [Descripción General del Uso](es/usage-overview.md). Para la creación Justo a Tiempo y la precarga, consulte la [Guía de Uso Avanzado](es/advanced-usage.md). Las guías de [Constantes](es/usage-constant.md) y [ObservedDependency](es/usage-observeddependency.md) cubren características adicionales. --- Esta traducción fue generada automáticamente y puede contener errores. Si eres un hablante nativo, te agradecemos que contribuyas con correcciones a través de un Pull Request. diff --git a/documentation/README.fr.md b/documentation/README.fr.md index 243c1a8..de45120 100644 --- a/documentation/README.fr.md +++ b/documentation/README.fr.md @@ -6,14 +6,14 @@ [![License](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![Version](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** est une bibliothèque Swift 6 conçue pour simplifier la gestion de l'état de l'application de manière thread-safe, type-safe et compatible avec SwiftUI. Elle fournit un ensemble d'outils pour centraliser et synchroniser l'état à travers votre application, ainsi que pour injecter des dépendances dans diverses parties de votre application. +**AppState** est une bibliothèque Swift 6 pour gérer l'état de l'application de manière thread-safe, type-safe et compatible avec SwiftUI. Centralisez et synchronisez l'état à travers votre application ; injectez des dépendances n'importe où. ## Exigences -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -29,6 +29,7 @@ - **State**: Gestion centralisée de l'état qui vous permet d'encapsuler et de diffuser les changements à travers l'application. - **StoredState**: État persistant utilisant `UserDefaults`, idéal pour sauvegarder de petites quantités de données entre les lancements de l'application. - **FileState**: État persistant stocké à l'aide de `FileManager`, utile pour stocker de plus grandes quantités de données en toute sécurité sur le disque. +- 🍎 **SwiftData (ModelState)**: Gérez les objets SwiftData `@Model` à travers AppState en injectant un `ModelContainer` partagé et en lisant/écrivant les modèles avec `ModelState`. - 🍎 **SyncState**: Synchronisez l'état sur plusieurs appareils à l'aide d'iCloud, garantissant la cohérence des préférences et des paramètres de l'utilisateur. - 🍎 **SecureState**: Stockez les données sensibles en toute sécurité à l'aide du Trousseau, protégeant les informations de l'utilisateur telles que les jetons ou les mots de passe. - **Gestion des Dépendances**: Injectez des dépendances comme des services réseau ou des clients de base de données à travers votre application pour une meilleure modularité et des tests facilités. @@ -38,14 +39,10 @@ ## Pour Commencer -Pour intégrer **AppState** dans votre projet Swift, vous devrez utiliser le Swift Package Manager. Suivez le [Guide d'Installation](fr/installation.md) pour des instructions détaillées sur la configuration de **AppState**. - -Après l'installation, consultez l'[Aperçu de l'Utilisation](fr/usage-overview.md) pour une introduction rapide sur la manière de gérer l'état et d'injecter des dépendances dans votre projet. +Ajoutez **AppState** via le Swift Package Manager — consultez le [Guide d'Installation](fr/installation.md). Consultez ensuite l'[Aperçu de l'Utilisation](fr/usage-overview.md) pour une introduction rapide. ## Exemple Rapide -Voici un exemple minimal montrant comment définir une tranche d'état et y accéder depuis une vue SwiftUI : - ```swift import AppState import SwiftUI @@ -61,15 +58,13 @@ struct ContentView: View { var body: some View { VStack { - Text("Compteur: \(counter)") - Button("Incrémenter") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -Cet extrait montre comment définir une valeur d'état dans une extension `Application` et utiliser le property wrapper `@AppState` pour la lier à l'intérieur d'une vue. - ## Documentation Voici une ventilation détaillée de la documentation de **AppState** : @@ -83,8 +78,10 @@ Voici une ventilation détaillée de la documentation de **AppState** : - [Découpage de l'État (Slicing)](fr/usage-slice.md) : Accédez et modifiez des parties spécifiques de l'état. - [Guide d'Utilisation de StoredState](fr/usage-storedstate.md) : Comment persister des données légères à l'aide de `StoredState`. - [Guide d'Utilisation de FileState](fr/usage-filestate.md) : Apprenez à persister de plus grandes quantités de données en toute sécurité sur le disque. +- 🍎 [Guide d'Utilisation de ModelState](fr/usage-modelstate.md) : Gérez les objets SwiftData `@Model` via un `ModelContainer` partagé. - [Utilisation de SecureState avec le Trousseau](fr/usage-securestate.md) : Stockez les données sensibles en toute sécurité à l'aide du Trousseau. - [Synchronisation iCloud avec SyncState](fr/usage-syncstate.md) : Maintenez l'état synchronisé sur tous les appareils à l'aide d'iCloud. +- [Mise à Niveau vers AppState 3.0](fr/upgrade-to-v3.md) : Changements incompatibles et comment migrer depuis la ligne de version 2.x. - [FAQ](fr/faq.md) : Réponses aux questions courantes lors de l'utilisation de **AppState**. - [Guide d'Utilisation des Constantes](fr/usage-constant.md) : Accédez à des valeurs en lecture seule de votre état. - [Guide d'Utilisation de ObservedDependency](fr/usage-observeddependency.md) : Travaillez avec des dépendances `ObservableObject` dans vos vues. diff --git a/documentation/README.hi.md b/documentation/README.hi.md index 15a028c..4f106d1 100644 --- a/documentation/README.hi.md +++ b/documentation/README.hi.md @@ -6,14 +6,16 @@ [![लाइसेंस](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![संस्करण](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** एक स्विफ्ट 6 लाइब्रेरी है जिसे एप्लिकेशन स्थिति के प्रबंधन को थ्रेड-सुरक्षित, प्रकार-सुरक्षित और SwiftUI-अनुकूल तरीके से सरल बनाने के लिए डिज़ाइन किया गया है। यह आपके एप्लिकेशन में स्थिति को केंद्रीकृत और सिंक्रनाइज़ करने के लिए उपकरणों का एक सेट प्रदान करता है, साथ ही आपके ऐप के विभिन्न हिस्सों में निर्भरताएँ इंजेक्ट करता है। +इसे अन्य भाषाओं में पढ़ें: [French](README.fr.md) | [German](README.de.md) | [Hindi](README.hi.md) | [Portuguese](README.pt.md) | [Russian](README.ru.md) | [Simplified Chinese](README.zh-CN.md) | [Spanish](README.es.md) + +**AppState** एप्लिकेशन स्थिति को थ्रेड-सुरक्षित, प्रकार-सुरक्षित और SwiftUI-अनुकूल तरीके से प्रबंधित करने के लिए एक Swift 6 लाइब्रेरी है। अपने ऐप में स्थिति को केंद्रीकृत और सिंक्रनाइज़ करें; कहीं भी निर्भरताएँ इंजेक्ट करें। ## आवश्यकताएँ -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **स्विफ्ट**: 6.0+ - **Xcode**: 16.0+ @@ -24,11 +26,12 @@ ## मुख्य विशेषताएँ -**AppState** में स्थिति और निर्भरताओं के प्रबंधन में मदद करने के लिए कई शक्तिशाली सुविधाएँ शामिल हैं: +**AppState** में शामिल हैं: - **State**: केंद्रीकृत स्थिति प्रबंधन जो आपको पूरे ऐप में परिवर्तनों को एनकैप्सुलेट और प्रसारित करने की अनुमति देता है। - **StoredState**: `UserDefaults` का उपयोग करके स्थायी स्थिति, ऐप लॉन्च के बीच थोड़ी मात्रा में डेटा सहेजने के लिए आदर्श। - **FileState**: `FileManager` का उपयोग करके संग्रहीत स्थायी स्थिति, डिस्क पर बड़ी मात्रा में डेटा को सुरक्षित रूप से संग्रहीत करने के लिए उपयोगी। +- 🍎 **SwiftData (ModelState)**: एक साझा `ModelContainer` को इंजेक्ट करके और `ModelState` के साथ मॉडलों को पढ़कर/लिखकर AppState के माध्यम से SwiftData `@Model` ऑब्जेक्ट्स का प्रबंधन करें। - 🍎 **SyncState**: iCloud का उपयोग करके कई उपकरणों में स्थिति को सिंक्रनाइज़ करें, उपयोगकर्ता की प्राथमिकताओं और सेटिंग्स में स्थिरता सुनिश्चित करता है। - 🍎 **SecureState**: कीचेन का उपयोग करके संवेदनशील डेटा को सुरक्षित रूप से संग्रहीत करें, उपयोगकर्ता की जानकारी जैसे टोकन या पासवर्ड की सुरक्षा करता है। - **निर्भरता प्रबंधन**: बेहतर मॉड्यूलरिटी और परीक्षण के लिए अपने ऐप में नेटवर्क सेवाओं या डेटाबेस क्लाइंट जैसी निर्भरताएँ इंजेक्ट करें। @@ -38,14 +41,10 @@ ## शुरुआत कैसे करें -**AppState** को अपने स्विफ्ट प्रोजेक्ट में एकीकृत करने के लिए, आपको स्विफ्ट पैकेज मैनेजर का उपयोग करना होगा। **AppState** स्थापित करने के बारे में विस्तृत निर्देशों के लिए [स्थापना मार्गदर्शिका](hi/installation.md) का पालन करें। - -स्थापना के बाद, अपने प्रोजेक्ट में स्थिति को प्रबंधित करने और निर्भरताएँ इंजेक्ट करने के तरीके के बारे में त्वरित परिचय के लिए [उपयोग अवलोकन](hi/usage-overview.md) देखें। +Swift Package Manager के माध्यम से **AppState** जोड़ें — देखें [स्थापना मार्गदर्शिका](en/installation.md)। फिर त्वरित परिचय के लिए [उपयोग अवलोकन](en/usage-overview.md) देखें। ## त्वरित उदाहरण -नीचे एक न्यूनतम उदाहरण दिया गया है जो दिखाता है कि स्थिति का एक टुकड़ा कैसे परिभाषित करें और इसे SwiftUI दृश्य से कैसे एक्सेस करें: - ```swift import AppState import SwiftUI @@ -61,44 +60,41 @@ struct ContentView: View { var body: some View { VStack { - Text("गणना: \(counter)") - Button("बढ़ाएँ") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -यह स्निपेट एक `Application` एक्सटेंशन में एक स्थिति मान को परिभाषित करने और इसे एक दृश्य के अंदर बाँधने के लिए `@AppState` प्रॉपर्टी रैपर का उपयोग करने का प्रदर्शन करता है। - ## दस्तावेज़ीकरण यहाँ **AppState** के दस्तावेज़ीकरण का विस्तृत विवरण दिया गया है: -- [स्थापना मार्गदर्शिका](hi/installation.md): स्विफ्ट पैकेज मैनेजर का उपयोग करके अपने प्रोजेक्ट में **AppState** कैसे जोड़ें। -- [उपयोग अवलोकन](hi/usage-overview.md): उदाहरण कार्यान्वयन के साथ मुख्य विशेषताओं का अवलोकन। +- [स्थापना मार्गदर्शिका](en/installation.md): Swift Package Manager का उपयोग करके अपने प्रोजेक्ट में **AppState** कैसे जोड़ें। +- [उपयोग अवलोकन](en/usage-overview.md): उदाहरण कार्यान्वयन के साथ मुख्य विशेषताओं का अवलोकन। ### विस्तृत उपयोग मार्गदर्शिकाएँ: -- [स्थिति और निर्भरता प्रबंधन](hi/usage-state-dependency.md): स्थिति को केंद्रीकृत करें और अपने पूरे ऐप में निर्भरताएँ इंजेक्ट करें। -- [स्लाइसिंग स्थिति](hi/usage-slice.md): स्थिति के विशिष्ट भागों तक पहुँचें और संशोधित करें। -- [StoredState उपयोग मार्गदर्शिका](hi/usage-storedstate.md): `StoredState` का उपयोग करके हल्के डेटा को कैसे बनाए रखें। -- [FileState उपयोग मार्गदर्शिका](hi/usage-filestate.md): डिस्क पर बड़ी मात्रा में डेटा को सुरक्षित रूप से कैसे बनाए रखें, जानें। -- [कीचेन SecureState उपयोग](hi/usage-securestate.md): कीचेन का उपयोग करके संवेदनशील डेटा को सुरक्षित रूप से संग्रहीत करें। -- [SyncState के साथ iCloud सिंकिंग](hi/usage-syncstate.md): iCloud का उपयोग करके उपकरणों में स्थिति को सिंक्रनाइज़ रखें। -- [अक्सर पूछे जाने वाले प्रश्न](hi/faq.md): **AppState** का उपयोग करते समय सामान्य प्रश्नों के उत्तर। -- [स्थिरांक उपयोग मार्गदर्शिका](hi/usage-constant.md): अपनी स्थिति से केवल-पढ़ने के लिए मानों तक पहुँचें। -- [ObservedDependency उपयोग मार्गदर्शिका](hi/usage-observeddependency.md): अपने विचारों में `ObservableObject` निर्भरताओं के साथ काम करें। -- [उन्नत उपयोग](hi/advanced-usage.md): जस्ट-इन-टाइम निर्माण और निर्भरताओं को प्रीलोड करने जैसी तकनीकें। -- [सर्वोत्तम प्रथाएँ](hi/best-practices.md): अपने ऐप की स्थिति को प्रभावी ढंग से संरचित करने के लिए युक्तियाँ। -- [माइग्रेशन विचार](hi/migration-considerations.md): स्थायी मॉडल अपडेट करते समय मार्गदर्शन। +- [स्थिति और निर्भरता प्रबंधन](en/usage-state-dependency.md): स्थिति को केंद्रीकृत करें और अपने पूरे ऐप में निर्भरताएँ इंजेक्ट करें। +- [स्लाइसिंग स्थिति](en/usage-slice.md): स्थिति के विशिष्ट भागों तक पहुँचें और संशोधित करें। +- [StoredState उपयोग मार्गदर्शिका](en/usage-storedstate.md): `StoredState` का उपयोग करके हल्के डेटा को कैसे बनाए रखें। +- [FileState उपयोग मार्गदर्शिका](en/usage-filestate.md): डिस्क पर बड़ी मात्रा में डेटा को सुरक्षित रूप से कैसे बनाए रखें, जानें। +- 🍎 [ModelState उपयोग मार्गदर्शिका](en/usage-modelstate.md): एक साझा `ModelContainer` के माध्यम से SwiftData `@Model` ऑब्जेक्ट्स का प्रबंधन करें। +- [कीचेन SecureState उपयोग](en/usage-securestate.md): कीचेन का उपयोग करके संवेदनशील डेटा को सुरक्षित रूप से संग्रहीत करें। +- [SyncState के साथ iCloud सिंकिंग](en/usage-syncstate.md): iCloud का उपयोग करके उपकरणों में स्थिति को सिंक्रनाइज़ रखें। +- [AppState 3.0 में अपग्रेड करना](en/upgrade-to-v3.md): ब्रेकिंग परिवर्तन और 2.x रिलीज़ लाइन से माइग्रेट कैसे करें। +- [अक्सर पूछे जाने वाले प्रश्न](en/faq.md): **AppState** का उपयोग करते समय सामान्य प्रश्नों के उत्तर। +- [स्थिरांक उपयोग मार्गदर्शिका](en/usage-constant.md): अपनी स्थिति से केवल-पढ़ने के लिए मानों तक पहुँचें। +- [ObservedDependency उपयोग मार्गदर्शिका](en/usage-observeddependency.md): अपने विचारों में `ObservableObject` निर्भरताओं के साथ काम करें। +- [उन्नत उपयोग](en/advanced-usage.md): जस्ट-इन-टाइम निर्माण और निर्भरताओं को प्रीलोड करने जैसी तकनीकें। +- [सर्वोत्तम प्रथाएँ](en/best-practices.md): अपने ऐप की स्थिति को प्रभावी ढंग से संरचित करने के लिए युक्तियाँ। +- [माइग्रेशन विचार](en/migration-considerations.md): स्थायी मॉडल अपडेट करते समय मार्गदर्शन। ## योगदान -हम योगदान का स्वागत करते हैं! कृपया शामिल होने के तरीके के लिए हमारी [योगदान मार्गदर्शिका](hi/contributing.md) देखें। +हम योगदान का स्वागत करते हैं! कृपया शामिल होने के तरीके के लिए हमारी [योगदान मार्गदर्शिका](en/contributing.md) देखें। ## अगले चरण -**AppState** स्थापित होने के साथ, आप [उपयोग अवलोकन](hi/usage-overview.md) और अधिक विस्तृत मार्गदर्शिकाओं को देखकर इसकी मुख्य विशेषताओं की खोज शुरू कर सकते हैं। अपने स्विफ्ट प्रोजेक्ट्स में स्थिति और निर्भरताओं का प्रभावी ढंग से प्रबंधन शुरू करें! अधिक उन्नत उपयोग तकनीकों के लिए, जैसे जस्ट-इन-टाइम निर्माण और निर्भरताओं को प्रीलोड करना, [उन्नत उपयोग मार्गदर्शिका](hi/advanced-usage.md) देखें। आप अतिरिक्त सुविधाओं के लिए [स्थिरांक](hi/usage-constant.md) और [ObservedDependency](hi/usage-observeddependency.md) मार्गदर्शिकाओं की भी समीक्षा कर सकते हैं। - ---- -यह अनुवाद स्वचालित रूप से उत्पन्न किया गया था और इसमें त्रुटियाँ हो सकती हैं। यदि आप एक देशी वक्ता हैं, तो हम एक पुल अनुरोध के माध्यम से सुधारों में आपके योगदान की सराहना करेंगे। +[उपयोग अवलोकन](en/usage-overview.md) से शुरुआत करें। जस्ट-इन-टाइम निर्माण और प्रीलोडिंग के लिए, [उन्नत उपयोग मार्गदर्शिका](en/advanced-usage.md) देखें। [स्थिरांक](en/usage-constant.md) और [ObservedDependency](en/usage-observeddependency.md) मार्गदर्शिकाएँ अतिरिक्त सुविधाओं को कवर करती हैं। diff --git a/documentation/README.pt.md b/documentation/README.pt.md index 048395f..e430923 100644 --- a/documentation/README.pt.md +++ b/documentation/README.pt.md @@ -6,14 +6,16 @@ [![License](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![Version](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** é uma biblioteca Swift 6 projetada para simplificar o gerenciamento do estado da aplicação de uma forma segura para threads, segura para tipos e amigável ao SwiftUI. Ele fornece um conjunto de ferramentas para centralizar e sincronizar o estado em toda a sua aplicação, bem como para injetar dependências em várias partes do seu aplicativo. +Leia isto em outros idiomas: [Inglês](../README.md) | [Francês](README.fr.md) | [Alemão](README.de.md) | [Hindi](README.hi.md) | [Russo](README.ru.md) | [Chinês Simplificado](README.zh-CN.md) | [Espanhol](README.es.md) + +**AppState** é uma biblioteca Swift 6 para gerenciar o estado da aplicação de uma forma segura para threads, segura para tipos e amigável ao SwiftUI. Centralize e sincronize o estado em toda a sua aplicação; injete dependências em qualquer lugar. ## Requisitos -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -24,11 +26,12 @@ ## Principais Recursos -**AppState** inclui vários recursos poderosos para ajudar a gerenciar o estado e as dependências: +**AppState** inclui: - **State**: Gerenciamento de estado centralizado que permite encapsular e transmitir alterações em todo o aplicativo. - **StoredState**: Estado persistente usando `UserDefaults`, ideal para salvar pequenas quantidades de dados entre as inicializações do aplicativo. - **FileState**: Estado persistente armazenado usando `FileManager`, útil para armazenar grandes quantidades de dados com segurança no disco. +- 🍎 **SwiftData (ModelState)**: Gerencie objetos `@Model` do SwiftData através do AppState, injetando um `ModelContainer` compartilhado e lendo/gravando modelos com `ModelState`. - 🍎 **SyncState**: Sincronize o estado em vários dispositivos usando o iCloud, garantindo a consistência nas preferências e configurações do usuário. - 🍎 **SecureState**: Armazene dados confidenciais com segurança usando o Keychain, protegendo informações do usuário, como tokens ou senhas. - **Gerenciamento de Dependências**: Injete dependências como serviços de rede ou clientes de banco de dados em todo o seu aplicativo para melhor modularidade e testes. @@ -38,14 +41,10 @@ ## Começando -Para integrar o **AppState** ao seu projeto Swift, você precisará usar o Swift Package Manager. Siga o [Guia de Instalação](pt/installation.md) para obter instruções detalhadas sobre como configurar o **AppState**. - -Após a instalação, consulte a [Visão Geral do Uso](pt/usage-overview.md) para uma introdução rápida sobre como gerenciar o estado e injetar dependências em seu projeto. +Adicione o **AppState** via Swift Package Manager — consulte o [Guia de Instalação](pt/installation.md). Em seguida, confira a [Visão Geral do Uso](pt/usage-overview.md) para uma introdução rápida. ## Exemplo Rápido -Abaixo está um exemplo mínimo mostrando como definir uma fatia de estado e acessá-la a partir de uma visualização SwiftUI: - ```swift import AppState import SwiftUI @@ -61,15 +60,13 @@ struct ContentView: View { var body: some View { VStack { - Text("Contagem: \(counter)") - Button("Incrementar") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -Este trecho demonstra a definição de um valor de estado em uma extensão `Application` e o uso do property wrapper `@AppState` para vinculá-lo dentro de uma visualização. - ## Documentação Aqui está um detalhamento da documentação do **AppState**: @@ -83,8 +80,10 @@ Aqui está um detalhamento da documentação do **AppState**: - [Fatiando o Estado](pt/usage-slice.md): Acesse e modifique partes específicas do estado. - [Guia de Uso do StoredState](pt/usage-storedstate.md): Como persistir dados leves usando `StoredState`. - [Guia de Uso do FileState](pt/usage-filestate.md): Aprenda a persistir grandes quantidades de dados com segurança no disco. +- 🍎 [Guia de Uso do ModelState](pt/usage-modelstate.md): Gerencie objetos `@Model` do SwiftData através de um `ModelContainer` compartilhado. - [Uso do SecureState com Keychain](pt/usage-securestate.md): Armazene dados confidenciais com segurança usando o Keychain. - [Sincronização com iCloud com SyncState](pt/usage-syncstate.md): Mantenha o estado sincronizado em todos os dispositivos usando o iCloud. +- [Atualizando para o AppState 3.0](pt/upgrade-to-v3.md): Alterações que quebram a compatibilidade e como migrar da linha de lançamento 2.x. - [FAQ](pt/faq.md): Respostas a perguntas comuns ao usar o **AppState**. - [Guia de Uso de Constantes](pt/usage-constant.md): Acesse valores somente leitura do seu estado. - [Guia de Uso de ObservedDependency](pt/usage-observeddependency.md): Trabalhe com dependências `ObservableObject` em suas visualizações. @@ -98,7 +97,7 @@ Aceitamos contribuições! Por favor, confira nosso [Guia de Contribuição](pt/ ## Próximos Passos -Com o **AppState** instalado, você pode começar a explorar seus principais recursos, consultando a [Visão Geral do Uso](pt/usage-overview.md) e guias mais detalhados. Comece a gerenciar o estado e as dependências de forma eficaz em seus projetos Swift! Para técnicas de uso mais avançadas, como criação Just-In-Time e pré-carregamento de dependências, consulte o [Guia de Uso Avançado](pt/advanced-usage.md). Você também pode revisar os guias [Constant](pt/usage-constant.md) e [ObservedDependency](pt/usage-observeddependency.md) para recursos adicionais. +Comece com a [Visão Geral do Uso](pt/usage-overview.md). Para criação Just-In-Time e pré-carregamento, consulte o [Guia de Uso Avançado](pt/advanced-usage.md). Os guias [Constant](pt/usage-constant.md) e [ObservedDependency](pt/usage-observeddependency.md) cobrem recursos adicionais. --- Esta tradução foi gerada automaticamente e pode conter erros. Se você é um falante nativo, agradecemos suas contribuições com correções por meio de um Pull Request. diff --git a/documentation/README.ru.md b/documentation/README.ru.md index ed1b362..3d162c6 100644 --- a/documentation/README.ru.md +++ b/documentation/README.ru.md @@ -6,14 +6,16 @@ [![License](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![Version](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** - это библиотека Swift 6, разработанная для упрощения управления состоянием приложения в поточно-безопасном, типобезопасном и дружественном к SwiftUI виде. Она предоставляет набор инструментов для централизации и синхронизации состояния в вашем приложении, а также для внедрения зависимостей в различные части вашего приложения. +Читайте это на других языках: [French](README.fr.md) | [German](README.de.md) | [Hindi](README.hi.md) | [Portuguese](README.pt.md) | [Russian](README.ru.md) | [Simplified Chinese](README.zh-CN.md) | [Spanish](README.es.md) + +**AppState** — это библиотека Swift 6 для управления состоянием приложения в поточно-безопасном, типобезопасном и дружественном к SwiftUI виде. Централизуйте и синхронизируйте состояние в вашем приложении; внедряйте зависимости где угодно. ## Требования -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -29,6 +31,7 @@ - **State**: Централизованное управление состоянием, которое позволяет инкапсулировать и транслировать изменения по всему приложению. - **StoredState**: Постоянное состояние с использованием `UserDefaults`, идеально подходящее для сохранения небольших объемов данных между запусками приложения. - **FileState**: Постоянное состояние, хранящееся с использованием `FileManager`, полезное для безопасного хранения больших объемов данных на диске. +- 🍎 **SwiftData (ModelState)**: Управляйте объектами SwiftData `@Model` через AppState, внедряя общий `ModelContainer` и читая/записывая модели с помощью `ModelState`. - 🍎 **SyncState**: Синхронизация состояния между несколькими устройствами с использованием iCloud, обеспечивающая согласованность пользовательских предпочтений и настроек. - 🍎 **SecureState**: Безопасное хранение конфиденциальных данных с использованием Keychain, защита информации пользователя, такой как токены или пароли. - **Управление зависимостями**: Внедряйте зависимости, такие как сетевые службы или клиенты баз данных, по всему вашему приложению для лучшей модульности и тестирования. @@ -38,14 +41,10 @@ ## Начало работы -Чтобы интегрировать **AppState** в ваш проект Swift, вам понадобится использовать Swift Package Manager. Следуйте [Руководству по установке](ru/installation.md) для получения подробных инструкций по настройке **AppState**. - -После установки обратитесь к [Обзору использования](ru/usage-overview.md) для быстрого введения в управление состоянием и внедрение зависимостей в ваш проект. +Добавьте **AppState** через Swift Package Manager — см. [Руководство по установке](ru/installation.md). Затем ознакомьтесь с [Обзором использования](ru/usage-overview.md) для быстрого введения. ## Краткий пример -Ниже приведен минимальный пример, показывающий, как определить фрагмент состояния и получить к нему доступ из представления SwiftUI: - ```swift import AppState import SwiftUI @@ -61,15 +60,13 @@ struct ContentView: View { var body: some View { VStack { - Text("Счет: \(counter)") - Button("Увеличить") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -Этот фрагмент демонстрирует определение значения состояния в расширении `Application` и использование обертки свойства `@AppState` для его привязки внутри представления. - ## Документация Вот подробная разбивка документации **AppState**: @@ -83,8 +80,10 @@ struct ContentView: View { - [Нарезка состояния](ru/usage-slice.md): Доступ и изменение определенных частей состояния. - [Руководство по использованию StoredState](ru/usage-storedstate.md): Как сохранять легковесные данные с помощью `StoredState`. - [Руководство по использованию FileState](ru/usage-filestate.md): Узнайте, как безопасно хранить большие объемы данных на диске. +- 🍎 [Руководство по использованию ModelState](ru/usage-modelstate.md): Управляйте объектами SwiftData `@Model` через общий `ModelContainer`. - [Использование SecureState с Keychain](ru/usage-securestate.md): Безопасное хранение конфиденциальных данных с использованием Keychain. - [Синхронизация с iCloud с помощью SyncState](ru/usage-syncstate.md): Поддерживайте синхронизацию состояния на всех устройствах с помощью iCloud. +- [Обновление до AppState 3.0](ru/upgrade-to-v3.md): Критические изменения и способы миграции с линейки выпусков 2.x. - [Часто задаваемые вопросы](ru/faq.md): Ответы на часто задаваемые вопросы при использовании **AppState**. - [Руководство по использованию констант](ru/usage-constant.md): Доступ к значениям только для чтения из вашего состояния. - [Руководство по использованию ObservedDependency](ru/usage-observeddependency.md): Работа с зависимостями `ObservableObject` в ваших представлениях. @@ -98,7 +97,7 @@ struct ContentView: View { ## Следующие шаги -После установки **AppState** вы можете начать изучать его ключевые функции, ознакомившись с [Обзором использования](ru/usage-overview.md) и более подробными руководствами. Начните эффективно управлять состоянием и зависимостями в ваших проектах Swift! Для более продвинутых техник использования, таких как создание «Точно в срок» и предварительная загрузка зависимостей, см. [Руководство по расширенному использованию](ru/advanced-usage.md). Вы также можете просмотреть руководства [Constant](ru/usage-constant.md) и [ObservedDependency](ru/usage-observeddependency.md) для получения дополнительных функций. +Начните с [Обзора использования](ru/usage-overview.md). Для создания «точно в срок» и предварительной загрузки зависимостей см. [Руководство по расширенному использованию](ru/advanced-usage.md). Руководства [Constant](ru/usage-constant.md) и [ObservedDependency](ru/usage-observeddependency.md) описывают дополнительные возможности. --- Это было сгенерировано с использованием [Jules](https://jules.google), могут возникнуть ошибки. Пожалуйста, сделайте Pull Request с любыми исправлениями, которые должны произойти, если вы носитель языка. diff --git a/documentation/README.zh-CN.md b/documentation/README.zh-CN.md index c02c869..813c50e 100644 --- a/documentation/README.zh-CN.md +++ b/documentation/README.zh-CN.md @@ -6,14 +6,16 @@ [![许可证](https://img.shields.io/github/license/0xLeif/AppState)](https://github.com/0xLeif/AppState/blob/main/LICENSE) [![版本](https://img.shields.io/github/v/release/0xLeif/AppState)](https://github.com/0xLeif/AppState/releases) -**AppState** 是一个 Swift 6 库,旨在以线程安全、类型安全和 SwiftUI 友好的方式简化应用程序状态的管理。它提供了一套工具来集中和同步整个应用程序的状态,并将依赖项注入到应用程序的各个部分。 +阅读其他语言版本:[French](README.fr.md) | [German](README.de.md) | [Hindi](README.hi.md) | [Portuguese](README.pt.md) | [Russian](README.ru.md) | [Simplified Chinese](README.zh-CN.md) | [Spanish](README.es.md) + +**AppState** 是一个 Swift 6 库,以线程安全、类型安全和 SwiftUI 友好的方式管理应用程序状态。集中并同步整个应用的状态;在任何地方注入依赖。 ## 要求 -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ +- **iOS**: 17.0+ +- **watchOS**: 10.0+ +- **macOS**: 14.0+ +- **tvOS**: 17.0+ - **visionOS**: 1.0+ - **Swift**: 6.0+ - **Xcode**: 16.0+ @@ -24,28 +26,25 @@ ## 主要功能 -**AppState** 包括几个强大的功能来帮助管理状态和依赖项: +**AppState** 包括: -- **State**:集中式状态管理,允许您封装和广播整个应用程序的更改。 -- **StoredState**:使用 `UserDefaults` 的持久状态,非常适合在应用程序启动之间保存少量数据。 +- **State**:集中式状态管理,允许你封装并广播整个应用的更改。 +- **StoredState**:使用 `UserDefaults` 的持久状态,非常适合在应用启动之间保存少量数据。 - **FileState**:使用 `FileManager` 存储的持久状态,用于在磁盘上安全地存储大量数据。 +- 🍎 **SwiftData (ModelState)**:通过注入共享的 `ModelContainer` 并使用 `ModelState` 读取/写入模型,借助 AppState 管理 SwiftData 的 `@Model` 对象。 - 🍎 **SyncState**:使用 iCloud 在多个设备之间同步状态,确保用户偏好和设置的一致性。 - 🍎 **SecureState**:使用钥匙串安全地存储敏感数据,保护用户信息(如令牌或密码)。 -- **依赖管理**:在整个应用程序中注入网络服务或数据库客户端等依赖项,以实现更好的模块化和测试。 -- **Slicing**:访问状态或依赖项的特定部分以进行精细控制,而无需管理整个应用程序状态。 -- **Constants**:当您需要不可变值时,可以访问状态的只读切片。 -- **Observed Dependencies**:观察 `ObservableObject` 依赖项,以便在它们更改时更新您的视图。 +- **依赖管理**:在整个应用中注入网络服务或数据库客户端等依赖,以实现更好的模块化和测试。 +- **Slicing**:访问状态或依赖的特定部分以进行精细控制,而无需管理整个应用状态。 +- **Constants**:当你需要不可变值时,可以访问状态的只读切片。 +- **Observed Dependencies**:观察 `ObservableObject` 依赖,以便在它们更改时更新你的视图。 ## 入门 -要将 **AppState** 集成到您的 Swift 项目中,您需要使用 Swift 包管理器。有关设置 **AppState** 的详细说明,请遵循[安装指南](zh-CN/installation.md)。 - -安装后,请参阅[用法概述](zh-CN/usage-overview.md),快速了解如何管理状态和将依赖项注入到您的项目中。 +通过 Swift 包管理器添加 **AppState** —— 参见[安装指南](en/installation.md)。然后查看[用法概述](en/usage-overview.md),快速了解入门方法。 ## 快速示例 -以下是一个最小示例,展示了如何定义一个状态片段并从 SwiftUI 视图中访问它: - ```swift import AppState import SwiftUI @@ -61,44 +60,44 @@ struct ContentView: View { var body: some View { VStack { - Text("计数: \(counter)") - Button("递增") { counter += 1 } + Text("Count: \(counter)") + Button("Increment") { counter += 1 } } } } ``` -此代码片段演示了如何在 `Application` 扩展中定义状态值,并使用 `@AppState` 属性包装器将其绑定到视图中。 - ## 文档 以下是 **AppState** 文档的详细分类: -- [安装指南](zh-CN/installation.md):如何使用 Swift 包管理器将 **AppState** 添加到您的项目中。 -- [用法概述](zh-CN/usage-overview.md):主要功能的概述及示例实现。 +- [安装指南](en/installation.md):如何使用 Swift 包管理器将 **AppState** 添加到你的项目中。 +- [用法概述](en/usage-overview.md):主要功能的概述及示例实现。 ### 详细用法指南: -- [状态和依赖管理](zh-CN/usage-state-dependency.md):集中管理状态并在整个应用程序中注入依赖项。 -- [状态切片](zh-CN/usage-slice.md):访问和修改状态的特定部分。 -- [StoredState 用法指南](zh-CN/usage-storedstate.md):如何使用 `StoredState` 持久化轻量级数据。 -- [FileState 用法指南](zh-CN/usage-filestate.md):了解如何安全地在磁盘上持久化大量数据。 -- [钥匙串 SecureState 用法](zh-CN/usage-securestate.md):使用钥匙串安全地存储敏感数据。 -- [使用 SyncState 进行 iCloud 同步](zh-CN/usage-syncstate.md):使用 iCloud 在设备之间保持状态同步。 -- [常见问题解答](zh-CN/faq.md):使用 **AppState** 时常见问题的解答。 -- [常量用法指南](zh-CN/usage-constant.md):从您的状态中访问只读值。 -- [ObservedDependency 用法指南](zh-CN/usage-observeddependency.md):在您的视图中使用 `ObservableObject` 依赖项。 -- [高级用法](zh-CN/advanced-usage.md):诸如即时创建和预加载依赖项等技术。 -- [最佳实践](zh-CN/best-practices.md):有效构建应用程序状态的技巧。 -- [迁移注意事项](zh-CN/migration-considerations.md):更新持久化模型时的指导。 +- [状态和依赖管理](en/usage-state-dependency.md):集中管理状态并在整个应用中注入依赖。 +- [状态切片](en/usage-slice.md):访问和修改状态的特定部分。 +- [StoredState 用法指南](en/usage-storedstate.md):如何使用 `StoredState` 持久化轻量级数据。 +- [FileState 用法指南](en/usage-filestate.md):了解如何安全地在磁盘上持久化大量数据。 +- 🍎 [ModelState 用法指南](en/usage-modelstate.md):通过共享的 `ModelContainer` 管理 SwiftData 的 `@Model` 对象。 +- [钥匙串 SecureState 用法](en/usage-securestate.md):使用钥匙串安全地存储敏感数据。 +- [使用 SyncState 进行 iCloud 同步](en/usage-syncstate.md):使用 iCloud 在设备之间保持状态同步。 +- [升级到 AppState 3.0](en/upgrade-to-v3.md):重大变更以及如何从 2.x 发布线迁移。 +- [常见问题解答](en/faq.md):使用 **AppState** 时常见问题的解答。 +- [常量用法指南](en/usage-constant.md):从你的状态中访问只读值。 +- [ObservedDependency 用法指南](en/usage-observeddependency.md):在你的视图中使用 `ObservableObject` 依赖。 +- [高级用法](en/advanced-usage.md):诸如即时创建和预加载依赖等技术。 +- [最佳实践](en/best-practices.md):有效构建应用状态的技巧。 +- [迁移注意事项](en/migration-considerations.md):更新持久化模型时的指导。 ## 贡献 -我们欢迎贡献!请查看我们的[贡献指南](zh-CN/contributing.md)以了解如何参与。 +我们欢迎贡献!请查看我们的[贡献指南](en/contributing.md)以了解如何参与。 ## 后续步骤 -安装 **AppState** 后,您可以通过查看[用法概述](zh-CN/usage-overview.md)和更详细的指南来开始探索其主要功能。开始在您的 Swift 项目中有效地管理状态和依赖项!有关更高级的用法技术,如即时创建和预加载依赖项,请参阅[高级用法指南](zh-CN/advanced-usage.md)。您还可以查看[常量](zh-CN/usage-constant.md)和[ObservedDependency](zh-CN/usage-observeddependency.md)指南以了解其他功能。 +从[用法概述](en/usage-overview.md)开始。有关即时创建和预加载,请参阅[高级用法指南](en/advanced-usage.md)。[常量](en/usage-constant.md)和 [ObservedDependency](en/usage-observeddependency.md) 指南涵盖了其他功能。 --- 这是使用 [Jules](https://jules.google) 生成的,可能会出现错误。如果您是母语人士,请提出包含任何应有修复的拉取请求。 diff --git a/documentation/de/upgrade-to-v3.md b/documentation/de/upgrade-to-v3.md new file mode 100644 index 0000000..439a47d --- /dev/null +++ b/documentation/de/upgrade-to-v3.md @@ -0,0 +1,83 @@ +# Upgrade auf AppState 3.0 + +AppState 3.0 ist rund um Swift 6 und Apples Observation-Framework aufgebaut. Im Folgenden finden Sie die Breaking Changes und wie Sie sich anpassen. + +## Breaking Changes auf einen Blick + +- **Plattform-Mindestversionen angehoben** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Strikte Swift-6-Nebenläufigkeit** — `ExistentialAny` aktiviert; explizites `any` bei Protokoll-Existentialen erforderlich +- **`ObservableObject` entfernt** — `Application` verwendet `@Observable`; `objectWillChange` ist weg, ersetzt durch `notifyChange()` +- **Neu (additiv): SwiftData-Unterstützung** — `ModelState` / `@ModelState` für `@Model`-Objekte + +--- + +## 1. Erhöhte Plattformanforderungen + +| Plattform | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux und Windows werden für den Funktionsumfang ohne Apple-Bezug weiterhin unterstützt. + +Bleiben Sie bei der 2.x-Release-Linie, wenn Sie ältere Betriebssystemversionen unterstützen müssen. + +## 2. Striktes Swift 6 + +Das Paket fixiert den Swift-6-Sprachmodus (`swiftLanguageModes: [.v6]`) und aktiviert das anstehende Feature `ExistentialAny`. CI-Builds behandeln Warnungen als Fehler. + +Für die meisten Apps sind keine Änderungen erforderlich. Wenn Sie eines der öffentlichen Protokolle von AppState implementiert haben — `FileManaging`, `UserDefaultsManaging` oder `UbiquitousKeyValueStoreManaging` —, müssen Sie möglicherweise existenzielle Typen mit einem expliziten `any` schreiben: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation ersetzt ObservableObject + +`Application` verwendet jetzt [`@Observable`](https://developer.apple.com/documentation/observation) anstelle von `ObservableObject`. + +**Die Property-Wrapper sind unverändert.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice` und `@ModelState` funktionieren allesamt weiterhin in SwiftUI-Ansichten. View-Modelle, die `ObservableObject` entsprechen und diese Wrapper hosten, werden weiterhin unterstützt. + +Was sich geändert hat: + +- `Application.shared.objectWillChange` existiert nicht mehr. +- `Application.notifyChange()` ersetzt es. Die eigenen Setter von AppState rufen es automatisch auf. +- Das direkte Lesen von `Application.state(_:).value` nimmt jetzt an der Observation teil — nicht nur der `@AppState`-Wrapper. Das bedeutet, dass beliebiger Code (nicht nur SwiftUI-Ansichten) Zustandsänderungen beobachten kann: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +Wenn Sie `Application` abgeleitet und `objectWillChange.send()` manuell aufgerufen haben (z. B. aus einem `didChangeExternally`-Override), ersetzen Sie es durch `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` ist unverändert — es beobachtet weiterhin Abhängigkeitswerte, die `ObservableObject` entsprechen. + +## 4. Neu: SwiftData-Unterstützung + +3.0 fügt SwiftData-Integration hinzu. Injizieren Sie einen gemeinsam genutzten `ModelContainer` als Abhängigkeit und lesen/schreiben Sie `@Model`-Objekte über `ModelState`. Dies ist additiv und optional — siehe den [ModelState-Verwendungsleitfaden](usage-modelstate.md). + +--- +Diese Übersetzung wurde automatisch generiert und kann Fehler enthalten. Wenn Sie Muttersprachler sind, freuen wir uns über Ihre Korrekturvorschläge per Pull Request. diff --git a/documentation/de/usage-modelstate.md b/documentation/de/usage-modelstate.md new file mode 100644 index 0000000..07172a0 --- /dev/null +++ b/documentation/de/usage-modelstate.md @@ -0,0 +1,207 @@ +# Verwendung von ModelState + +🍎 `ModelState` ermöglicht es Ihnen, SwiftData-`@Model`-Objekte über das Dependency-Injection-Modell von AppState zu verwalten. Registrieren Sie einen gemeinsam genutzten `ModelContainer` einmal; lesen und schreiben Sie Modelle von überall — View-Modelle, Dienste oder anderer Nicht-View-Code — ohne den `ModelContext` durch Ihren Aufrufstapel zu fädeln. + +> 🍎 `ModelState` erfordert Apple-Plattformen mit SwiftData-Unterstützung (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). Diese APIs werden auf Linux und Windows nicht einkompiliert. + +## End-to-End-Beispiel + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Registrieren des ModelContainer + +`modelContainer(_:)` registriert den Container mit einer automatisch generierten Kennung und wertet die Autoclosure nur einmal aus. Bauen Sie den Container in einer Hilfsfunktion statt inline — das macht Fehler explizit: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Definieren eines ModelState + +Ohne `FetchDescriptor` entspricht der Zustand allen Modellen des angegebenen Typs: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Geben Sie einen `FetchDescriptor` zum Filtern oder Sortieren an: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Lesen und Ändern + +**Über `@ModelState`** — lesen Sie den umschlossenen Wert, ändern Sie über `$items`: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**Über `Application.modelState`** — nützlich in Diensten und Nicht-View-Code: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` führt bei jedem Lesezugriff einen Live-Abruf aus SwiftData durch. Speichern Sie das Ergebnis in einer lokalen Variablen, wenn Sie es mehr als einmal benötigen. + +### Projected-Value-API + +| Methode | Verhalten | +| --- | --- | +| `$items.insert(_:)` | Fügt ein Modell ein und speichert | +| `$items.delete(_:)` | Löscht ein Modell und speichert | +| `$items.save()` | Persistiert ausstehende Änderungen | +| `$items.deleteAll()` | Löscht alle Modelle, die dem `FetchDescriptor` entsprechen, und speichert | + +Diese Mutatoren protokollieren jeden zugrunde liegenden SwiftData-Fehler und unterdrücken ihn, damit die Aufrufstellen knapp bleiben. Wenn Sie einen fehlgeschlagenen Schreibvorgang offenlegen oder davon wiederherstellen müssen, greifen Sie auf die werfenden Gegenstücke von `strict` zurück: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // Fehler anzeigen, zurückrollen, erneut versuchen… +} +``` + +`strict` stellt werfende Versionen aller vier Mutatoren (`insert`, `delete`, `save`, `deleteAll`) bereit, die durch denselben Kontext gestützt werden — wählen Sie die nachsichtige API, wenn ein protokollierter Fehler akzeptabel ist, und `strict`, wenn der Aufrufer ihn behandeln muss. + +## Zugriff auf den ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Gibt den `mainContext` des aufgelösten `ModelContainer` zurück — denselben Kontext, der von allen Lese- und Schreibvorgängen verwendet wird. + +## ModelState vs. SwiftData @Query + +Über `ModelState` vorgenommene Änderungen werden **nicht** automatisch an SwiftUI-Ansichten weitergegeben. Das ist beabsichtigt. + +- **Reaktive Ansichten** — verwenden Sie `@Query`. Es beobachtet den `ModelContext` direkt und aktualisiert die Ansicht, wenn sich die Daten ändern. Teilen Sie den von AppState bereitgestellten Container mit der SwiftUI-Umgebung, damit Ansichten und Nicht-View-Code denselben Speicher verwenden: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **View-Modelle und Dienste** — verwenden Sie `@ModelState` / `Application.modelState`. Ideal, wenn `@Environment` und `@Query` nicht verfügbar sind oder wenn Sie Modelloperationen außerhalb von View-Code benötigen. + +## Hinweise + +- Alle Lese- und Schreibvorgänge laufen über den `mainContext` des Containers — halten Sie die Verwendung auf dem Main-Actor. +- `ModelState` speichert Ergebnisse nicht im eigenen Cache von AppState zwischen. Der `ModelContext` von SwiftData ist die Quelle der Wahrheit. +- Registrieren Sie eine einzelne `ModelContainer`-Abhängigkeit und referenzieren Sie sie aus allen Modellzuständen und der SwiftUI-Umgebung. + +--- +Diese Übersetzung wurde automatisch generiert und kann Fehler enthalten. Wenn Sie Muttersprachler sind, freuen wir uns über Ihre Korrekturvorschläge per Pull Request. diff --git a/documentation/de/usage-overview.md b/documentation/de/usage-overview.md index 87ab054..3ad4579 100644 --- a/documentation/de/usage-overview.md +++ b/documentation/de/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("Hallo, \(user.name)!") - Button("Anmelden") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("Einstellungen: \(userPreferences)") - Button("Einstellungen aktualisieren") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("Dunkelmodus", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` verwaltet SwiftData-`@Model`-Objekte über AppState, indem ein gemeinsam genutzter `ModelContainer` injiziert wird. Es ist für View-Modelle, Dienste und anderen Nicht-View-Code gedacht; für reaktive Ansichten verwenden Sie SwiftDatas `@Query` zusammen mit dem von AppState bereitgestellten `ModelContainer`. SwiftData-Funktionen erfordern iOS 17+ / macOS 14+. + +### Beispiel + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +Weitere Details finden Sie im [ModelState-Verwendungsleitfaden](usage-modelstate.md). + ## SecureState `SecureState` speichert vertrauliche Daten sicher im Schlüsselbund. @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("Benutzertoken: \(token)") + Text("User token: \(token)") } else { - Text("Kein Token gefunden.") + Text("No token found.") } - Button("Token festlegen") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -165,7 +205,7 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("Benutzername: \(name)") + Text("Username: \(name)") } } ``` @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("Benutzername: \(name)") - Button("Benutzernamen aktualisieren") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -196,7 +236,7 @@ struct SlicingView: View { ## Bewährte Praktiken -- **Verwenden Sie `AppState` in SwiftUI-Ansichten**: Eigenschafts-Wrapper wie `@AppState`, `@StoredState`, `@FileState`, `@SecureState` und andere sind für die Verwendung im Geltungsbereich von SwiftUI-Ansichten konzipiert. +- **Verwenden Sie `AppState` in SwiftUI-Ansichten**: Property-Wrapper wie `@AppState`, `@StoredState`, `@FileState`, `@SecureState` und andere sind für die Verwendung im Geltungsbereich von SwiftUI-Ansichten konzipiert. - **Definieren Sie den Zustand in der Anwendungserweiterung**: Zentralisieren Sie die Zustandsverwaltung, indem Sie `Application` erweitern, um den Zustand und die Abhängigkeiten Ihrer App zu definieren. - **Reaktive Aktualisierungen**: SwiftUI aktualisiert Ansichten automatisch, wenn sich der Zustand ändert, sodass Sie die Benutzeroberfläche nicht manuell aktualisieren müssen. - **[Leitfaden zu bewährten Praktiken](best-practices.md)**: Für eine detaillierte Aufschlüsselung der bewährten Praktiken bei der Verwendung von AppState. @@ -206,6 +246,7 @@ struct SlicingView: View { Nachdem Sie sich mit der grundlegenden Verwendung vertraut gemacht haben, können Sie sich mit fortgeschritteneren Themen befassen: - Erkunden Sie die Verwendung von **FileState** zum dauerhaften Speichern großer Datenmengen in Dateien im [FileState-Verwendungsleitfaden](usage-filestate.md). +- 🍎 Erfahren Sie, wie Sie **SwiftData**-Modelle über AppState verwalten, im [ModelState-Verwendungsleitfaden](usage-modelstate.md). - Erfahren Sie mehr über **Konstanten** und wie Sie sie für unveränderliche Werte im Zustand Ihrer App verwenden können, im [Konstanten-Verwendungsleitfaden](usage-constant.md). - Untersuchen Sie, wie **Dependency** in AppState verwendet wird, um gemeinsam genutzte Dienste zu verarbeiten, und sehen Sie sich Beispiele im [Zustandsabhängigkeits-Verwendungsleitfaden](usage-state-dependency.md) an. - Tauchen Sie tiefer in fortgeschrittene **SwiftUI**-Techniken wie die Verwendung von `ObservedDependency` zur Verwaltung beobachtbarer Abhängigkeiten in Ansichten im [ObservedDependency-Verwendungsleitfaden](usage-observeddependency.md) ein. diff --git a/documentation/de/usage-syncstate.md b/documentation/de/usage-syncstate.md index e3b5e52..07cd5f0 100644 --- a/documentation/de/usage-syncstate.md +++ b/documentation/de/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/en/upgrade-to-v3.md b/documentation/en/upgrade-to-v3.md new file mode 100644 index 0000000..7912b93 --- /dev/null +++ b/documentation/en/upgrade-to-v3.md @@ -0,0 +1,80 @@ +# Upgrading to AppState 3.0 + +AppState 3.0 is built around Swift 6 and Apple's Observation framework. Below are the breaking changes and how to adapt. + +## Breaking changes at a glance + +- **Platform minimums raised** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Swift 6 strict concurrency** — `ExistentialAny` enabled; explicit `any` required on protocol existentials +- **`ObservableObject` removed** — `Application` uses `@Observable`; `objectWillChange` is gone, replace with `notifyChange()` +- **New (additive): SwiftData support** — `ModelState` / `@ModelState` for `@Model` objects + +--- + +## 1. Raised platform requirements + +| Platform | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux and Windows continue to be supported for the non-Apple feature set. + +Stay on the 2.x release line if you need to support older OS versions. + +## 2. Strict Swift 6 + +The package pins the Swift 6 language mode (`swiftLanguageModes: [.v6]`) and enables the `ExistentialAny` upcoming feature. CI builds with warnings as errors. + +Most apps require no changes. If you implemented any of AppState's public protocols — `FileManaging`, `UserDefaultsManaging`, or `UbiquitousKeyValueStoreManaging` — you may need to write existential types with an explicit `any`: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation replaces ObservableObject + +`Application` now uses [`@Observable`](https://developer.apple.com/documentation/observation) instead of `ObservableObject`. + +**Property wrappers are unchanged.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice`, and `@ModelState` all continue to work inside SwiftUI views. View models that conform to `ObservableObject` and host these wrappers are still supported. + +What changed: + +- `Application.shared.objectWillChange` no longer exists. +- `Application.notifyChange()` replaces it. AppState's own setters call it automatically. +- Reading `Application.state(_:).value` directly now participates in Observation — not just the `@AppState` wrapper. This means any code (not just SwiftUI views) can observe state changes: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +If you subclassed `Application` and called `objectWillChange.send()` manually (e.g., from a `didChangeExternally` override), replace it with `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` is unchanged — it still observes dependency values that conform to `ObservableObject`. + +## 4. New: SwiftData support + +3.0 adds SwiftData integration. Inject a shared `ModelContainer` as a dependency and read/write `@Model` objects through `ModelState`. This is additive and optional — see the [ModelState Usage Guide](usage-modelstate.md). diff --git a/documentation/en/usage-modelstate.md b/documentation/en/usage-modelstate.md new file mode 100644 index 0000000..ce8bd69 --- /dev/null +++ b/documentation/en/usage-modelstate.md @@ -0,0 +1,204 @@ +# ModelState Usage + +🍎 `ModelState` lets you manage SwiftData `@Model` objects through AppState's dependency-injection model. Register a shared `ModelContainer` once; read and write models from anywhere — view models, services, or other non-view code — without threading `ModelContext` through your call stack. + +> 🍎 `ModelState` requires Apple platforms with SwiftData support (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). These APIs are compiled out on Linux and Windows. + +## End-to-End Example + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Registering the ModelContainer + +`modelContainer(_:)` registers the container with an auto-generated identifier and evaluates the autoclosure only once. Build the container in a helper rather than inline — it makes failures explicit: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Defining a ModelState + +With no `FetchDescriptor`, the state matches all models of the given type: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Supply a `FetchDescriptor` for filtering or sorting: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Reading and Mutating + +**Via `@ModelState`** — read the wrapped value, mutate through `$items`: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**Via `Application.modelState`** — useful in services and non-view code: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` performs a live SwiftData fetch on every read. Capture the result in a local when you need it more than once. + +### Projected-value API + +| Method | Behavior | +| --- | --- | +| `$items.insert(_:)` | Inserts a model and saves | +| `$items.delete(_:)` | Deletes a model and saves | +| `$items.save()` | Persists pending changes | +| `$items.deleteAll()` | Deletes all models matching the `FetchDescriptor` and saves | + +These mutators log and swallow any underlying SwiftData error so call sites stay terse. When you need to surface or recover from a failed write, reach for the throwing counterparts on `strict`: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // present the error, roll back, retry… +} +``` + +`strict` exposes throwing versions of all four mutators (`insert`, `delete`, `save`, `deleteAll`) backed by the same context — pick the lenient API when a logged failure is acceptable, and `strict` when the caller must handle it. + +## Accessing the ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Returns the `mainContext` of the resolved `ModelContainer` — the same context used by all reads and writes. + +## ModelState vs SwiftData @Query + +`ModelState` mutations are **not** automatically broadcast to SwiftUI views. This is intentional. + +- **Reactive views** — use `@Query`. It observes the `ModelContext` directly and refreshes the view when data changes. Share the AppState-provided container with the SwiftUI environment so views and non-view code use the same store: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **View models and services** — use `@ModelState` / `Application.modelState`. Ideal when `@Environment` and `@Query` aren't available, or when you need model operations outside of view code. + +## Notes + +- All reads and writes go through the container's `mainContext` — keep usages on the main actor. +- `ModelState` does not cache results in AppState's own cache. SwiftData's `ModelContext` is the source of truth. +- Register a single `ModelContainer` dependency and reference it from all model states and the SwiftUI environment. diff --git a/documentation/en/usage-overview.md b/documentation/en/usage-overview.md index 3a0a326..50b7cbc 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 + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@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). diff --git a/documentation/en/usage-syncstate.md b/documentation/en/usage-syncstate.md index 557eb5f..582d783 100644 --- a/documentation/en/usage-syncstate.md +++ b/documentation/en/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/es/upgrade-to-v3.md b/documentation/es/upgrade-to-v3.md new file mode 100644 index 0000000..b3b50e5 --- /dev/null +++ b/documentation/es/upgrade-to-v3.md @@ -0,0 +1,83 @@ +# Actualización a AppState 3.0 + +AppState 3.0 está construido en torno a Swift 6 y el framework Observation de Apple. A continuación se detallan los cambios importantes y cómo adaptarse. + +## Cambios importantes de un vistazo + +- **Versiones mínimas de plataforma elevadas** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Concurrencia estricta de Swift 6** — `ExistentialAny` habilitado; se requiere `any` explícito en los existenciales de protocolo +- **`ObservableObject` eliminado** — `Application` usa `@Observable`; `objectWillChange` desaparece, reemplácelo con `notifyChange()` +- **Nuevo (aditivo): soporte para SwiftData** — `ModelState` / `@ModelState` para objetos `@Model` + +--- + +## 1. Requisitos de plataforma elevados + +| Plataforma | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux y Windows siguen siendo compatibles para el conjunto de características que no son de Apple. + +Permanezca en la línea de versiones 2.x si necesita admitir versiones de SO más antiguas. + +## 2. Swift 6 estricto + +El paquete fija el modo de lenguaje Swift 6 (`swiftLanguageModes: [.v6]`) y habilita la característica próxima `ExistentialAny`. CI compila con las advertencias tratadas como errores. + +La mayoría de las aplicaciones no requieren cambios. Si implementó alguno de los protocolos públicos de AppState — `FileManaging`, `UserDefaultsManaging` o `UbiquitousKeyValueStoreManaging` — es posible que necesite escribir los tipos existenciales con un `any` explícito: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation reemplaza a ObservableObject + +`Application` ahora usa [`@Observable`](https://developer.apple.com/documentation/observation) en lugar de `ObservableObject`. + +**Los property wrappers no cambian.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice` y `@ModelState` siguen funcionando dentro de las vistas de SwiftUI. Los view models que se ajustan a `ObservableObject` y alojan estos wrappers todavía son compatibles. + +Lo que cambió: + +- `Application.shared.objectWillChange` ya no existe. +- `Application.notifyChange()` lo reemplaza. Los propios setters de AppState lo llaman automáticamente. +- Leer `Application.state(_:).value` directamente ahora participa en Observation — no solo el wrapper `@AppState`. Esto significa que cualquier código (no solo las vistas de SwiftUI) puede observar los cambios de estado: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +Si creó una subclase de `Application` y llamó a `objectWillChange.send()` manualmente (por ejemplo, desde una anulación de `didChangeExternally`), reemplácelo con `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` no ha cambiado — todavía observa los valores de dependencia que se ajustan a `ObservableObject`. + +## 4. Nuevo: Soporte para SwiftData + +3.0 añade integración con SwiftData. Inyecte un `ModelContainer` compartido como una dependencia y lea/escriba objetos `@Model` a través de `ModelState`. Esto es aditivo y opcional — consulte la [Guía de Uso de ModelState](usage-modelstate.md). + +--- +Esta traducción fue generada automáticamente y puede contener errores. Si eres un hablante nativo, te agradecemos que contribuyas con correcciones a través de un Pull Request. diff --git a/documentation/es/usage-modelstate.md b/documentation/es/usage-modelstate.md new file mode 100644 index 0000000..d1089c0 --- /dev/null +++ b/documentation/es/usage-modelstate.md @@ -0,0 +1,207 @@ +# Uso de ModelState + +🍎 `ModelState` le permite gestionar objetos `@Model` de SwiftData a través del modelo de inyección de dependencias de AppState. Registre un `ModelContainer` compartido una vez; lea y escriba modelos desde cualquier lugar — view models, servicios u otro código que no es de vista — sin tener que pasar el `ModelContext` a través de su pila de llamadas. + +> 🍎 `ModelState` requiere plataformas de Apple con soporte para SwiftData (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). Estas API se excluyen de la compilación en Linux y Windows. + +## Ejemplo de Extremo a Extremo + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Registro del ModelContainer + +`modelContainer(_:)` registra el contenedor con un identificador generado automáticamente y evalúa el autoclosure solo una vez. Construya el contenedor en una función auxiliar en lugar de hacerlo en línea — así los fallos quedan explícitos: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Definición de un ModelState + +Sin un `FetchDescriptor`, el estado coincide con todos los modelos del tipo dado: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Proporcione un `FetchDescriptor` para filtrar u ordenar: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Lectura y Mutación + +**A través de `@ModelState`** — lea el valor envuelto, mute a través de `$items`: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**A través de `Application.modelState`** — útil en servicios y código que no es de vista: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` realiza una búsqueda en vivo de SwiftData en cada lectura. Capture el resultado en una variable local cuando lo necesite más de una vez. + +### API del Valor Proyectado + +| Método | Comportamiento | +| --- | --- | +| `$items.insert(_:)` | Inserta un modelo y guarda | +| `$items.delete(_:)` | Elimina un modelo y guarda | +| `$items.save()` | Persiste los cambios pendientes | +| `$items.deleteAll()` | Elimina todos los modelos que coinciden con el `FetchDescriptor` y guarda | + +Estos mutadores registran y descartan cualquier error subyacente de SwiftData para que los sitios de llamada se mantengan concisos. Cuando necesite exponer o recuperarse de una escritura fallida, recurra a las contrapartes que lanzan errores en `strict`: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // presentar el error, revertir, reintentar… +} +``` + +`strict` expone versiones que lanzan errores de los cuatro mutadores (`insert`, `delete`, `save`, `deleteAll`) respaldadas por el mismo contexto — elija la API tolerante cuando un fallo registrado sea aceptable, y `strict` cuando quien llama deba gestionarlo. + +## Acceso al ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Devuelve el `mainContext` del `ModelContainer` resuelto — el mismo contexto usado por todas las lecturas y escrituras. + +## ModelState vs el @Query de SwiftData + +Las mutaciones de `ModelState` **no** se transmiten automáticamente a las vistas de SwiftUI. Esto es intencional. + +- **Vistas reactivas** — use `@Query`. Observa el `ModelContext` directamente y actualiza la vista cuando cambian los datos. Comparta el contenedor proporcionado por AppState con el entorno de SwiftUI para que las vistas y el código que no es de vista usen el mismo almacén: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **View models y servicios** — use `@ModelState` / `Application.modelState`. Ideal cuando `@Environment` y `@Query` no están disponibles, o cuando necesita operaciones de modelo fuera del código de vista. + +## Notas + +- Todas las lecturas y escrituras pasan por el `mainContext` del contenedor — mantenga los usos en el actor principal. +- `ModelState` no almacena en caché los resultados en la propia caché de AppState. El `ModelContext` de SwiftData es la fuente de verdad. +- Registre una sola dependencia `ModelContainer` y refiéralo desde todos los estados de modelo y el entorno de SwiftUI. + +--- +Esta traducción fue generada automáticamente y puede contener errores. Si eres un hablante nativo, te agradecemos que contribuyas con correcciones a través de un Pull Request. diff --git a/documentation/es/usage-overview.md b/documentation/es/usage-overview.md index 52d3b40..4e21180 100644 --- a/documentation/es/usage-overview.md +++ b/documentation/es/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("¡Hola, \(user.name)!") - Button("Iniciar sesión") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("Preferencias: \(userPreferences)") - Button("Actualizar Preferencias") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("Modo Oscuro", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` gestiona objetos `@Model` de SwiftData a través de AppState inyectando un `ModelContainer` compartido. Está destinado a view models, servicios y otro código que no es de vista; para vistas reactivas, use el `@Query` de SwiftData junto con el `ModelContainer` proporcionado por AppState. Las características de SwiftData requieren iOS 17+ / macOS 14+. + +### Ejemplo + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +Para más detalles, consulte la [Guía de Uso de ModelState](usage-modelstate.md). + ## SecureState `SecureState` almacena datos confidenciales de forma segura en el Llavero. @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("Token de usuario: \(token)") + Text("User token: \(token)") } else { - Text("No se encontró ningún token.") + Text("No token found.") } - Button("Establecer Token") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -165,7 +205,7 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("Nombre de usuario: \(name)") + Text("Username: \(name)") } } ``` @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("Nombre de usuario: \(name)") - Button("Actualizar Nombre de Usuario") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -206,6 +246,7 @@ struct SlicingView: View { Después de familiarizarse con el uso básico, puede explorar temas más avanzados: - Explore el uso de **FileState** para persistir grandes cantidades de datos en archivos en la [Guía de Uso de FileState](usage-filestate.md). +- 🍎 Aprenda a gestionar modelos de **SwiftData** a través de AppState en la [Guía de Uso de ModelState](usage-modelstate.md). - Aprenda sobre **Constantes** y cómo usarlas para valores inmutables en el estado de su aplicación en la [Guía de Uso de Constantes](usage-constant.md). - Investigue cómo se usa **Dependency** en AppState para manejar servicios compartidos y vea ejemplos en la [Guía de Uso de Dependencia de Estado](usage-state-dependency.md). - Profundice en técnicas avanzadas de **SwiftUI** como el uso de `ObservedDependency` para gestionar dependencias observables en las vistas en la [Guía de Uso de ObservedDependency](usage-observeddependency.md). diff --git a/documentation/es/usage-syncstate.md b/documentation/es/usage-syncstate.md index 2196c46..b954bb3 100644 --- a/documentation/es/usage-syncstate.md +++ b/documentation/es/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/fr/upgrade-to-v3.md b/documentation/fr/upgrade-to-v3.md new file mode 100644 index 0000000..ef473c9 --- /dev/null +++ b/documentation/fr/upgrade-to-v3.md @@ -0,0 +1,83 @@ +# Mise à Niveau vers AppState 3.0 + +AppState 3.0 est construit autour de Swift 6 et du framework Observation d'Apple. Vous trouverez ci-dessous les changements incompatibles et la manière de s'y adapter. + +## Aperçu des changements incompatibles + +- **Versions minimales des plates-formes relevées** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Concurrence stricte de Swift 6** — `ExistentialAny` activé ; `any` explicite requis sur les existentiels de protocole +- **`ObservableObject` supprimé** — `Application` utilise `@Observable` ; `objectWillChange` a disparu, à remplacer par `notifyChange()` +- **Nouveau (additif) : prise en charge de SwiftData** — `ModelState` / `@ModelState` pour les objets `@Model` + +--- + +## 1. Versions minimales des plates-formes relevées + +| Plate-forme | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux et Windows continuent d'être pris en charge pour l'ensemble des fonctionnalités non-Apple. + +Restez sur la ligne de version 2.x si vous devez prendre en charge des versions d'OS plus anciennes. + +## 2. Swift 6 strict + +Le package fixe le mode de langage Swift 6 (`swiftLanguageModes: [.v6]`) et active la fonctionnalité à venir `ExistentialAny`. La CI compile en traitant les avertissements comme des erreurs. + +La plupart des applications ne nécessitent aucune modification. Si vous avez implémenté l'un des protocoles publics d'AppState — `FileManaging`, `UserDefaultsManaging` ou `UbiquitousKeyValueStoreManaging` — vous devrez peut-être écrire les types existentiels avec un `any` explicite : + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation remplace ObservableObject + +`Application` utilise désormais [`@Observable`](https://developer.apple.com/documentation/observation) au lieu de `ObservableObject`. + +**Les property wrappers sont inchangés.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice` et `@ModelState` continuent tous de fonctionner à l'intérieur des vues SwiftUI. Les modèles de vue qui se conforment à `ObservableObject` et hébergent ces wrappers sont toujours pris en charge. + +Ce qui a changé : + +- `Application.shared.objectWillChange` n'existe plus. +- `Application.notifyChange()` le remplace. Les propres setters d'AppState l'appellent automatiquement. +- Lire directement `Application.state(_:).value` participe désormais à l'Observation — pas seulement le wrapper `@AppState`. Cela signifie que n'importe quel code (pas seulement les vues SwiftUI) peut observer les changements d'état : + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +Si vous avez sous-classé `Application` et appelé `objectWillChange.send()` manuellement (par exemple, depuis une surcharge de `didChangeExternally`), remplacez-le par `notifyChange()` : + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` est inchangé — il observe toujours les valeurs de dépendance qui se conforment à `ObservableObject`. + +## 4. Nouveau : prise en charge de SwiftData + +La version 3.0 ajoute l'intégration de SwiftData. Injectez un `ModelContainer` partagé en tant que dépendance et lisez/écrivez les objets `@Model` via `ModelState`. Cet ajout est additif et optionnel — consultez le [Guide d'Utilisation de ModelState](usage-modelstate.md). + +--- +Cette traduction a été générée automatiquement et peut contenir des erreurs. Si vous êtes un locuteur natif, nous vous serions reconnaissants de contribuer avec des corrections via une Pull Request. diff --git a/documentation/fr/usage-modelstate.md b/documentation/fr/usage-modelstate.md new file mode 100644 index 0000000..f8ca697 --- /dev/null +++ b/documentation/fr/usage-modelstate.md @@ -0,0 +1,207 @@ +# Utilisation de ModelState + +🍎 `ModelState` vous permet de gérer les objets SwiftData `@Model` via le modèle d'injection de dépendances d'AppState. Enregistrez un `ModelContainer` partagé une seule fois ; lisez et écrivez les modèles depuis n'importe où — modèles de vue, services ou autre code hors-vue — sans avoir à faire transiter un `ModelContext` à travers votre pile d'appels. + +> 🍎 `ModelState` nécessite des plates-formes Apple prenant en charge SwiftData (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). Ces API ne sont pas compilées sur Linux et Windows. + +## Exemple de Bout en Bout + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Enregistrement du ModelContainer + +`modelContainer(_:)` enregistre le conteneur avec un identifiant généré automatiquement et n'évalue l'autoclosure qu'une seule fois. Construisez le conteneur dans une fonction d'aide plutôt qu'en ligne — cela rend les échecs explicites : + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Définition d'un ModelState + +Sans `FetchDescriptor`, l'état correspond à tous les modèles du type donné : + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Fournissez un `FetchDescriptor` pour le filtrage ou le tri : + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Lecture et Mutation + +**Via `@ModelState`** — lisez la valeur encapsulée, mutez via `$items` : + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**Via `Application.modelState`** — utile dans les services et le code hors-vue : + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` effectue une récupération SwiftData en direct à chaque lecture. Capturez le résultat dans une variable locale lorsque vous en avez besoin plusieurs fois. + +### API de la valeur projetée + +| Méthode | Comportement | +| --- | --- | +| `$items.insert(_:)` | Insère un modèle et sauvegarde | +| `$items.delete(_:)` | Supprime un modèle et sauvegarde | +| `$items.save()` | Persiste les changements en attente | +| `$items.deleteAll()` | Supprime tous les modèles correspondant au `FetchDescriptor` et sauvegarde | + +Ces mutateurs journalisent et absorbent toute erreur SwiftData sous-jacente afin que les sites d'appel restent concis. Lorsque vous avez besoin de faire remonter ou de récupérer une écriture échouée, tournez-vous vers les variantes pouvant lever des erreurs disponibles sur `strict` : + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // présenter l'erreur, annuler, réessayer… +} +``` + +`strict` expose des versions pouvant lever des erreurs des quatre mutateurs (`insert`, `delete`, `save`, `deleteAll`) adossées au même contexte — choisissez l'API indulgente lorsqu'un échec journalisé est acceptable, et `strict` lorsque l'appelant doit le gérer. + +## Accès au ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Renvoie le `mainContext` du `ModelContainer` résolu — le même contexte utilisé par toutes les lectures et écritures. + +## ModelState vs @Query de SwiftData + +Les mutations de `ModelState` ne sont **pas** automatiquement diffusées aux vues SwiftUI. C'est intentionnel. + +- **Vues réactives** — utilisez `@Query`. Il observe directement le `ModelContext` et rafraîchit la vue lorsque les données changent. Partagez le conteneur fourni par AppState avec l'environnement SwiftUI afin que les vues et le code hors-vue utilisent le même magasin : + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **Modèles de vue et services** — utilisez `@ModelState` / `Application.modelState`. Idéal lorsque `@Environment` et `@Query` ne sont pas disponibles, ou lorsque vous avez besoin d'opérations sur les modèles en dehors du code de vue. + +## Remarques + +- Toutes les lectures et écritures passent par le `mainContext` du conteneur — gardez les usages sur le main actor. +- `ModelState` ne met pas les résultats en cache dans le cache propre d'AppState. Le `ModelContext` de SwiftData est la source de vérité. +- Enregistrez une seule dépendance `ModelContainer` et référencez-la depuis tous les états de modèle et l'environnement SwiftUI. + +--- +Cette traduction a été générée automatiquement et peut contenir des erreurs. Si vous êtes un locuteur natif, nous vous serions reconnaissants de contribuer avec des corrections via une Pull Request. diff --git a/documentation/fr/usage-overview.md b/documentation/fr/usage-overview.md index 1a93197..ef58bae 100644 --- a/documentation/fr/usage-overview.md +++ b/documentation/fr/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("Bonjour, \(user.name)!") - Button("Se connecter") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("Préférences : \(userPreferences)") - Button("Mettre à jour les préférences") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("Mode Sombre", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` gère les objets SwiftData `@Model` à travers AppState en injectant un `ModelContainer` partagé. Il est destiné aux modèles de vue, aux services et à tout autre code hors-vue ; pour les vues réactives, utilisez le `@Query` de SwiftData avec le `ModelContainer` fourni par AppState. Les fonctionnalités de SwiftData nécessitent iOS 17+ / macOS 14+. + +### Exemple + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +Pour plus de détails, consultez le [Guide d'Utilisation de ModelState](usage-modelstate.md). + ## SecureState `SecureState` stocke les données sensibles de manière sécurisée dans le Trousseau. @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("Jeton utilisateur : \(token)") + Text("User token: \(token)") } else { - Text("Aucun jeton trouvé.") + Text("No token found.") } - Button("Définir le jeton") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -165,7 +205,7 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("Nom d'utilisateur : \(name)") + Text("Username: \(name)") } } ``` @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("Nom d'utilisateur : \(name)") - Button("Mettre à jour le nom d'utilisateur") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -206,6 +246,7 @@ struct SlicingView: View { Après vous être familiarisé avec l'utilisation de base, vous pouvez explorer des sujets plus avancés : - Explorez l'utilisation de **FileState** pour persister de grandes quantités de données dans des fichiers dans le [Guide d'Utilisation de FileState](usage-filestate.md). +- 🍎 Apprenez à gérer les modèles **SwiftData** à travers AppState dans le [Guide d'Utilisation de ModelState](usage-modelstate.md). - Apprenez-en davantage sur les **Constantes** et comment les utiliser pour des valeurs immuables dans l'état de votre application dans le [Guide d'Utilisation des Constantes](usage-constant.md). - Examinez comment **Dependency** est utilisé dans AppState pour gérer les services partagés, et consultez des exemples dans le [Guide d'Utilisation de la Dépendance d'État](usage-state-dependency.md). - Approfondissez les techniques avancées de **SwiftUI** comme l'utilisation de `ObservedDependency` pour gérer les dépendances observables dans les vues dans le [Guide d'Utilisation de ObservedDependency](usage-observeddependency.md). diff --git a/documentation/fr/usage-syncstate.md b/documentation/fr/usage-syncstate.md index 89dc715..5cb5c24 100644 --- a/documentation/fr/usage-syncstate.md +++ b/documentation/fr/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/hi/upgrade-to-v3.md b/documentation/hi/upgrade-to-v3.md new file mode 100644 index 0000000..a464896 --- /dev/null +++ b/documentation/hi/upgrade-to-v3.md @@ -0,0 +1,80 @@ +# AppState 3.0 में अपग्रेड करना + +AppState 3.0 Swift 6 और Apple के Observation फ्रेमवर्क के इर्द-गिर्द बनाया गया है। नीचे ब्रेकिंग परिवर्तन और उन्हें अनुकूलित करने का तरीका दिया गया है। + +## ब्रेकिंग परिवर्तन एक नज़र में + +- **प्लेटफ़ॉर्म न्यूनतम बढ़ाए गए** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Swift 6 सख्त समवर्तीता** — `ExistentialAny` सक्षम; प्रोटोकॉल एक्ज़िस्टेंशियल पर स्पष्ट `any` आवश्यक +- **`ObservableObject` हटाया गया** — `Application` अब `@Observable` का उपयोग करता है; `objectWillChange` समाप्त हो गया है, इसे `notifyChange()` से बदलें +- **नया (जोड़ा गया): SwiftData समर्थन** — `@Model` ऑब्जेक्ट्स के लिए `ModelState` / `@ModelState` + +--- + +## 1. बढ़ाई गई प्लेटफ़ॉर्म आवश्यकताएँ + +| प्लेटफ़ॉर्म | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +गैर-Apple फ़ीचर सेट के लिए Linux और Windows का समर्थन जारी है। + +यदि आपको पुराने OS संस्करणों का समर्थन करने की आवश्यकता है तो 2.x रिलीज़ लाइन पर बने रहें। + +## 2. सख्त Swift 6 + +पैकेज Swift 6 भाषा मोड (`swiftLanguageModes: [.v6]`) पिन करता है और `ExistentialAny` आगामी फ़ीचर सक्षम करता है। CI चेतावनियों को त्रुटियों के रूप में बिल्ड करता है। + +अधिकांश ऐप्स को किसी परिवर्तन की आवश्यकता नहीं होती। यदि आपने AppState के किसी भी सार्वजनिक प्रोटोकॉल — `FileManaging`, `UserDefaultsManaging`, या `UbiquitousKeyValueStoreManaging` — को लागू किया है, तो आपको स्पष्ट `any` के साथ एक्ज़िस्टेंशियल प्रकार लिखने पड़ सकते हैं: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation, ObservableObject की जगह लेता है + +`Application` अब `ObservableObject` के बजाय [`@Observable`](https://developer.apple.com/documentation/observation) का उपयोग करता है। + +**प्रॉपर्टी रैपर अपरिवर्तित हैं।** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice`, और `@ModelState` सभी SwiftUI व्यू के अंदर काम करना जारी रखते हैं। `ObservableObject` के अनुरूप व्यू मॉडल जो इन रैपर को होस्ट करते हैं, अभी भी समर्थित हैं। + +क्या बदला: + +- `Application.shared.objectWillChange` अब मौजूद नहीं है। +- `Application.notifyChange()` इसकी जगह लेता है। AppState के अपने सेटर इसे स्वचालित रूप से कॉल करते हैं। +- `Application.state(_:).value` को सीधे पढ़ना अब Observation में भाग लेता है — केवल `@AppState` रैपर ही नहीं। इसका मतलब है कि कोई भी कोड (केवल SwiftUI व्यू ही नहीं) स्थिति परिवर्तनों का निरीक्षण कर सकता है: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +यदि आपने `Application` को सबक्लास किया और मैन्युअल रूप से `objectWillChange.send()` को कॉल किया (उदाहरण के लिए, `didChangeExternally` ओवरराइड से), तो इसे `notifyChange()` से बदलें: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` अपरिवर्तित है — यह अभी भी `ObservableObject` के अनुरूप निर्भरता मानों का निरीक्षण करता है। + +## 4. नया: SwiftData समर्थन + +3.0 SwiftData एकीकरण जोड़ता है। एक साझा `ModelContainer` को निर्भरता के रूप में इंजेक्ट करें और `ModelState` के माध्यम से `@Model` ऑब्जेक्ट्स को पढ़ें/लिखें। यह जोड़ा गया और वैकल्पिक है — देखें [ModelState उपयोग मार्गदर्शिका](usage-modelstate.md)। diff --git a/documentation/hi/usage-modelstate.md b/documentation/hi/usage-modelstate.md new file mode 100644 index 0000000..4db96a9 --- /dev/null +++ b/documentation/hi/usage-modelstate.md @@ -0,0 +1,204 @@ +# ModelState का उपयोग + +🍎 `ModelState` आपको AppState के निर्भरता-इंजेक्शन मॉडल के माध्यम से SwiftData `@Model` ऑब्जेक्ट्स का प्रबंधन करने देता है। एक साझा `ModelContainer` को एक बार पंजीकृत करें; अपने कॉल स्टैक के माध्यम से `ModelContext` को पास किए बिना — व्यू मॉडल, सेवाओं, या अन्य गैर-व्यू कोड से — कहीं भी मॉडलों को पढ़ें और लिखें। + +> 🍎 `ModelState` के लिए SwiftData समर्थन वाले Apple प्लेटफ़ॉर्म आवश्यक हैं (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+)। ये API Linux और Windows पर संकलित नहीं होते। + +## एंड-टू-एंड उदाहरण + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## ModelContainer पंजीकृत करना + +`modelContainer(_:)` कंटेनर को एक स्वतः-उत्पन्न पहचानकर्ता के साथ पंजीकृत करता है और ऑटोक्लोज़र का मूल्यांकन केवल एक बार करता है। कंटेनर को इनलाइन के बजाय एक हेल्पर में बनाएं — इससे विफलताएँ स्पष्ट होती हैं: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## ModelState परिभाषित करना + +बिना किसी `FetchDescriptor` के, स्थिति दिए गए प्रकार के सभी मॉडलों से मेल खाती है: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +फ़िल्टरिंग या सॉर्टिंग के लिए एक `FetchDescriptor` प्रदान करें: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## पढ़ना और बदलना + +**`@ModelState` के माध्यम से** — रैप किए गए मान को पढ़ें, `$items` के माध्यम से बदलें: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**`Application.modelState` के माध्यम से** — सेवाओं और गैर-व्यू कोड में उपयोगी: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` प्रत्येक पठन पर एक लाइव SwiftData फ़ेच करता है। जब आपको इसकी एक से अधिक बार आवश्यकता हो तो परिणाम को एक लोकल में कैप्चर करें। + +### प्रोजेक्टेड-वैल्यू API + +| विधि | व्यवहार | +| --- | --- | +| `$items.insert(_:)` | एक मॉडल सम्मिलित करता है और सहेजता है | +| `$items.delete(_:)` | एक मॉडल हटाता है और सहेजता है | +| `$items.save()` | लंबित परिवर्तनों को स्थायी करता है | +| `$items.deleteAll()` | `FetchDescriptor` से मेल खाने वाले सभी मॉडलों को हटाता है और सहेजता है | + +ये म्यूटेटर किसी भी अंतर्निहित SwiftData त्रुटि को लॉग करते हैं और निगल जाते हैं ताकि कॉल साइट संक्षिप्त रहें। जब आपको किसी विफल लेखन को सामने लाने या उससे उबरने की आवश्यकता हो, तो `strict` पर मौजूद थ्रोइंग समकक्षों का उपयोग करें: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // त्रुटि प्रस्तुत करें, रोलबैक करें, पुनः प्रयास करें… +} +``` + +`strict` सभी चार म्यूटेटरों (`insert`, `delete`, `save`, `deleteAll`) के थ्रोइंग संस्करणों को उसी संदर्भ द्वारा समर्थित करके उजागर करता है — जब लॉग की गई विफलता स्वीकार्य हो तो लेनिएंट API चुनें, और जब कॉलर को इसे संभालना ही हो तो `strict` चुनें। + +## ModelContext तक पहुँचना + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +हल किए गए `ModelContainer` का `mainContext` लौटाता है — वही संदर्भ जो सभी पठन और लेखन द्वारा उपयोग किया जाता है। + +## ModelState बनाम SwiftData @Query + +`ModelState` बदलाव SwiftUI व्यू में स्वचालित रूप से प्रसारित **नहीं** होते। यह जानबूझकर किया गया है। + +- **रिएक्टिव व्यू** — `@Query` का उपयोग करें। यह `ModelContext` का सीधे निरीक्षण करता है और डेटा बदलने पर व्यू को रिफ्रेश करता है। AppState द्वारा प्रदान किए गए कंटेनर को SwiftUI एनवायरनमेंट के साथ साझा करें ताकि व्यू और गैर-व्यू कोड एक ही स्टोर का उपयोग करें: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **व्यू मॉडल और सेवाएँ** — `@ModelState` / `Application.modelState` का उपयोग करें। आदर्श जब `@Environment` और `@Query` उपलब्ध न हों, या जब आपको व्यू कोड के बाहर मॉडल ऑपरेशन की आवश्यकता हो। + +## नोट्स + +- सभी पठन और लेखन कंटेनर के `mainContext` से होकर गुजरते हैं — उपयोग को मुख्य अभिनेता पर रखें। +- `ModelState` AppState के अपने कैश में परिणामों को कैश नहीं करता। SwiftData का `ModelContext` सत्य का स्रोत है। +- एक एकल `ModelContainer` निर्भरता पंजीकृत करें और इसे सभी मॉडल स्थितियों और SwiftUI एनवायरनमेंट से संदर्भित करें। diff --git a/documentation/hi/usage-overview.md b/documentation/hi/usage-overview.md index 230bc70..bf733bd 100644 --- a/documentation/hi/usage-overview.md +++ b/documentation/hi/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -35,7 +35,7 @@ extension Application { ## State -`State` आपको एप्लिकेशन-व्यापी स्थिति को परिभाषित करने की अनुमति देता है जिसे आपके ऐप में कहीं भी एक्सेस और संशोधित किया जा सकता है। +`State` आपको एप्लिकेशन-व्यापी स्थिति परिभाषित करने की अनुमति देता है जिसे आपके ऐप में कहीं भी एक्सेस और संशोधित किया जा सकता है। ### उदाहरण @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("नमस्ते, \(user.name)!") - Button("लॉग इन करें") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -59,7 +59,7 @@ struct ContentView: View { ## StoredState -`StoredState` `UserDefaults` का उपयोग करके स्थिति को बनाए रखता है ताकि यह सुनिश्चित हो सके कि मान ऐप लॉन्च के बीच सहेजे गए हैं। +`StoredState` `UserDefaults` का उपयोग करके स्थिति को स्थायी बनाता है ताकि यह सुनिश्चित हो सके कि मान ऐप लॉन्च के बीच सहेजे जाएँ। ### उदाहरण @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("वरीयताएँ: \(userPreferences)") - Button("वरीयताएँ अपडेट करें") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -83,7 +83,7 @@ struct PreferencesView: View { ## SyncState -`SyncState` iCloud का उपयोग करके कई उपकरणों में ऐप की स्थिति को सिंक्रनाइज़ करता है। +`SyncState` iCloud का उपयोग करके कई उपकरणों में ऐप स्थिति को सिंक्रनाइज़ करता है। ### उदाहरण @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("डार्क मोड", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -104,7 +104,7 @@ struct SyncSettingsView: View { ## FileState -`FileState` का उपयोग फ़ाइल सिस्टम का उपयोग करके बड़े या अधिक जटिल डेटा को स्थायी रूप से संग्रहीत करने के लिए किया जाता है, जो इसे कैशिंग या उन डेटा को सहेजने के लिए आदर्श बनाता है जो `UserDefaults` की सीमाओं के भीतर फिट नहीं होते हैं। +`FileState` का उपयोग फ़ाइल सिस्टम का उपयोग करके बड़े या अधिक जटिल डेटा को स्थायी रूप से संग्रहीत करने के लिए किया जाता है, जो इसे कैशिंग या ऐसे डेटा को सहेजने के लिए आदर्श बनाता है जो `UserDefaults` की सीमाओं में फिट नहीं होता। ### उदाहरण @@ -123,9 +123,49 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` एक साझा `ModelContainer` को इंजेक्ट करके AppState के माध्यम से SwiftData `@Model` ऑब्जेक्ट्स का प्रबंधन करता है। यह व्यू मॉडल, सेवाओं और अन्य गैर-व्यू कोड के लिए अभिप्रेत है; रिएक्टिव व्यू के लिए, AppState द्वारा प्रदान किए गए `ModelContainer` के साथ SwiftData के `@Query` का उपयोग करें। SwiftData फ़ीचर्स के लिए iOS 17+ / macOS 14+ आवश्यक है। + +### उदाहरण + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +अधिक विवरण के लिए, देखें [ModelState उपयोग मार्गदर्शिका](usage-modelstate.md)। + ## SecureState -`SecureState` संवेदनशील डेटा को किचेन में सुरक्षित रूप से संग्रहीत करता है। +`SecureState` संवेदनशील डेटा को कीचेन में सुरक्षित रूप से संग्रहीत करता है। ### उदाहरण @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("उपयोगकर्ता टोकन: \(token)") + Text("User token: \(token)") } else { - Text("कोई टोकन नहीं मिला।") + Text("No token found.") } - Button("टोकन सेट करें") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -153,7 +193,7 @@ struct SecureView: View { ## Constant -`Constant` आपके एप्लिकेशन की स्थिति के भीतर मानों तक अपरिवर्तनीय, केवल-पढ़ने के लिए पहुँच प्रदान करता है, उन मानों तक पहुँचते समय सुरक्षा सुनिश्चित करता है जिन्हें संशोधित नहीं किया जाना चाहिए। +`Constant` आपके एप्लिकेशन की स्थिति के भीतर मानों तक अपरिवर्तनीय, केवल-पढ़ने योग्य पहुँच प्रदान करता है, जो उन मानों तक पहुँचते समय सुरक्षा सुनिश्चित करता है जिन्हें संशोधित नहीं किया जाना चाहिए। ### उदाहरण @@ -165,12 +205,12 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("उपयोगकर्ता नाम: \(name)") + Text("Username: \(name)") } } ``` -## स्लाइसिंग स्टेट +## स्लाइसिंग स्थिति `Slice` और `OptionalSlice` आपको अपने एप्लिकेशन की स्थिति के विशिष्ट भागों तक पहुँचने की अनुमति देते हैं। @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("उपयोगकर्ता नाम: \(name)") - Button("उपयोगकर्ता नाम अपडेट करें") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -194,22 +234,20 @@ struct SlicingView: View { } ``` -## सर्वोत्तम प्रथाएं +## सर्वोत्तम प्रथाएँ -- **SwiftUI दृश्यों में `AppState` का उपयोग करें**: `@AppState`, `@StoredState`, `@FileState`, `@SecureState`, और अन्य जैसे संपत्ति रैपर SwiftUI दृश्यों के दायरे में उपयोग किए जाने के लिए डिज़ाइन किए गए हैं। -- **एप्लिकेशन एक्सटेंशन में स्थिति को परिभाषित करें**: अपने ऐप की स्थिति और निर्भरताओं को परिभाषित करने के लिए `Application` का विस्तार करके स्थिति प्रबंधन को केंद्रीकृत करें। -- **प्रतिक्रियाशील अपडेट**: जब स्थिति बदलती है तो SwiftUI स्वचालित रूप से दृश्यों को अपडेट करता है, इसलिए आपको UI को मैन्युअल रूप से रीफ्रेश करने की आवश्यकता नहीं है। -- **[सर्वोत्तम प्रथाओं के लिए गाइड](best-practices.md)**: AppState का उपयोग करते समय सर्वोत्तम प्रथाओं के विस्तृत विवरण के लिए। +- **SwiftUI व्यू में `AppState` का उपयोग करें**: `@AppState`, `@StoredState`, `@FileState`, `@SecureState`, और अन्य जैसे प्रॉपर्टी रैपर SwiftUI व्यू के दायरे में उपयोग के लिए डिज़ाइन किए गए हैं। +- **एप्लिकेशन एक्सटेंशन में स्थिति परिभाषित करें**: अपने ऐप की स्थिति और निर्भरताओं को परिभाषित करने के लिए `Application` का विस्तार करके स्थिति प्रबंधन को केंद्रीकृत करें। +- **रिएक्टिव अपडेट**: स्थिति बदलने पर SwiftUI स्वचालित रूप से व्यू अपडेट करता है, इसलिए आपको UI को मैन्युअल रूप से रिफ्रेश करने की आवश्यकता नहीं है। +- **[सर्वोत्तम प्रथाएँ मार्गदर्शिका](best-practices.md)**: AppState का उपयोग करते समय सर्वोत्तम प्रथाओं के विस्तृत विवरण के लिए। ## अगले चरण बुनियादी उपयोग से परिचित होने के बाद, आप अधिक उन्नत विषयों का पता लगा सकते हैं: -- [FileState उपयोग गाइड](usage-filestate.md) में फ़ाइलों में बड़ी मात्रा में डेटा को बनाए रखने के लिए **FileState** का उपयोग करने का अन्वेषण करें। -- [स्थिरांक उपयोग गाइड](usage-constant.md) में **स्थिरांक** के बारे में जानें और अपने ऐप की स्थिति में अपरिवर्तनीय मानों के लिए उनका उपयोग कैसे करें। -- [राज्य निर्भरता उपयोग गाइड](usage-state-dependency.md) में साझा सेवाओं को संभालने के लिए AppState में **निर्भरता** का उपयोग कैसे किया जाता है, इसकी जांच करें और उदाहरण देखें। -- [देखे गए निर्भरता उपयोग गाइड](usage-observeddependency.md) में दृश्यों में अवलोकन योग्य निर्भरताओं के प्रबंधन के लिए `ObservedDependency` का उपयोग करने जैसी **उन्नत SwiftUI** तकनीकों में गहराई से उतरें। -- अधिक उन्नत उपयोग तकनीकों के लिए, जैसे जस्ट-इन-टाइम निर्माण और निर्भरताओं को प्रीलोड करना, [उन्नत उपयोग गाइड](advanced-usage.md) देखें। - ---- -यह अनुवाद स्वचालित रूप से उत्पन्न किया गया था और इसमें त्रुटियाँ हो सकती हैं। यदि आप एक देशी वक्ता हैं, तो हम एक पुल अनुरोध के माध्यम से सुधारों में आपके योगदान की सराहना करेंगे। +- [FileState उपयोग मार्गदर्शिका](usage-filestate.md) में फ़ाइलों में बड़ी मात्रा में डेटा को स्थायी बनाने के लिए **FileState** का उपयोग करना देखें। +- 🍎 [ModelState उपयोग मार्गदर्शिका](usage-modelstate.md) में AppState के माध्यम से **SwiftData** मॉडलों का प्रबंधन करना सीखें। +- [स्थिरांक उपयोग मार्गदर्शिका](usage-constant.md) में **Constants** के बारे में और अपने ऐप की स्थिति में अपरिवर्तनीय मानों के लिए उनका उपयोग कैसे करें, सीखें। +- AppState में साझा सेवाओं को संभालने के लिए **Dependency** का उपयोग कैसे किया जाता है, इसकी जाँच करें, और [स्टेट निर्भरता उपयोग मार्गदर्शिका](usage-state-dependency.md) में उदाहरण देखें। +- [ObservedDependency उपयोग मार्गदर्शिका](usage-observeddependency.md) में व्यू में अवलोकनीय निर्भरताओं के प्रबंधन के लिए `ObservedDependency` का उपयोग करने जैसी **उन्नत SwiftUI** तकनीकों में गहराई से उतरें। +- अधिक उन्नत उपयोग तकनीकों के लिए, जैसे जस्ट-इन-टाइम निर्माण और निर्भरताओं को प्रीलोड करना, [उन्नत उपयोग मार्गदर्शिका](advanced-usage.md) देखें। diff --git a/documentation/hi/usage-syncstate.md b/documentation/hi/usage-syncstate.md index 4904b72..6975974 100644 --- a/documentation/hi/usage-syncstate.md +++ b/documentation/hi/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/pt/upgrade-to-v3.md b/documentation/pt/upgrade-to-v3.md new file mode 100644 index 0000000..4831ffb --- /dev/null +++ b/documentation/pt/upgrade-to-v3.md @@ -0,0 +1,83 @@ +# Atualizando para o AppState 3.0 + +O AppState 3.0 é construído em torno do Swift 6 e do framework Observation da Apple. Abaixo estão as alterações que quebram a compatibilidade e como se adaptar. + +## Visão geral das alterações que quebram a compatibilidade + +- **Versões mínimas de plataforma elevadas** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Concorrência estrita do Swift 6** — `ExistentialAny` ativado; `any` explícito necessário em existenciais de protocolo +- **`ObservableObject` removido** — `Application` usa `@Observable`; `objectWillChange` foi removido, substitua por `notifyChange()` +- **Novo (aditivo): suporte a SwiftData** — `ModelState` / `@ModelState` para objetos `@Model` + +--- + +## 1. Requisitos de plataforma elevados + +| Plataforma | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux e Windows continuam a ser suportados para o conjunto de recursos não-Apple. + +Permaneça na linha de lançamento 2.x se você precisar dar suporte a versões mais antigas do sistema operacional. + +## 2. Swift 6 estrito + +O pacote fixa o modo de linguagem do Swift 6 (`swiftLanguageModes: [.v6]`) e ativa o recurso futuro `ExistentialAny`. A CI compila com avisos tratados como erros. + +A maioria dos aplicativos não requer alterações. Se você implementou algum dos protocolos públicos do AppState — `FileManaging`, `UserDefaultsManaging` ou `UbiquitousKeyValueStoreManaging` — talvez precise escrever tipos existenciais com um `any` explícito: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation substitui ObservableObject + +`Application` agora usa [`@Observable`](https://developer.apple.com/documentation/observation) em vez de `ObservableObject`. + +**Os property wrappers permanecem inalterados.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice` e `@ModelState` continuam todos a funcionar dentro de visualizações SwiftUI. Os view models que se conformam a `ObservableObject` e hospedam esses wrappers ainda são suportados. + +O que mudou: + +- `Application.shared.objectWillChange` não existe mais. +- `Application.notifyChange()` o substitui. Os próprios setters do AppState o chamam automaticamente. +- Ler `Application.state(_:).value` diretamente agora participa do Observation — não apenas o wrapper `@AppState`. Isso significa que qualquer código (não apenas visualizações SwiftUI) pode observar alterações de estado: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +Se você criou uma subclasse de `Application` e chamou `objectWillChange.send()` manualmente (por exemplo, a partir de uma sobrescrita de `didChangeExternally`), substitua-a por `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` permanece inalterado — ele ainda observa valores de dependência que se conformam a `ObservableObject`. + +## 4. Novo: suporte a SwiftData + +O 3.0 adiciona integração com SwiftData. Injete um `ModelContainer` compartilhado como uma dependência e leia/grave objetos `@Model` através do `ModelState`. Isso é aditivo e opcional — consulte o [Guia de Uso do ModelState](usage-modelstate.md). + +--- +Esta tradução foi gerada automaticamente e pode conter erros. Se você é um falante nativo, agradecemos suas contribuições com correções por meio de um Pull Request. diff --git a/documentation/pt/usage-modelstate.md b/documentation/pt/usage-modelstate.md new file mode 100644 index 0000000..e951e25 --- /dev/null +++ b/documentation/pt/usage-modelstate.md @@ -0,0 +1,207 @@ +# Uso do ModelState + +🍎 `ModelState` permite que você gerencie objetos `@Model` do SwiftData através do modelo de injeção de dependência do AppState. Registre um `ModelContainer` compartilhado uma vez; leia e grave modelos de qualquer lugar — view models, serviços ou outro código fora de visualizações — sem ter que passar o `ModelContext` por toda a sua pilha de chamadas. + +> 🍎 `ModelState` requer plataformas Apple com suporte ao SwiftData (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). Essas APIs não são compiladas no Linux e no Windows. + +## Exemplo de Ponta a Ponta + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Registrando o ModelContainer + +`modelContainer(_:)` registra o contêiner com um identificador gerado automaticamente e avalia a autoclosure apenas uma vez. Construa o contêiner em uma função auxiliar em vez de inline — isso torna as falhas explícitas: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Definindo um ModelState + +Sem um `FetchDescriptor`, o estado corresponde a todos os modelos do tipo fornecido: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Forneça um `FetchDescriptor` para filtragem ou ordenação: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Lendo e Modificando + +**Via `@ModelState`** — leia o valor encapsulado, mute através de `$items`: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**Via `Application.modelState`** — útil em serviços e código fora de visualizações: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` executa uma busca ativa no SwiftData a cada leitura. Capture o resultado em uma variável local quando precisar dele mais de uma vez. + +### API de Valor Projetado + +| Método | Comportamento | +| --- | --- | +| `$items.insert(_:)` | Insere um modelo e salva | +| `$items.delete(_:)` | Exclui um modelo e salva | +| `$items.save()` | Persiste as alterações pendentes | +| `$items.deleteAll()` | Exclui todos os modelos que correspondem ao `FetchDescriptor` e salva | + +Esses mutadores registram e engolem qualquer erro subjacente do SwiftData para que os pontos de chamada permaneçam concisos. Quando você precisar expor ou se recuperar de uma gravação malsucedida, recorra às contrapartes que lançam erros em `strict`: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // apresente o erro, reverta, tente novamente… +} +``` + +`strict` expõe versões que lançam erros de todos os quatro mutadores (`insert`, `delete`, `save`, `deleteAll`) apoiadas pelo mesmo contexto — escolha a API tolerante quando uma falha registrada for aceitável, e `strict` quando o chamador precisar tratá-la. + +## Acessando o ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Retorna o `mainContext` do `ModelContainer` resolvido — o mesmo contexto usado por todas as leituras e gravações. + +## ModelState vs @Query do SwiftData + +As mutações do `ModelState` **não** são transmitidas automaticamente para as visualizações SwiftUI. Isso é intencional. + +- **Visualizações reativas** — use `@Query`. Ele observa o `ModelContext` diretamente e atualiza a visualização quando os dados mudam. Compartilhe o contêiner fornecido pelo AppState com o ambiente do SwiftUI para que as visualizações e o código fora de visualizações usem o mesmo armazenamento: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **View models e serviços** — use `@ModelState` / `Application.modelState`. Ideal quando `@Environment` e `@Query` não estão disponíveis, ou quando você precisa de operações de modelo fora do código de visualização. + +## Notas + +- Todas as leituras e gravações passam pelo `mainContext` do contêiner — mantenha os usos no main actor. +- `ModelState` não armazena resultados em cache no próprio cache do AppState. O `ModelContext` do SwiftData é a fonte da verdade. +- Registre uma única dependência `ModelContainer` e referencie-a a partir de todos os estados de modelo e do ambiente do SwiftUI. + +--- +Esta tradução foi gerada automaticamente e pode conter erros. Se você é um falante nativo, agradecemos suas contribuições com correções por meio de um Pull Request. diff --git a/documentation/pt/usage-overview.md b/documentation/pt/usage-overview.md index ec1efa4..892eaed 100644 --- a/documentation/pt/usage-overview.md +++ b/documentation/pt/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("Olá, \(user.name)!") - Button("Fazer login") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("Preferências: \(userPreferences)") - Button("Atualizar Preferências") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("Modo Escuro", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` gerencia objetos `@Model` do SwiftData através do AppState, injetando um `ModelContainer` compartilhado. Ele é destinado a view models, serviços e outro código fora de visualizações; para visualizações reativas, use o `@Query` do SwiftData junto com o `ModelContainer` fornecido pelo AppState. Os recursos do SwiftData exigem iOS 17+ / macOS 14+. + +### Exemplo + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +Para mais detalhes, consulte o [Guia de Uso do ModelState](usage-modelstate.md). + ## SecureState `SecureState` armazena dados sensíveis de forma segura no Keychain. @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("Token do usuário: \(token)") + Text("User token: \(token)") } else { - Text("Nenhum token encontrado.") + Text("No token found.") } - Button("Definir Token") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -165,7 +205,7 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("Nome de usuário: \(name)") + Text("Username: \(name)") } } ``` @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("Nome de usuário: \(name)") - Button("Atualizar Nome de Usuário") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -206,6 +246,7 @@ struct SlicingView: View { Depois de se familiarizar com o uso básico, você pode explorar tópicos mais avançados: - Explore o uso de **FileState** para persistir grandes quantidades de dados em arquivos no [Guia de Uso do FileState](usage-filestate.md). +- 🍎 Aprenda como gerenciar modelos do **SwiftData** através do AppState no [Guia de Uso do ModelState](usage-modelstate.md). - Aprenda sobre **Constantes** e como usá-las para valores imutáveis no estado da sua aplicação no [Guia de Uso de Constantes](usage-constant.md). - Investigue como a **Dependência** é usada no AppState para lidar com serviços compartilhados e veja exemplos no [Guia de Uso de Dependência de Estado](usage-state-dependency.md). - Aprofunde-se em técnicas avançadas de **SwiftUI**, como o uso de `ObservedDependency` para gerenciar dependências observáveis em visualizações, no [Guia de Uso de ObservedDependency](usage-observeddependency.md). diff --git a/documentation/pt/usage-syncstate.md b/documentation/pt/usage-syncstate.md index f5f8976..33c42c4 100644 --- a/documentation/pt/usage-syncstate.md +++ b/documentation/pt/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/ru/upgrade-to-v3.md b/documentation/ru/upgrade-to-v3.md new file mode 100644 index 0000000..0be846e --- /dev/null +++ b/documentation/ru/upgrade-to-v3.md @@ -0,0 +1,80 @@ +# Обновление до AppState 3.0 + +AppState 3.0 построен вокруг Swift 6 и фреймворка Observation от Apple. Ниже перечислены критические изменения и способы адаптации к ним. + +## Критические изменения вкратце + +- **Повышены минимальные требования к платформам** — iOS 17, macOS 14, tvOS 17, watchOS 10 +- **Строгая конкурентность Swift 6** — включён `ExistentialAny`; для протокольных экзистенциалов требуется явный `any` +- **`ObservableObject` удалён** — `Application` использует `@Observable`; `objectWillChange` больше нет, замените его на `notifyChange()` +- **Новое (дополнительно): поддержка SwiftData** — `ModelState` / `@ModelState` для объектов `@Model` + +--- + +## 1. Повышенные требования к платформам + +| Платформа | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux и Windows по-прежнему поддерживаются для набора функций, не относящихся к Apple. + +Если вам нужно поддерживать более старые версии ОС, оставайтесь на линейке выпусков 2.x. + +## 2. Строгий Swift 6 + +Пакет фиксирует языковой режим Swift 6 (`swiftLanguageModes: [.v6]`) и включает предстоящую функцию `ExistentialAny`. CI собирает проект с предупреждениями, рассматриваемыми как ошибки. + +Большинству приложений изменения не требуются. Если вы реализовали какие-либо из публичных протоколов AppState — `FileManaging`, `UserDefaultsManaging` или `UbiquitousKeyValueStoreManaging` — вам, возможно, потребуется записывать экзистенциальные типы с явным `any`: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation заменяет ObservableObject + +`Application` теперь использует [`@Observable`](https://developer.apple.com/documentation/observation) вместо `ObservableObject`. + +**Обертки свойств не изменились.** `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@Slice`, `@OptionalSlice`, `@DependencySlice` и `@ModelState` по-прежнему работают внутри представлений SwiftUI. Модели представлений, соответствующие `ObservableObject` и содержащие эти обертки, по-прежнему поддерживаются. + +Что изменилось: + +- `Application.shared.objectWillChange` больше не существует. +- На замену пришёл `Application.notifyChange()`. Собственные сеттеры AppState вызывают его автоматически. +- Прямое чтение `Application.state(_:).value` теперь участвует в Observation — а не только обертка `@AppState`. Это значит, что любой код (не только представления SwiftUI) может наблюдать за изменениями состояния: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +Если вы создали подкласс `Application` и вызывали `objectWillChange.send()` вручную (например, из переопределения `didChangeExternally`), замените его на `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` не изменился — он по-прежнему наблюдает за значениями зависимостей, соответствующих `ObservableObject`. + +## 4. Новое: поддержка SwiftData + +3.0 добавляет интеграцию SwiftData. Внедряйте общий `ModelContainer` в качестве зависимости и читайте/записывайте объекты `@Model` через `ModelState`. Это дополнительная и необязательная возможность — см. [Руководство по использованию ModelState](usage-modelstate.md). diff --git a/documentation/ru/usage-modelstate.md b/documentation/ru/usage-modelstate.md new file mode 100644 index 0000000..d03a969 --- /dev/null +++ b/documentation/ru/usage-modelstate.md @@ -0,0 +1,204 @@ +# Использование ModelState + +🍎 `ModelState` позволяет управлять объектами SwiftData `@Model` через модель внедрения зависимостей AppState. Зарегистрируйте общий `ModelContainer` один раз; читайте и записывайте модели откуда угодно — из моделей представлений, служб или другого кода, не относящегося к представлениям — без необходимости пробрасывать `ModelContext` через стек вызовов. + +> 🍎 `ModelState` требует платформ Apple с поддержкой SwiftData (iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+). На Linux и Windows эти API не компилируются. + +## Сквозной пример + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## Регистрация ModelContainer + +`modelContainer(_:)` регистрирует контейнер с автоматически сгенерированным идентификатором и вычисляет автозамыкание только один раз. Создавайте контейнер во вспомогательной функции, а не встраивайте его — это делает ошибки явными: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## Определение ModelState + +Без `FetchDescriptor` состояние соответствует всем моделям заданного типа: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +Передайте `FetchDescriptor` для фильтрации или сортировки: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## Чтение и изменение + +**Через `@ModelState`** — читайте обернутое значение, изменяйте через `$items`: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**Через `Application.modelState`** — удобно в службах и коде, не относящемся к представлениям: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` выполняет «живую» выборку SwiftData при каждом чтении. Сохраняйте результат в локальной переменной, когда он нужен более одного раза. + +### API проецируемого значения + +| Метод | Поведение | +| --- | --- | +| `$items.insert(_:)` | Вставляет модель и сохраняет | +| `$items.delete(_:)` | Удаляет модель и сохраняет | +| `$items.save()` | Сохраняет ожидающие изменения | +| `$items.deleteAll()` | Удаляет все модели, соответствующие `FetchDescriptor`, и сохраняет | + +Эти методы изменения логируют и поглощают любую внутреннюю ошибку SwiftData, чтобы места вызова оставались лаконичными. Когда вам нужно показать сбой записи или восстановиться после него, используйте бросающие исключения аналоги в `strict`: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // показать ошибку, откатить, повторить… +} +``` + +`strict` предоставляет бросающие исключения версии всех четырёх методов изменения (`insert`, `delete`, `save`, `deleteAll`), опирающиеся на тот же контекст — выбирайте мягкий API, когда залогированный сбой допустим, и `strict`, когда вызывающая сторона обязана его обработать. + +## Доступ к ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +Возвращает `mainContext` разрешённого `ModelContainer` — тот же контекст, который используется для всех чтений и записей. + +## ModelState против SwiftData @Query + +Изменения `ModelState` **не** транслируются автоматически в представления SwiftUI. Это сделано намеренно. + +- **Реактивные представления** — используйте `@Query`. Он наблюдает за `ModelContext` напрямую и обновляет представление при изменении данных. Передайте предоставляемый AppState контейнер в окружение SwiftUI, чтобы представления и код, не относящийся к представлениям, использовали одно и то же хранилище: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **Модели представлений и службы** — используйте `@ModelState` / `Application.modelState`. Идеально подходит там, где `@Environment` и `@Query` недоступны, или когда операции над моделями нужны вне кода представлений. + +## Примечания + +- Все чтения и записи проходят через `mainContext` контейнера — держите использование на главном акторе. +- `ModelState` не кэширует результаты в собственном кэше AppState. `ModelContext` SwiftData является источником истины. +- Регистрируйте единственную зависимость `ModelContainer` и ссылайтесь на неё из всех состояний модели и окружения SwiftUI. diff --git a/documentation/ru/usage-overview.md b/documentation/ru/usage-overview.md index be5422a..cba1207 100644 --- a/documentation/ru/usage-overview.md +++ b/documentation/ru/usage-overview.md @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("Привет, \(user.name)!") - Button("Войти") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("Настройки: \(userPreferences)") - Button("Обновить настройки") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("Темный режим", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` управляет объектами SwiftData `@Model` через AppState, внедряя общий `ModelContainer`. Он предназначен для моделей представлений, служб и другого кода, не относящегося к представлениям; для реактивных представлений используйте `@Query` SwiftData вместе с предоставляемым AppState `ModelContainer`. Функции SwiftData требуют iOS 17+ / macOS 14+. + +### Пример + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +Для получения дополнительных сведений см. [Руководство по использованию ModelState](usage-modelstate.md). + ## SecureState `SecureState` надежно хранит конфиденциальные данные в связке ключей. @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("Токен пользователя: \(token)") + Text("User token: \(token)") } else { - Text("Токен не найден.") + Text("No token found.") } - Button("Установить токен") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -165,7 +205,7 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("Имя пользователя: \(name)") + Text("Username: \(name)") } } ``` @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("Имя пользователя: \(name)") - Button("Обновить имя пользователя") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -206,6 +246,7 @@ struct SlicingView: View { После ознакомления с основами использования вы можете изучить более сложные темы: - Изучите использование **FileState** для сохранения больших объемов данных в файлы в [Руководстве по использованию FileState](usage-filestate.md). +- 🍎 Узнайте, как управлять моделями **SwiftData** через AppState, в [Руководстве по использованию ModelState](usage-modelstate.md). - Узнайте о **константах** и о том, как их использовать для неизменяемых значений в состоянии вашего приложения, в [Руководстве по использованию констант](usage-constant.md). - Узнайте, как **Dependency** используется в AppState для обработки общих служб, и посмотрите примеры в [Руководстве по использованию зависимостей состояния](usage-state-dependency.md). - Углубитесь в более сложные методы **SwiftUI**, такие как использование `ObservedDependency` для управления наблюдаемыми зависимостями в представлениях, в [Руководстве по использованию ObservedDependency](usage-observeddependency.md). diff --git a/documentation/ru/usage-syncstate.md b/documentation/ru/usage-syncstate.md index d80e89e..892fb3c 100644 --- a/documentation/ru/usage-syncstate.md +++ b/documentation/ru/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/documentation/zh-CN/upgrade-to-v3.md b/documentation/zh-CN/upgrade-to-v3.md new file mode 100644 index 0000000..69e39d6 --- /dev/null +++ b/documentation/zh-CN/upgrade-to-v3.md @@ -0,0 +1,83 @@ +# 升级到 AppState 3.0 + +AppState 3.0 围绕 Swift 6 和 Apple 的 Observation 框架构建。以下是重大变更以及如何适配。 + +## 重大变更速览 + +- **平台最低版本提升** — iOS 17、macOS 14、tvOS 17、watchOS 10 +- **Swift 6 严格并发** — 启用 `ExistentialAny`;协议存在类型需显式标注 `any` +- **移除 `ObservableObject`** — `Application` 改用 `@Observable`;`objectWillChange` 已不存在,请改用 `notifyChange()` +- **新增(增量功能):SwiftData 支持** — 为 `@Model` 对象提供 `ModelState` / `@ModelState` + +--- + +## 1. 提升的平台要求 + +| 平台 | 2.x | 3.0 | +| --- | --- | --- | +| iOS | 15.0 | **17.0** | +| macOS | 11.0 | **14.0** | +| tvOS | 15.0 | **17.0** | +| watchOS | 8.0 | **10.0** | +| visionOS | 1.0 | 1.0 | + +Linux 和 Windows 继续支持非苹果平台的功能集。 + +如果你需要支持更旧的操作系统版本,请继续使用 2.x 发布线。 + +## 2. 严格的 Swift 6 + +该包锁定 Swift 6 语言模式(`swiftLanguageModes: [.v6]`),并启用 `ExistentialAny` 即将到来的特性。CI 构建将警告视为错误。 + +大多数应用无需任何更改。如果你实现了 AppState 的任何公共协议 —— `FileManaging`、`UserDefaultsManaging` 或 `UbiquitousKeyValueStoreManaging` —— 你可能需要用显式的 `any` 来书写存在类型: + +```swift +// Before (2.x) +var fileManager: FileManaging + +// After (3.0) +var fileManager: any FileManaging +``` + +## 3. Observation 取代 ObservableObject + +`Application` 现在使用 [`@Observable`](https://developer.apple.com/documentation/observation) 而非 `ObservableObject`。 + +**属性包装器保持不变。** `@AppState`、`@StoredState`、`@FileState`、`@SyncState`、`@SecureState`、`@Slice`、`@OptionalSlice`、`@DependencySlice` 和 `@ModelState` 都继续在 SwiftUI 视图中正常工作。遵循 `ObservableObject` 并承载这些包装器的视图模型仍受支持。 + +变更内容: + +- `Application.shared.objectWillChange` 不再存在。 +- `Application.notifyChange()` 取而代之。AppState 自身的 setter 会自动调用它。 +- 直接读取 `Application.state(_:).value` 现在也会参与 Observation —— 而不仅限于 `@AppState` 包装器。这意味着任何代码(不只是 SwiftUI 视图)都可以观察状态变更: + + ```swift + withObservationTracking { + _ = Application.state(\.counter).value + } onChange: { + // runs when the value changes — no SwiftUI required + } + ``` + +如果你子类化了 `Application` 并手动调用 `objectWillChange.send()`(例如在 `didChangeExternally` 重写中),请将其替换为 `notifyChange()`: + +```swift +class CustomApplication: Application { + override func didChangeExternally(notification: Notification) { + super.didChangeExternally(notification: notification) + + DispatchQueue.main.async { + self.notifyChange() + } + } +} +``` + +> `@ObservedDependency` 保持不变 —— 它仍然观察遵循 `ObservableObject` 的依赖值。 + +## 4. 新增:SwiftData 支持 + +3.0 增加了 SwiftData 集成。将共享的 `ModelContainer` 作为依赖注入,并通过 `ModelState` 读写 `@Model` 对象。这是增量且可选的 —— 参见 [ModelState 用法指南](usage-modelstate.md)。 + +--- +该译文由机器自动生成,可能存在错误。如果您是母语使用者,我们期待您通过 Pull Request 提出修改建议。 diff --git a/documentation/zh-CN/usage-modelstate.md b/documentation/zh-CN/usage-modelstate.md new file mode 100644 index 0000000..900abab --- /dev/null +++ b/documentation/zh-CN/usage-modelstate.md @@ -0,0 +1,207 @@ +# ModelState 用法 + +🍎 `ModelState` 让你通过 AppState 的依赖注入模型来管理 SwiftData 的 `@Model` 对象。只需注册一次共享的 `ModelContainer`;即可在任何地方 —— 视图模型、服务或其他非视图代码 —— 读写模型,而无需将 `ModelContext` 沿调用栈层层传递。 + +> 🍎 `ModelState` 需要支持 SwiftData 的苹果平台(iOS 17+、macOS 14+、tvOS 17+、watchOS 10+、visionOS 1+)。这些 API 在 Linux 和 Windows 上会被编译排除。 + +## 端到端示例 + +```swift +import AppState +import SwiftData +import SwiftUI + +// 1. Define the 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 container and a ModelState on Application. +private func makeModelContainer() -> ModelContainer { + do { + return try ModelContainer(for: TodoItem.self) + } catch { + fatalError("Failed to create 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() + } +} +``` + +## 注册 ModelContainer + +`modelContainer(_:)` 使用自动生成的标识符注册容器,并且只对 autoclosure 求值一次。请在辅助函数中构建容器,而不是内联构建 —— 这能让失败更明确: + +```swift +extension Application { + var modelContainer: Dependency { + modelContainer(makeModelContainer()) + } +} +``` + +## 定义 ModelState + +不提供 `FetchDescriptor` 时,该状态会匹配给定类型的所有模型: + +```swift +extension Application { + var items: ModelState { + modelState(container: \.modelContainer) + } +} +``` + +提供 `FetchDescriptor` 以进行筛选或排序: + +```swift +extension Application { + var items: ModelState { + modelState( + container: \.modelContainer, + fetchDescriptor: FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ), + id: "items" + ) + } +} +``` + +## 读取与修改 + +**通过 `@ModelState`** —— 读取被包装的值,通过 `$items` 进行修改: + +```swift +@ModelState(\.items) var items: [Item] + +func add(_ item: Item) { $items.insert(item) } +func remove(_ item: Item) { $items.delete(item) } +func persist() { $items.save() } +``` + +**通过 `Application.modelState`** —— 在服务和非视图代码中很有用: + +```swift +@MainActor +func syncItems() { + let state = Application.modelState(\.items) + let current = state.models + state.insert(Item(title: "New")) + state.delete(current.first!) + state.save() +} +``` + +> `models` 在每次读取时都会执行一次实时的 SwiftData 抓取。如果需要多次使用,请将结果捕获到局部变量中。 + +### 投影值 API + +| 方法 | 行为 | +| --- | --- | +| `$items.insert(_:)` | 插入一个模型并保存 | +| `$items.delete(_:)` | 删除一个模型并保存 | +| `$items.save()` | 持久化待处理的更改 | +| `$items.deleteAll()` | 删除所有匹配 `FetchDescriptor` 的模型并保存 | + +这些方法会记录并吞掉底层的任何 SwiftData 错误,从而让调用处保持简洁。当你需要暴露或从失败的写入中恢复时,请使用 `strict` 上对应的可抛出版本: + +```swift +do { + try $items.strict.insert(item) + try $items.strict.save() +} catch { + // 呈现错误、回滚、重试…… +} +``` + +`strict` 暴露了全部四个方法(`insert`、`delete`、`save`、`deleteAll`)的可抛出版本,它们基于同一个上下文 —— 当可以接受被记录的失败时,请选择宽松的 API;当调用方必须处理错误时,请使用 `strict`。 + +## 访问 ModelContext + +```swift +let context = Application.modelContext(\.modelContainer) +``` + +返回已解析的 `ModelContainer` 的 `mainContext` —— 即所有读写操作所使用的同一个上下文。 + +## ModelState 与 SwiftData @Query 的对比 + +`ModelState` 的修改**不会**自动广播到 SwiftUI 视图。这是有意为之的设计。 + +- **响应式视图** —— 使用 `@Query`。它直接观察 `ModelContext`,并在数据变化时刷新视图。请将 AppState 提供的容器与 SwiftUI 环境共享,使视图和非视图代码使用同一个存储: + + ```swift + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ItemsView() + } + .modelContainer(Application.dependency(\.modelContainer)) + } + } + + struct ItemsView: View { + @Query(sort: \Item.title) private var items: [Item] + + var body: some View { + List(items) { Text($0.title) } + } + } + ``` + +- **视图模型与服务** —— 使用 `@ModelState` / `Application.modelState`。当 `@Environment` 和 `@Query` 不可用,或需要在视图代码之外执行模型操作时,这是理想之选。 + +## 注意事项 + +- 所有读写都经由容器的 `mainContext` —— 请将使用保持在主 actor 上。 +- `ModelState` 不会在 AppState 自身的缓存中缓存结果。SwiftData 的 `ModelContext` 才是事实来源。 +- 注册单个 `ModelContainer` 依赖,并从所有 model state 和 SwiftUI 环境中引用它。 + +--- +该译文由机器自动生成,可能存在错误。如果您是母语使用者,我们期待您通过 Pull Request 提出修改建议。 diff --git a/documentation/zh-CN/usage-overview.md b/documentation/zh-CN/usage-overview.md index c2a7204..22d8def 100644 --- a/documentation/zh-CN/usage-overview.md +++ b/documentation/zh-CN/usage-overview.md @@ -1,10 +1,10 @@ # 用法概述 -本概述简要介绍了如何在 SwiftUI `View` 中使用 **AppState** 库的关键组件。每个部分都包含适合 SwiftUI 视图结构范围的简单示例。 +本概述快速介绍如何在 SwiftUI `View` 中使用 **AppState** 库的关键组件。每个小节都包含适配于 SwiftUI 视图结构范围内的简单示例。 ## 在 Application 扩展中定义值 -要定义应用程序范围的状态或依赖项,您应该扩展 `Application` 对象。这使您可以将应用程序的所有状态集中在一个地方。以下是如何扩展 `Application` 以创建各种状态和依赖项的示例: +要定义应用范围内的状态或依赖,你应当扩展 `Application` 对象。这能让你把应用的所有状态集中在一处。以下示例展示如何扩展 `Application` 来创建各种状态和依赖: ```swift import AppState @@ -25,7 +25,7 @@ extension Application { var userToken: SecureState { secureState(id: "userToken") } - + @MainActor var largeDataset: FileState<[String]> { fileState(initial: [], filename: "largeDataset") @@ -35,7 +35,7 @@ extension Application { ## State -`State` 允许您定义可在应用程序中任何位置访问和修改的应用程序范围状态。 +`State` 让你定义可在应用任何位置访问和修改的应用范围状态。 ### 示例 @@ -48,8 +48,8 @@ struct ContentView: View { var body: some View { VStack { - Text("你好, \(user.name)!") - Button("登录") { + Text("Hello, \(user.name)!") + Button("Log in") { user.isLoggedIn.toggle() } } @@ -59,7 +59,7 @@ struct ContentView: View { ## StoredState -`StoredState` 使用 `UserDefaults` 持久化状态,以确保值在应用程序启动之间被保存。 +`StoredState` 使用 `UserDefaults` 持久化状态,确保值在应用启动之间被保存。 ### 示例 @@ -72,8 +72,8 @@ struct PreferencesView: View { var body: some View { VStack { - Text("偏好设置: \(userPreferences)") - Button("更新偏好设置") { + Text("Preferences: \(userPreferences)") + Button("Update Preferences") { userPreferences = "Updated Preferences" } } @@ -83,7 +83,7 @@ struct PreferencesView: View { ## SyncState -`SyncState` 使用 iCloud 在多个设备之间同步应用程序状态。 +`SyncState` 使用 iCloud 在多个设备之间同步应用状态。 ### 示例 @@ -96,7 +96,7 @@ struct SyncSettingsView: View { var body: some View { VStack { - Toggle("深色模式", isOn: $isDarkModeEnabled) + Toggle("Dark Mode", isOn: $isDarkModeEnabled) } } } @@ -104,7 +104,7 @@ struct SyncSettingsView: View { ## FileState -`FileState` 用于使用文件系统持久地存储较大或更复杂的数据,使其非常适合缓存或保存不适合 `UserDefaults` 限制的数据。 +`FileState` 用于使用文件系统持久化存储更大或更复杂的数据,非常适合缓存或保存那些超出 `UserDefaults` 限制的数据。 ### 示例 @@ -123,6 +123,46 @@ struct LargeDataView: View { } ``` +## ModelState + +🍎 `ModelState` 通过注入共享的 `ModelContainer`,借助 AppState 管理 SwiftData 的 `@Model` 对象。它适用于视图模型、服务以及其他非视图代码;对于响应式视图,请将 SwiftData 的 `@Query` 与 AppState 提供的 `ModelContainer` 配合使用。SwiftData 功能需要 iOS 17+ / macOS 14+。 + +### 示例 + +```swift +import AppState +import SwiftData + +private func makeItemContainer() -> ModelContainer { + do { + return try ModelContainer(for: Item.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } +} + +extension Application { + var modelContainer: Dependency { + modelContainer(makeItemContainer()) + } + + var items: ModelState { + modelState(container: \.modelContainer) + } +} + +@MainActor +final class ItemsViewModel: ObservableObject { + @ModelState(\.items) var items: [Item] + + func add(_ item: Item) { + $items.insert(item) + } +} +``` + +更多细节请参阅 [ModelState 用法指南](usage-modelstate.md)。 + ## SecureState `SecureState` 将敏感数据安全地存储在钥匙串中。 @@ -139,11 +179,11 @@ struct SecureView: View { var body: some View { VStack { if let token = userToken { - Text("用户令牌: \(token)") + Text("User token: \(token)") } else { - Text("未找到令牌。") + Text("No token found.") } - Button("设置令牌") { + Button("Set Token") { userToken = "secure_token_value" } } @@ -153,7 +193,7 @@ struct SecureView: View { ## Constant -`Constant` 提供对应用程序状态中值的不可变、只读访问,确保在访问不应修改的值时的安全性。 +`Constant` 提供对应用状态中各值的不可变、只读访问,确保在访问那些不应被修改的值时的安全性。 ### 示例 @@ -165,14 +205,14 @@ struct ExampleView: View { @Constant(\.user, \.name) var name: String var body: some View { - Text("用户名: \(name)") + Text("Username: \(name)") } } ``` -## Slicing State +## 切片状态 -`Slice` 和 `OptionalSlice` 允许您访问应用程序状态的特定部分。 +`Slice` 和 `OptionalSlice` 让你访问应用状态的特定部分。 ### 示例 @@ -185,8 +225,8 @@ struct SlicingView: View { var body: some View { VStack { - Text("用户名: \(name)") - Button("更新用户名") { + Text("Username: \(name)") + Button("Update Username") { name = "NewUsername" } } @@ -196,20 +236,21 @@ struct SlicingView: View { ## 最佳实践 -- **在 SwiftUI 视图中使用 `AppState`**:`@AppState`、`@StoredState`、`@FileState`、`@SecureState` 等属性包装器设计用于 SwiftUI 视图的范围内。 -- **在 Application 扩展中定义状态**:通过扩展 `Application` 来定义应用程序的状态和依赖项,从而集中管理状态。 -- **反应式更新**:当状态更改时,SwiftUI 会自动更新视图,因此您无需手动刷新 UI。 -- **[最佳实践指南](best-practices.md)**:有关使用 AppState 时的最佳实践的详细分类。 +- **在 SwiftUI 视图中使用 `AppState`**:诸如 `@AppState`、`@StoredState`、`@FileState`、`@SecureState` 等属性包装器,是为在 SwiftUI 视图范围内使用而设计的。 +- **在 Application 扩展中定义状态**:通过扩展 `Application` 来定义应用的状态和依赖,从而集中管理状态。 +- **响应式更新**:当状态变化时,SwiftUI 会自动更新视图,因此你无需手动刷新 UI。 +- **[最佳实践指南](best-practices.md)**:使用 AppState 时最佳实践的详细分类。 ## 后续步骤 -熟悉基本用法后,您可以探索更高级的主题: +熟悉基本用法后,你可以探索更多进阶主题: -- 在[FileState 用法指南](usage-filestate.md)中探索使用 **FileState** 将大量数据持久化到文件中。 -- 在[常量用法指南](usage-constant.md)中了解 **常量** 以及如何在应用程序状态中使用它们来表示不可变值。 -- 在[状态依赖用法指南](usage-state-dependency.md)中研究 **Dependency** 如何在 AppState 中用于处理共享服务,并查看示例。 -- 在[ObservedDependency 用法指南](usage-observeddependency.md)中更深入地研究 **高级 SwiftUI** 技术,例如使用 `ObservedDependency` 在视图中管理可观察的依赖项。 -- 有关更高级的用法技术,例如即时创建和预加载依赖项,请参阅[高级用法指南](advanced-usage.md)。 +- 在 [FileState 用法指南](usage-filestate.md) 中探索如何使用 **FileState** 将大量数据持久化到文件。 +- 🍎 在 [ModelState 用法指南](usage-modelstate.md) 中了解如何借助 AppState 管理 **SwiftData** 模型。 +- 在 [常量用法指南](usage-constant.md) 中了解 **Constants** 以及如何用它们表示应用状态中的不可变值。 +- 研究 **Dependency** 在 AppState 中如何用于处理共享服务,并在 [状态依赖用法指南](usage-state-dependency.md) 中查看示例。 +- 在 [ObservedDependency 用法指南](usage-observeddependency.md) 中深入探讨 **进阶 SwiftUI** 技术,例如使用 `ObservedDependency` 在视图中管理可观察的依赖。 +- 有关更进阶的用法技术,如即时创建和预加载依赖,请参阅 [高级用法指南](advanced-usage.md)。 --- 该译文由机器自动生成,可能存在错误。如果您是母语使用者,我们期待您通过 Pull Request 提出修改建议。 diff --git a/documentation/zh-CN/usage-syncstate.md b/documentation/zh-CN/usage-syncstate.md index 2f3c7a4..2741f77 100644 --- a/documentation/zh-CN/usage-syncstate.md +++ b/documentation/zh-CN/usage-syncstate.md @@ -57,7 +57,7 @@ class CustomApplication: Application { super.didChangeExternally(notification: notification) DispatchQueue.main.async { - self.objectWillChange.send() + self.notifyChange() } } } diff --git a/fledge.toml b/fledge.toml new file mode 100644 index 0000000..1aa447e --- /dev/null +++ b/fledge.toml @@ -0,0 +1,15 @@ +# fledge.toml — project task definitions +# Docs: https://github.com/CorvidLabs/fledge#task-runner +# Detected project type: swift + +[tasks] +build = "swift build" +test = "swift test" +# Treat warnings as errors to enforce strict Swift 6 cleanliness (mirrors CI). +lint = "swift build -Xswiftc -warnings-as-errors && swift test -Xswiftc -warnings-as-errors" +# Build and run the SwiftData example as a smoke test (Apple platforms only). +example = "swift run --package-path Examples/SwiftDataExample" + +[lanes.ci] +description = "Run the full CI pipeline (strict warnings, tests, example smoke test)" +steps = ["lint", "example"] diff --git a/specs/application/application.spec.md b/specs/application/application.spec.md new file mode 100644 index 0000000..84aa29d --- /dev/null +++ b/specs/application/application.spec.md @@ -0,0 +1,107 @@ +--- +module: application +version: 2 +status: draft +files: + - Sources/AppState/Application/Application.swift + - Sources/AppState/Application/Application+public.swift + - Sources/AppState/Application/Application+internal.swift + +db_tables: [] +depends_on: [] +--- + +# Application + +## Purpose + +The `Application` singleton manages global app state, dependencies, and scoped state containers. It provides a centralized registry for `State` values, secure state (Keychain), stored state (`UserDefaults`), synced state (iCloud), and file-backed state, and it manages dependency injection via the `Dependency` and `DependencySlice` types. + +As of AppState 3.0 the singleton adopts Apple's Observation framework (`@Observable`) instead of `ObservableObject`. State and dependency values still live in an untracked `Cache`; a private observation anchor bridges cache changes to Observation so that SwiftUI views update reactively. + +## Public API + +### Exported Functions + +| Export | Description | +|--------|-------------| +| Application.state(_:) | Retrieve or define a `State` value | +| Application.storedState(_:) | Retrieve or define a `UserDefaults`-backed state | +| Application.secureState(_:) | Retrieve or define a Keychain-backed state (Apple platforms) | +| Application.syncState(_:) | Retrieve or define an iCloud-backed state (Apple platforms) | +| Application.fileState(_:) | Retrieve or define a file-backed state | +| Application.dependency(_:) | Retrieve or define a dependency | +| Application.override(_:with:) | Temporarily override a dependency (previews/tests) | +| Application.promote(to:) | Promote the shared instance to a custom `Application` subclass | +| Application.reset(_:) | Reset a state to its initial value | +| Application.logging(isEnabled:) | Enable or disable AppState's internal logging | + +> SwiftData support (`Application.modelState(_:)`, `Application.modelContext(_:)`) is documented in the `swiftdata` spec. + +### Structs & Enums + +| Type | Description | +|------|-------------| +| Application | `@Observable` singleton managing all app-wide state and dependencies (subclass of `NSObject`) | +| Application.Scope | Scope (name + id) used to derive unique keys for state and dependencies | +| Application.Dependency | Read-only injected dependency value | +| Application.DependencyOverride | Token that reverts a dependency override when released | +| ApplicationLogger | Logging utility used on Linux/Windows | + +### Traits + +| Trait | Description | +|-------|-------------| +| MutableApplicationState | Protocol for state types that expose a mutable `value` and a `reset()` | + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| state | `static func state(_: KeyPath>) -> State` | Access a state value by key path | +| dependency | `static func dependency(_: KeyPath>) -> Value` | Resolve a dependency by key path | +| notifyChange | `func notifyChange()` | Ask observers (SwiftUI views) to update; called by AppState's setters and available for manual use (e.g. in `didChangeExternally` overrides) | + +## Invariants + +1. `Application` must always be a singleton; only one shared instance exists at runtime (`Application.shared`, main-actor isolated). +2. State and dependency values are stored in the `Cache`, which is `@ObservationIgnored`; observation is driven solely through the private change anchor via `registerObservation()` / `notifyChange()`. +3. Reading a value through a property wrapper registers an observation dependency; mutating a value notifies observers exactly once per change. +4. Dependency values are `Sendable`; dependency resolution must never cause retain cycles. +5. The library builds warning-free under the Swift 6 language mode with the `ExistentialAny` upcoming feature and `-warnings-as-errors`. + +## Behavioral Examples + +``` +Given an Application extension defining a state property +When the value is read via @AppState inside a SwiftUI view body +Then an observation dependency is registered for that view +And when the value is mutated, the view is asked to update via notifyChange() +``` + +``` +Given a custom Application subclass overriding didChangeExternally(notification:) +When an iCloud change arrives +Then the subclass calls notifyChange() to refresh SwiftUI views +``` + +## Error Cases + +| Error | When | Behavior | +|-------|------|----------| +| Keychain unavailable | SecureState accessed without entitlements | Returns the initial value; error logged | +| iCloud unavailable | SyncState accessed without iCloud capability | Falls back to the local value | +| Decode failure | Stored/File state data cannot be decoded | Returns the initial value; error logged | + +## Dependencies + +- Cache (0xLeif/Cache) — underlying caching layer for state and dependency values +- Observation (Swift standard library) — `@Observable` reactivity +- Combine (Apple platforms) — bridges the cache's change notifications to the observation anchor + +## Change Log + +| Version | Date | Changes | +|---------|------|---------| +| 1 | 2026-04-21 | Initial spec | +| 2 | 2026-06-09 | AppState 3.0: adopt Observation (`@Observable`); add `notifyChange()`; remove `ObservableObject` conformance; raise platform floors to iOS 17 / macOS 14; pin Swift 6 language mode + `ExistentialAny` | diff --git a/specs/application/context.md b/specs/application/context.md new file mode 100644 index 0000000..16e399b --- /dev/null +++ b/specs/application/context.md @@ -0,0 +1,19 @@ +--- +spec: application.spec.md +--- + +## Context + +`Application` is the heart of AppState: a single, globally shared registry that centralizes state and dependency management so apps can avoid ad-hoc singletons and scattered `@EnvironmentObject` plumbing. State is defined declaratively as computed properties in `Application` extensions and accessed through property wrappers. + +## Related Modules + +- `property-wrappers` — the `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@ModelState`, and slice/dependency wrappers that read and write `Application` state. +- `swiftdata` — the SwiftData `ModelContainer` dependency and `ModelState` built on top of `Application`. + +## Design Decisions + +- **Singleton + key paths.** State and dependencies are addressed by `KeyPath`, giving type-safe, autocomplete-friendly access without string keys. +- **Untracked cache + observation anchor.** Values live in a `Cache` (`@ObservationIgnored`). Because the cache is dynamically keyed, a single private anchor is read on every value access and bumped on every change, bridging the cache to the Observation framework with coarse but reliable view updates. +- **`@Observable` over `ObservableObject` (3.0).** Modernizes reactivity and works cross-platform; `NSObject` is retained so the `@objc` iCloud `didChangeExternally` hook continues to work. +- **Thread-safety via a recursive lock.** Value resolution is guarded so state can be read from any context, while mutation/observation are main-actor bound. diff --git a/specs/application/requirements.md b/specs/application/requirements.md new file mode 100644 index 0000000..027f5f0 --- /dev/null +++ b/specs/application/requirements.md @@ -0,0 +1,27 @@ +--- +spec: application.spec.md +--- + +## User Stories + +- As a developer, I want to define a piece of state once in an `Application` extension and access it anywhere by key path. +- As a developer, I want SwiftUI views to update automatically when shared state changes. +- As a developer, I want to inject and override dependencies for previews and tests. +- As a developer, I want to react to external iCloud changes and refresh my UI. + +## Acceptance Criteria + +- Reading a value through a property wrapper in a SwiftUI view body registers an observation dependency for that view. +- Mutating a value notifies observers so dependent views update. +- Dependency overrides apply for the lifetime of the returned token and revert when it is released. +- The library compiles warning-free under Swift 6 language mode with `ExistentialAny` and `-warnings-as-errors`. + +## Constraints + +- Minimum platforms: iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1; also Linux and Windows for the non-Apple feature set. +- `Application.shared` is main-actor isolated; all dependency values must be `Sendable`. + +## Out of Scope + +- Fine-grained, per-key observation (the anchor intentionally provides coarse, whole-registry change notification). +- Persisting `State` (use `StoredState`/`FileState`/`SyncState`/`ModelState` for persistence). diff --git a/specs/application/tasks.md b/specs/application/tasks.md new file mode 100644 index 0000000..8331f01 --- /dev/null +++ b/specs/application/tasks.md @@ -0,0 +1,12 @@ +--- +spec: application.spec.md +--- + +## Tasks + +- [x] Write spec +- [x] Implement module +- [x] Write tests +- [x] Adopt Observation (`@Observable`) and add `notifyChange()` / `registerObservation()` +- [x] Remove `ObservableObject` conformance +- [x] Raise platform floors and pin Swift 6 language mode + `ExistentialAny` diff --git a/specs/application/testing.md b/specs/application/testing.md new file mode 100644 index 0000000..85f2c05 --- /dev/null +++ b/specs/application/testing.md @@ -0,0 +1,15 @@ +--- +spec: application.spec.md +--- + +## Test Plan + +### Unit Tests + +- `ApplicationTests` — state/dependency definition and resolution by key path; `reset`; `override`/`promote`. +- `ObservationTests` — reading a wrapper registers an observation dependency and mutating the value fires `withObservationTracking`'s `onChange` (the `registerObservation()` / `notifyChange()` bridge); negative case asserts no notification without a mutation. +- `AppDependencyTests` — dependency injection and override lifetime. + +### Integration Tests + +- Reactive view updates (SwiftUI re-rendering on state change) require a real Apple target and are verified manually; CI covers compilation, unit tests, and `-warnings-as-errors`. diff --git a/specs/property-wrappers/context.md b/specs/property-wrappers/context.md new file mode 100644 index 0000000..35e07d0 --- /dev/null +++ b/specs/property-wrappers/context.md @@ -0,0 +1,21 @@ +--- +spec: property-wrappers.spec.md +--- + +## Context + +Property wrappers are how AppState is actually used day-to-day. The `Application` singleton holds the state and dependency registry, but developers rarely touch it directly: instead they declare `@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@ModelState`, `@AppDependency`, `@ObservedDependency`, or one of the slice/constant wrappers, passing a `KeyPath`. The wrapper reads and writes the underlying value and, on Apple platforms, hands SwiftUI a `Binding` so the value can be bound to controls. This keeps call sites terse and type-safe while centralizing storage in `Application`. + +## Related Modules + +- `application` — owns the `Application` singleton, the `Cache`, `MutableApplicationState`, and the `state`/`storedState`/`fileState`/`syncState`/`secureState`/`modelState`/`dependency`/`slice`/`dependencySlice` resolvers plus `registerObservation()` / `notifyChange()` that every wrapper calls. +- `swiftdata` — defines the SwiftData `ModelContainer` dependency and `Application.ModelState` that `@ModelState` projects (`insert`/`delete`/`save`/`deleteAll`, `FetchDescriptor`); the `@ModelState` wrapped value itself is a read-only `[Model]` live fetch. + +## Design Decisions + +- **Key paths over strings.** Each wrapper is addressed by `KeyPath`, giving compile-time-checked, autocomplete-friendly access to declared state and dependencies. +- **Observation instead of `@ObservedObject` (3.0).** State wrappers dropped their stored `@ObservedObject`. They now expose a computed `app` (`Application.shared`) and call `registerObservation()` inside the `wrappedValue` getter, letting SwiftUI track reads through the Observation framework (`Application` is `@Observable`). Setters write through `Application`, which calls `notifyChange()`. This removes per-wrapper Combine machinery and works the same way across all reactive wrappers. +- **`@ObservedDependency` keeps Combine.** Because it intentionally wraps an `ObservableObject` dependency, it still uses `@ObservedObject` and projects an `ObservedObject.Wrapper`. +- **Constants are read-only by construction.** `@Constant`, `@OptionalConstant`, and `@DependencyConstant` expose only a getter and do not conform to `DynamicProperty`, signaling immutable, non-reactive access. +- **Enclosing-instance subscript for view models.** Each reactive wrapper provides the `static subscript(_enclosingInstance:wrapped:storage:)` so it can live inside an `ObservableObject` host and drive that host's `objectWillChange` when written. +- **Platform gating.** `@SyncState`/`@SecureState` are Apple-only; `@ModelState` is SwiftData-only; `@AppState`/`@StoredState`/`@FileState`/`@AppDependency` and the value slices/constants build cross-platform, with `Binding` projected values compiled only on Apple platforms. diff --git a/specs/property-wrappers/property-wrappers.spec.md b/specs/property-wrappers/property-wrappers.spec.md new file mode 100644 index 0000000..ebf3340 --- /dev/null +++ b/specs/property-wrappers/property-wrappers.spec.md @@ -0,0 +1,175 @@ +--- +module: property-wrappers +version: 2 +status: draft +files: + - Sources/AppState/PropertyWrappers/State/AppState.swift + - Sources/AppState/PropertyWrappers/State/StoredState.swift + - Sources/AppState/PropertyWrappers/State/FileState.swift + - Sources/AppState/PropertyWrappers/State/SyncState.swift + - Sources/AppState/PropertyWrappers/State/SecureState.swift + - Sources/AppState/PropertyWrappers/State/ModelState.swift + - Sources/AppState/PropertyWrappers/State/Slice/Slice.swift + - Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift + - Sources/AppState/PropertyWrappers/State/Slice/Constant.swift + - Sources/AppState/PropertyWrappers/State/Slice/OptionalConstant.swift + - Sources/AppState/PropertyWrappers/Dependency/AppDependency.swift + - Sources/AppState/PropertyWrappers/Dependency/ObservedDependency.swift + - Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift + - Sources/AppState/PropertyWrappers/Dependency/Slice/DependencyConstant.swift + +db_tables: [] +depends_on: ["application"] +--- + +# Property-wrappers + +## Purpose + +The property wrappers are AppState's public surface for reading and writing `Application` state and dependencies from SwiftUI views, view models, and other code. Each wrapper is initialized with a `KeyPath` identifying the value it manages, and exposes that value through `wrappedValue` (and, on Apple platforms, a SwiftUI `Binding` or related `projectedValue`). + +There are three families: + +- **State wrappers** (`@AppState`, `@StoredState`, `@FileState`, `@SyncState`, `@SecureState`, `@ModelState`) read and write mutable, optionally persisted application state. +- **Dependency wrappers** (`@AppDependency`, `@ObservedDependency`) resolve injected, read-only dependency values. +- **Slice / Constant wrappers** (`@Slice`, `@OptionalSlice`, `@Constant`, `@OptionalConstant`, `@DependencySlice`, `@DependencyConstant`) provide granular access to a sub-value of a larger state or dependency via a nested key path. + +As of AppState 3.0 the wrappers no longer hold an `@ObservedObject`. Instead each one exposes a computed `app` (`Application.shared`) and, in its `wrappedValue` getter, calls `Application.shared.registerObservation()` so that SwiftUI tracks reads through Apple's Observation framework (`Application` is `@Observable`). Writing through `wrappedValue` mutates the value via `Application`, which calls `notifyChange()` and refreshes dependent views. + +## Public API + +### Exported Functions + +This module exports property wrapper types rather than free functions. The wrappers below are the public API; their `wrappedValue` getters call `Application.shared.registerObservation()` and their setters write through `Application`. + +### Structs & Enums + +#### State wrappers + +| Wrapper | wrappedValue | projectedValue (Apple) | Platforms | Notes | +|---------|--------------|------------------------|-----------|-------| +| `@AppState` | `Value` | `Binding` | All | In-memory `State`; `ApplicationState: MutableApplicationState`, `ApplicationState.Value == Value` | +| `@StoredState` | `Value` | `Binding` | All | `UserDefaults`-backed; `Value: Codable & Sendable` | +| `@FileState` | `Value` | `Binding` | All | `FileManager`-backed; `Value: Codable & Sendable` | +| `@SyncState` | `Value` | `Binding` | Apple only | iCloud `NSUbiquitousKeyValueStore`-backed; `Value: Codable & Sendable`; `@available(watchOS 9.0, *)` | +| `@SecureState` | `String?` | `Binding` | Apple only | Keychain-backed | +| `@ModelState` | `[Model]` (read-only) | `Application.ModelState` | SwiftData (`canImport(SwiftData)`) | `Model: PersistentModel`; wrapped value is read-only (live fetch); mutate via the projected value's `insert`/`delete`/`save`/`deleteAll` | + +#### Dependency wrappers + +| Wrapper | wrappedValue | projectedValue (Apple) | Platforms | Notes | +|---------|--------------|------------------------|-----------|-------| +| `@AppDependency` | `Value` | none | All | Read-only resolved dependency; `Value: Sendable` | +| `@ObservedDependency` | `Value` | `ObservedObject.Wrapper` | Apple only | `Value: Sendable & ObservableObject`; wraps the dependency in `@ObservedObject` | + +#### Slice / Constant wrappers + +| Wrapper | wrappedValue | projectedValue (Apple) | Mutable | Notes | +|---------|--------------|------------------------|---------|-------| +| `@Slice` | `SliceValue` | `Binding` | Yes | `SlicedState.Value == Value`; sub-value via `WritableKeyPath` | +| `@OptionalSlice` | `SliceValue?` | `Binding` | Yes | `SlicedState.Value == Value?`; init from a `WritableKeyPath` to `SliceValue` or `SliceValue?` | +| `@Constant` | `SliceValue` | none | No (read-only) | Read-only slice; accepts a `KeyPath` or `WritableKeyPath` to the sub-value | +| `@OptionalConstant` | `SliceValue?` | none | No (read-only) | `SlicedState.Value == Value?`; read-only optional slice | +| `@DependencySlice` | `SliceValue` | `Binding` | Yes | Slices a dependency; `Value, SliceValue: Sendable`; setter calls `notifyChange()` (Apple) | +| `@DependencyConstant` | `SliceValue` | none | No (read-only) | Read-only slice of a dependency | + +### Traits + +| Trait | Description | +|-------|-------------| +| `DynamicProperty` (SwiftUI) | On Apple platforms every state and slice wrapper, plus `@SyncState`, `@SecureState`, `@ModelState`, `@ObservedDependency`, and `@DependencySlice`, conforms to `DynamicProperty` so SwiftUI installs them as view storage. `@AppDependency`, `@Constant`, `@OptionalConstant`, and `@DependencyConstant` do not. | +| `MutableApplicationState` | Generic bound for `@AppState`, `@Slice`, `@OptionalSlice`, `@Constant`, and `@OptionalConstant`; the backing state exposes a mutable `value`. Defined in the `application` module. | + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `wrappedValue` get | `var wrappedValue: Value { get }` | Calls `app.registerObservation()` then returns the value resolved from `Application` (e.g. `Application.state(_:)`, `Application.slice(_:_:)`, `Application.dependency(_:)`) | +| `wrappedValue` set | `nonmutating set` | Logs the change, then mutates via `app.value(keyPath:)`, writing the new value back through `Application` (which triggers `notifyChange()`) | +| `projectedValue` | `var projectedValue: Binding` (Apple) | A `Binding` whose get/set forward to `wrappedValue`; `@ModelState` instead projects `Application.ModelState`, `@ObservedDependency` projects `ObservedObject.Wrapper` | +| enclosing-instance subscript | `static subscript(_enclosingInstance:wrapped:storage:)` | Supports use inside an `ObservableObject` host (view model): reads forward to `wrappedValue`; writes send the host's `objectWillChange` then write through (Apple platforms; not on `@Constant`/`@DependencyConstant`/`@AppDependency`) | + +## Invariants + +1. Every wrapper is constructed with a `KeyPath` and resolves all values through `Application.shared`; the wrapper holds no copy of the value itself. +2. Reading `wrappedValue` of a reactive wrapper calls `Application.shared.registerObservation()` exactly once per access, registering an Observation dependency so SwiftUI re-renders when the value changes. +3. Wrappers no longer hold `@ObservedObject` for state (3.0); reactivity flows through Observation, not Combine `objectWillChange`. (`@ObservedDependency` is the sole exception and intentionally wraps an `ObservableObject`.) +4. Mutating `wrappedValue` writes through `Application`, which calls `notifyChange()` so observers update; `@Constant`, `@OptionalConstant`, and `@DependencyConstant` are read-only and expose no setter. +5. `@SyncState` and `@SecureState` compile only on Apple platforms (`!os(Linux) && !os(Windows)`); `@ModelState` compiles only where `canImport(SwiftData)`. `@AppState`, `@StoredState`, `@FileState`, `@AppDependency`, and the value slices/constants build cross-platform. +6. On Apple platforms each reactive wrapper conforms to `DynamicProperty`; the `Binding` projected value's get and set both round-trip through `wrappedValue`. +7. The enclosing-instance subscript only sends `objectWillChange` when the host's publisher is an `ObservableObjectPublisher`; otherwise the write is skipped. +8. `Value` (and `SliceValue`) of dependency wrappers is `Sendable`; `Value` of `@StoredState`/`@FileState`/`@SyncState` is `Codable & Sendable`. + +## Behavioral Examples + +``` +Given an Application extension defines `var counter: State` +When a SwiftUI view reads `@AppState(\.counter) var counter` in its body +Then the getter calls Application.shared.registerObservation() +And an Observation dependency is registered for that view +And when `counter` is later mutated, Application.notifyChange() asks the view to update +``` + +``` +Given `@StoredState(\.username) var username` +When the view writes `username = "ada"` +Then the setter logs the change +And writes the new value through Application.value(keyPath:), persisting it to UserDefaults +And dependent views refresh via notifyChange() +``` + +``` +Given a struct `Settings` with `var volume: Double` stored in `State` +When a view uses `@Slice(\.settings, \.volume) var volume` +Then reading `volume` registers an Observation dependency and returns settings.volume +And writing `volume = 0.5` mutates only the `volume` sub-value of the backing Settings state +``` + +``` +Given `@ModelState(\.todos) var todos` backed by a SwiftData ModelContainer dependency +When the view reads `todos` +Then a FetchDescriptor fetch returns the matching [Todo] (the wrapped value is read-only; it cannot be assigned) +And `$todos.insert(newTodo)` / `$todos.delete(todo)` / `$todos.save()` / `$todos.deleteAll()` mutate the backing context via the projected value +And (note) these mutations are not auto-broadcast to SwiftUI; use @Query for reactive views +``` + +``` +Given a dependency `Value: ObservableObject` injected at `\.session` +When a view uses `@ObservedDependency(\.session) var session` +Then the wrapper resolves the dependency once at init and wraps it in @ObservedObject +And the view updates when the dependency itself publishes objectWillChange +``` + +``` +Given a wrapper used inside an ObservableObject view model +When the view model writes through the wrapper's enclosing-instance subscript +Then the host's objectWillChange publisher is sent (if it is an ObservableObjectPublisher) +And the value is written through to Application +``` + +## Error Cases + +| Error | When | Behavior | +|-------|------|----------| +| Keychain unavailable / missing entitlement | `@SecureState` accessed without Keychain access | `Application` returns the initial value; error logged (handled in the `application` module) | +| iCloud unavailable | `@SyncState` accessed without iCloud capability | Falls back to the local value | +| Decode failure | `@StoredState` / `@FileState` data cannot be decoded | Returns the initial value; error logged | +| SwiftData fetch/save failure | `@ModelState` read or `insert`/`delete`/`save`/`deleteAll` fails | Surfaced by `Application.ModelState`; see the `swiftdata` spec | +| `nil` parent in optional slice | `@OptionalSlice` whose backing `Value?` is `nil` | Getter returns `nil`; setter is a no-op against the missing parent | +| Non-`ObservableObjectPublisher` host | Enclosing-instance subscript set on a host without an `ObservableObjectPublisher` | Write is skipped (guard returns) | + +## Dependencies + +- `application` (this repo) — all wrappers resolve values through `Application` (`Application.state/storedState/fileState/syncState/secureState/modelState/dependency/slice/dependencySlice`, `registerObservation()`, `notifyChange()`, `MutableApplicationState`). +- SwiftUI (Apple platforms) — `DynamicProperty`, `Binding`, `ObservedObject` for projected values and reactive installation. +- Observation (Swift standard library) — the `@Observable` `Application` drives view updates via `registerObservation()`. +- Combine (Apple platforms) — imported for `ObservableObjectPublisher` used by the enclosing-instance subscript. +- SwiftData (`canImport(SwiftData)`) — `@ModelState` only; `PersistentModel`, `ModelContainer`, `FetchDescriptor`. See the `swiftdata` spec for details. + +## Change Log + +| Version | Date | Changes | +|---------|------|---------| +| 1 | 2026-04-21 | Initial spec | +| 2 | 2026-06-09 | Author full spec; Observation-based reactivity; add `@ModelState` | +| 2 | 2026-06-09 | `@ModelState` wrapped value is read-only `[Model]` (live fetch); mutate via the projected value's `insert`/`delete`/`save`/`deleteAll` (no wrapped-value assignment) | diff --git a/specs/property-wrappers/requirements.md b/specs/property-wrappers/requirements.md new file mode 100644 index 0000000..0db6f66 --- /dev/null +++ b/specs/property-wrappers/requirements.md @@ -0,0 +1,35 @@ +--- +spec: property-wrappers.spec.md +--- + +## User Stories + +- As a developer, I want to declare `@AppState(\.keyPath)` in a SwiftUI view and have the view re-render automatically when the underlying `Application` value changes. +- As a developer, I want a `Binding` projected value (`$value`) so I can wire shared state directly to SwiftUI controls. +- As a developer, I want persisted variants (`@StoredState`, `@FileState`, `@SyncState`, `@SecureState`) that behave like `@AppState` but write through to `UserDefaults`, the file system, iCloud, or the Keychain. +- As a developer, I want to slice a sub-value of a larger state (`@Slice`, `@OptionalSlice`) or expose it read-only (`@Constant`, `@OptionalConstant`). +- As a developer, I want to resolve injected dependencies (`@AppDependency`, `@ObservedDependency`) and slice them (`@DependencySlice`, `@DependencyConstant`). +- As a developer, I want to read (read-only) and mutate SwiftData models from non-view code via `@ModelState`, with `insert`/`delete`/`save`/`deleteAll` on its projected value (the wrapped value cannot be assigned). +- As a developer, I want these wrappers to work inside an `ObservableObject` view model and drive its `objectWillChange`. + +## Acceptance Criteria + +- Each wrapper is constructed from a `KeyPath` and resolves all values through `Application.shared`; it stores no copy of the value. +- Reading `wrappedValue` of a reactive wrapper calls `Application.shared.registerObservation()`, registering an Observation dependency so SwiftUI re-renders on change. +- Mutating `wrappedValue` writes through `Application` (`app.value(keyPath:)`), which triggers `notifyChange()`; `@Constant`, `@OptionalConstant`, and `@DependencyConstant` expose no setter. +- On Apple platforms, reactive wrappers conform to `DynamicProperty` and provide a `Binding` projected value whose get/set round-trip through `wrappedValue`. +- The enclosing-instance subscript sends the host's `objectWillChange` only when its publisher is an `ObservableObjectPublisher`, then writes through. + +## Constraints + +- `@SyncState` and `@SecureState` compile only on Apple platforms (`!os(Linux) && !os(Windows)`); `@SyncState` is additionally `@available(watchOS 9.0, *)`. +- `@ModelState` compiles only where `canImport(SwiftData)`. +- `@AppState`, `@StoredState`, `@FileState`, `@AppDependency`, and the value slices/constants build cross-platform; their `Binding` projected values and enclosing-instance subscripts are compiled only on Apple platforms. +- `@StoredState`/`@FileState`/`@SyncState` require `Value: Codable & Sendable`; dependency wrappers require `Value` (and `SliceValue`) `Sendable`; `@ModelState` requires `Model: PersistentModel`. +- All `wrappedValue` access and mutation is `@MainActor` isolated. + +## Out of Scope + +- The storage mechanics themselves (UserDefaults/file/iCloud/Keychain encoding, cache, observation anchor) — owned by the `application` module. +- SwiftData `ModelContainer` setup and `Application.ModelState` internals — owned by the `swiftdata` spec. +- Automatic broadcast of `@ModelState` mutations to SwiftUI (use `@Query` for reactive views). diff --git a/specs/property-wrappers/tasks.md b/specs/property-wrappers/tasks.md new file mode 100644 index 0000000..8b952f2 --- /dev/null +++ b/specs/property-wrappers/tasks.md @@ -0,0 +1,14 @@ +--- +spec: property-wrappers.spec.md +--- + +## Tasks + +- [x] Write spec +- [x] Implement module +- [x] Write tests +- [x] Adopt Observation-based reactivity: computed `app` + `registerObservation()` in getters; remove stored `@ObservedObject` from state wrappers +- [x] Route mutations through `Application` (`app.value(keyPath:)`) so setters trigger `notifyChange()` +- [x] Add `@ModelState` (SwiftData) with a read-only wrapped value and `insert`/`delete`/`save`/`deleteAll` on its projected value +- [x] Provide `Binding` projected values and the enclosing-instance subscript on Apple platforms +- [x] Gate `@SyncState`/`@SecureState` to Apple platforms and `@ModelState` to `canImport(SwiftData)` diff --git a/specs/property-wrappers/testing.md b/specs/property-wrappers/testing.md new file mode 100644 index 0000000..582b7ad --- /dev/null +++ b/specs/property-wrappers/testing.md @@ -0,0 +1,23 @@ +--- +spec: property-wrappers.spec.md +--- + +## Test Plan + +### Unit Tests + +- `AppStateTests` — `@AppState` reads/writes through `Application`; property-wrapper round-trips; behavior across different value types; logging toggle. +- `StoredStateTests` — `@StoredState` persists to and reads back from `UserDefaults`; default/reset behavior. +- `FileStateTests` — `@FileState` persists to and reads back from the file system via `FileManager`. +- `SyncStateTests` — `@SyncState` round-trips through the iCloud key-value store (Apple platforms). +- `SecureStateTests` — `@SecureState` round-trips a `String?` through the Keychain (Apple platforms). +- `SliceTests` — `Application.slice(_:_:)` and the `@Slice` property wrapper read and write a sub-value of a backing state. +- `OptionalSliceTests` — `@OptionalSlice` get/set against a `nil` and non-`nil` parent, for both `WritableKeyPath` and `WritableKeyPath` initializers. +- `DependencySliceTests` — `Application.dependencySlice(_:_:)` and `@DependencySlice` read and mutate a sub-value of a dependency. +- `ModelStateTests` — `@ModelState` read-only fetch via `FetchDescriptor` (including predicates), projected-value insert/read (`$x.insert`), projected-value CRUD (`insert`/`delete`/`save`), `modelContext` dependency, and `deleteAll`. +- `ObservedDependencyTests` — `@ObservedDependency` resolves an `ObservableObject` dependency and exposes it plus its `$`-projected `ObservedObject.Wrapper`. +- `ObservationTests` — reading a wrapper registers an Observation dependency and mutating the value fires the `registerObservation()` / `notifyChange()` bridge (`testMutatingStateNotifiesObservers`); negative case asserts no notification without a tracked mutation (`testReadingWithoutTrackedMutationDoesNotNotify`). + +### Integration Tests + +- Reactive SwiftUI view updates (re-render on state change, `Binding` two-way flow, enclosing-instance subscript driving an `ObservableObject` host) require a real Apple target and are verified manually; CI covers compilation, unit tests, and `-warnings-as-errors`. diff --git a/specs/swiftdata/context.md b/specs/swiftdata/context.md new file mode 100644 index 0000000..9dd9c3d --- /dev/null +++ b/specs/swiftdata/context.md @@ -0,0 +1,22 @@ +--- +spec: swiftdata.spec.md +--- + +## Context + +This module bridges Apple's SwiftData persistence framework into AppState. Apps already use `Application` to register dependencies and scoped state by key path; the SwiftData integration extends that model so a `ModelContainer` becomes a regular dependency and collections of `@Model` objects become an `Application.ModelState` accessible through the same key-path conventions and through the `@ModelState` property wrapper. + +The integration deliberately layers on top of existing primitives rather than introducing a parallel storage system: there is no new cache, no new persistence path, and no string keys. SwiftData's `ModelContext` remains the source of truth. + +## Related Modules + +- `application` — provides the dependency system (`Dependency`, `Application.dependency`), `Scope`, observation hooks, and logging that this module builds on. +- `property-wrappers` — the `@ModelState` wrapper sits alongside `@AppState`, `@StoredState`, `@SyncState`, `@SecureState`, and `@FileState`. + +## Design Decisions + +- **`ModelContainer` as a plain dependency.** `ModelContainer` is `Sendable`, so it is registered with the ordinary `Application.dependency` machinery via the `modelContainer(_:)` convenience instead of a bespoke storage type. +- **`ModelContext` is the source of truth.** `ModelState` does not store model values in AppState's `Cache`. Every `models` read performs a live `FetchDescriptor` fetch and every mutation writes straight to the container's `mainContext`, avoiding cache/store divergence. +- **Main-actor isolation.** SwiftData's `mainContext` is main-actor bound, so `modelContext`, `ModelState.context`, and all reads/writes are `@MainActor`. +- **Not auto-reactive.** Mutations are not broadcast to SwiftUI. The wrapper registers an observation dependency on read for view-model ergonomics, but reactive views are expected to use SwiftData's `@Query` against the AppState-provided container. `ModelState` targets view models, services, and non-view code that needs shared, dependency-injected model access. +- **Compiled out off-Apple.** Everything is wrapped in `#if canImport(SwiftData)` so Linux and Windows builds are unaffected. diff --git a/specs/swiftdata/requirements.md b/specs/swiftdata/requirements.md new file mode 100644 index 0000000..eb72b52 --- /dev/null +++ b/specs/swiftdata/requirements.md @@ -0,0 +1,30 @@ +--- +spec: swiftdata.spec.md +--- + +## User Stories + +- As a developer, I want to register a SwiftData `ModelContainer` as an AppState dependency and resolve its `ModelContext` anywhere, including in view models and services. +- As a developer, I want to define a collection of `@Model` objects once on an `Application` extension and access it by key path through `@ModelState`. +- As a developer, I want to insert, delete, fetch, save, and delete-all persisted models through a simple, dependency-injected API. +- As a developer, I want a custom `FetchDescriptor` (filtering/sorting) to shape what a `ModelState` exposes. + +## Acceptance Criteria + +- `Application.modelContext(\.container)` returns the backing container's `mainContext`, and repeated calls return the same context. +- Reading `ModelState.models` performs a live fetch using the state's `FetchDescriptor`; an empty result returns `[]`. `models` is read-only. +- `insert(_:)`, `delete(_:)`, and `save()` persist through the container's `mainContext`. +- `ModelState.deleteAll()` deletes every model matching the `FetchDescriptor` and saves, after which `models` is empty. +- A `ModelState` configured with a sorting `FetchDescriptor` returns models in the specified order. + +## Constraints + +- Available only where `canImport(SwiftData)` holds; minimum platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. +- `ModelContext` access and all `ModelState` reads/writes are `@MainActor` isolated. +- `ModelContainer` must be `Sendable` (it is) to register as an AppState dependency. + +## Out of Scope + +- Automatic broadcasting of model mutations to SwiftUI (use SwiftData's `@Query` for reactive views). +- Caching of fetched model values in AppState's `Cache` (the `ModelContext` is the source of truth). +- Assigning to `ModelState.models` or the `@ModelState` wrapped value (both are read-only; mutate via `insert(_:)`/`delete(_:)`/`deleteAll()`). diff --git a/specs/swiftdata/swiftdata.spec.md b/specs/swiftdata/swiftdata.spec.md new file mode 100644 index 0000000..ae84b59 --- /dev/null +++ b/specs/swiftdata/swiftdata.spec.md @@ -0,0 +1,112 @@ +--- +module: swiftdata +version: 2 +status: draft +files: + - Sources/AppState/Application/Types/Dependency/Application+ModelContainer.swift + - Sources/AppState/Application/Types/State/Application+ModelState.swift + - Sources/AppState/PropertyWrappers/State/ModelState.swift +db_tables: [] +depends_on: ["application", "property-wrappers"] +--- + +# SwiftData + +## Purpose + +This module integrates Apple's SwiftData persistence framework with AppState's dependency and state system. It lets an app register a SwiftData `ModelContainer` as a normal AppState `Dependency`, resolve its main-actor `ModelContext` anywhere, and expose collections of `@Model` objects through a dependency-injected `Application.ModelState` and the `@ModelState` property wrapper. + +`ModelContainer` is `Sendable`, so it is stored as an ordinary AppState `Dependency` rather than requiring special handling. `ModelState` reads through a live fetch and mutates through that container's `mainContext`; SwiftData's `ModelContext` — not AppState's `Cache` — is the source of truth. Because mutations are not automatically broadcast to SwiftUI, `ModelState` is intended for view models, services, and other non-view code; reactive views should use SwiftData's own `@Query` against the AppState-provided container. + +The entire module is gated behind `#if canImport(SwiftData)` and requires iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. On platforms without SwiftData (Linux, Windows) it is compiled out entirely. + +## Public API + +### Application — SwiftData functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| modelContext | `static func modelContext(_: KeyPath>, …) -> ModelContext` | Resolves the `ModelContainer` dependency and returns its `mainContext` (main-actor isolated) | +| modelContainer | `func modelContainer(_: @autoclosure () -> ModelContainer, …) -> Dependency` | Registration convenience that defines a `Dependency` with a call-site-derived id; the autoclosure is evaluated once on first access | +| modelState | `static func modelState(_: KeyPath>, …) -> ModelState` | Retrieves a defined `ModelState` by key path | +| modelState | `func modelState(container:fetchDescriptor:feature:id:) -> ModelState` | Defines a `ModelState` backed by a container dependency, scoped by `feature`/`id`, using an explicit `FetchDescriptor` | +| modelState | `func modelState(container:feature:id:) -> ModelState` | Defines a `ModelState` that fetches all models of the type (default `FetchDescriptor`) | +| modelState | `func modelState(container:fetchDescriptor:…) -> ModelState` | Defines a `ModelState` with a call-site-derived id and an explicit `FetchDescriptor` | +| modelState | `func modelState(container:…) -> ModelState` | Defines a `ModelState` with a call-site-derived id that fetches all models of the type | + +### Application.ModelState<Model: PersistentModel> + +A `struct` with `emoji == "🗃️"`. It does not conform to `MutableApplicationState` and has no `Value` typealias. All members below are `@MainActor`. + +| Member | Signature | Description | +|--------|-----------|-------------| +| models (get) | `var models: [Model] { get }` | Read-only; performs a live fetch of models matching the state's `FetchDescriptor` on every read; returns `[]` and logs on failure. No setter. | +| context | `var context: ModelContext` | The `mainContext` of the backing `ModelContainer` dependency | +| insert | `func insert(_ model: Model)` | Inserts a model into the context and saves | +| delete | `func delete(_ model: Model)` | Deletes a model from the context and saves | +| save | `func save()` | Persists pending changes (no-op when `context.hasChanges` is false) | +| deleteAll | `func deleteAll()` | Fetches every model matching the `FetchDescriptor`, deletes each, and saves | + +### @ModelState property wrapper + +A `@propertyWrapper` (also `DynamicProperty`) initialized with a `KeyPath>`. + +| Member | Type | Description | +|--------|------|-------------| +| wrappedValue (get) | `[Model]` | Read-only; registers an observation dependency, then returns the backing `ModelState.models` (a live fetch). No setter — the wrapped value cannot be assigned. | +| projectedValue | `Application.ModelState` | The underlying `ModelState`, exposing `insert(_:)`, `delete(_:)`, `save()`, and `deleteAll()` | + +## Invariants + +1. The module only exists where `canImport(SwiftData)` holds (Apple platforms at iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1); it is fully compiled out elsewhere. +2. `ModelContainer` is registered as a standard `Dependency` because it is `Sendable`; the same container resolves to one shared, main-actor `mainContext`. +3. SwiftData's `ModelContext` is the single source of truth. `ModelState` never caches model values in AppState's `Cache`; every `models` read performs a live fetch. +4. All `ModelState` reads, mutations, and the resolution of `modelContext`/`context` are `@MainActor` isolated. +5. `ModelState.models` is read-only and has no setter; mutations happen exclusively through `insert(_:)`, `delete(_:)`, `save()`, and `deleteAll()`. `ModelState` does not conform to `MutableApplicationState`. +6. Saving is conditional on `context.hasChanges`; `save` is a no-op when there are no pending changes. +7. Mutations through `ModelState` are not automatically broadcast to SwiftUI; reactive views must use SwiftData's `@Query` against the AppState-provided container. + +## Behavioral Examples + +``` +Given an Application extension defining `modelContainer` via Application.modelContainer(try! ModelContainer(for: Item.self)) +When Application.modelContext(\.modelContainer) is called more than once +Then the same main-actor ModelContext (the container's mainContext) is returned each time +``` + +``` +Given a ModelState defined as `modelState(container: \.modelContainer)` +When insert(_:) is called with a new Item and then models is read +Then the Item is persisted through the container's mainContext +And the subsequent fetch returns an array containing that Item +``` + +``` +Given a ModelState holding several persisted models +When deleteAll() is called on the ModelState (e.g. via $items.deleteAll()) +Then every model matching the state's FetchDescriptor is deleted and saved +And a following read of models returns an empty array +``` + +## Error Cases + +| Error | When | Behavior | +|-------|------|----------| +| Fetch failure | `context.fetch(...)` throws while reading `models` or during `deleteAll()` | Error is logged via `Application.log`; `models` returns `[]` | +| Save failure | `context.save()` throws on insert/delete/save/deleteAll | Error is logged via `Application.log`; the operation otherwise completes | +| Empty result | No models match the `FetchDescriptor` | `models` returns an empty array `[]` (not an error) | +| No pending changes | `save()` invoked with `context.hasChanges == false` | No-op; nothing is written | + +## Dependencies + +- SwiftData (Apple) — `ModelContainer`, `ModelContext`, `FetchDescriptor`, `PersistentModel`/`@Model`. +- AppState `Application` (`application` spec) — the dependency system (`Dependency`, `Application.dependency(_:)`), `Scope`, `registerObservation()`, and `Application.log`. +- AppState property wrappers (`property-wrappers` spec) — the `@ModelState` wrapper composes with the wider wrapper family. +- SwiftUI / Combine — `@ModelState` conforms to `DynamicProperty` and bridges to `ObservableObjectPublisher` for view-model use. + +## Change Log + +| Version | Date | Changes | +|---------|------|---------| +| 1 | 2026-06-09 | Initial spec: SwiftData ModelContainer dependency + ModelState | +| 2 | 2026-06-09 | models is read-only; deleteAll() replaces reset(); dropped MutableApplicationState conformance | diff --git a/specs/swiftdata/tasks.md b/specs/swiftdata/tasks.md new file mode 100644 index 0000000..775ee88 --- /dev/null +++ b/specs/swiftdata/tasks.md @@ -0,0 +1,12 @@ +--- +spec: swiftdata.spec.md +--- + +## Tasks + +- [x] Write spec +- [x] Add `ModelContainer` dependency support (`Application.modelContext(_:)` + `modelContainer(_:)` registration convenience) +- [x] Implement `Application.ModelState` (read-only `models`, `context`, `insert`, `delete`, `save`, `deleteAll`) and the `modelState(...)` factories/accessors +- [x] Implement the `@ModelState` property wrapper (read-only wrappedValue + projected value exposing `insert`/`delete`/`save`/`deleteAll`) +- [x] Gate the module behind `#if canImport(SwiftData)` with the iOS 17 / macOS 14 platform floor +- [x] Write tests (`ModelStateTests`) diff --git a/specs/swiftdata/testing.md b/specs/swiftdata/testing.md new file mode 100644 index 0000000..f4512e8 --- /dev/null +++ b/specs/swiftdata/testing.md @@ -0,0 +1,22 @@ +--- +spec: swiftdata.spec.md +--- + +## Test Plan + +### Unit Tests + +`ModelStateTests` (`Tests/AppStateTests/ModelStateTests.swift`), using an in-memory `ModelContainer` (`ModelConfiguration(isStoredInMemoryOnly: true)`) over a `TestItem` `@Model`: + +- `testModelContextDependency` — `Application.modelContext(\.modelContainer)` returns the same context across calls; direct insert/save/fetch through that context round-trips. +- `testInsertAndFetchThroughApplication` — `Application.modelState(\.items)` starts empty, and `insert(_:)` followed by reading `models` returns the persisted models. +- `testPropertyWrapperReadAndProjectedInsert` — reading a `@ModelState` wrapped value reflects models inserted elsewhere, and `$items.insert(_:)` inserts and saves; works from both a value type and an `ObservableObject` view model. +- `testProjectedValueCRUD` — `$items.insert`, `$items.delete`, and `$items.save` perform create/delete/update through the projected `ModelState`. +- `testDeleteAll` — after inserting several models, `$items.deleteAll()` empties the state. +- `testFetchDescriptorSorting` — a `ModelState` configured with a sorting `FetchDescriptor` returns models in ascending order. + +`setUp`/`tearDown` call `deleteAll()` on `\.items` and assert the state is empty, keeping each test isolated against the shared in-memory store. + +### Integration Tests + +- Reactive `@Query`-driven view updates require a real Apple target and are verified manually. CI covers compilation (Apple platforms), the unit tests above, and `-warnings-as-errors`. On Linux/Windows the module and its tests are compiled out via `#if canImport(SwiftData)`.