Skip to content

Commit 2493e5a

Browse files
feat: Implement safe SwiftData-driven AppData state
1 parent 2a2a8f9 commit 2493e5a

File tree

8 files changed

+613
-0
lines changed

8 files changed

+613
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
- **State**: Centralized state management that allows you to encapsulate and broadcast changes across the app.
3030
- **StoredState**: Persistent state using `UserDefaults`, ideal for saving small amounts of data between app launches.
3131
- **FileState**: Persistent state stored using `FileManager`, useful for storing larger amounts of data securely on disk.
32+
- 🍎 **AppData**: Type-safe state management driven by SwiftData, perfect for complex data models.
3233
- 🍎 **SyncState**: Synchronize state across multiple devices using iCloud, ensuring consistency in user preferences and settings.
3334
- 🍎 **SecureState**: Store sensitive data securely using the Keychain, protecting user information such as tokens or passwords.
3435
- **Dependency Management**: Inject dependencies like network services or database clients across your app for better modularity and testing.
@@ -83,6 +84,7 @@ Here’s a detailed breakdown of **AppState**'s documentation:
8384
- [Slicing State](documentation/usage-slice.md): Access and modify specific parts of the state.
8485
- [StoredState Usage Guide](documentation/usage-storedstate.md): How to persist lightweight data using `StoredState`.
8586
- [FileState Usage Guide](documentation/usage-filestate.md): Learn how to persist larger amounts of data securely on disk.
87+
- [AppData Usage Guide](documentation/usage-appdata.md): How to manage state with SwiftData using `AppData`.
8688
- [Keychain SecureState Usage](documentation/usage-securestate.md): Store sensitive data securely using the Keychain.
8789
- [iCloud Syncing with SyncState](documentation/usage-syncstate.md): Keep state synchronized across devices using iCloud.
8890
- [FAQ](documentation/faq.md): Answers to common questions when using **AppState**.

Sources/AppState/Application/Application+public.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,68 @@ extension Application {
13011301
}
13021302
}
13031303

1304+
// MARK: - AppData Functions
1305+
1306+
#if !os(Linux) && !os(Windows)
1307+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
1308+
public extension Application {
1309+
/// Resets an `AppData` instance to its initial value, deleting the backing model from the `ModelContainer`.
1310+
@MainActor
1311+
static func reset<Value: AppDataModel>(
1312+
appData keyPath: KeyPath<Application, AppData<Value>>,
1313+
_ fileID: StaticString = #fileID,
1314+
_ function: StaticString = #function,
1315+
_ line: Int = #line,
1316+
_ column: Int = #column
1317+
) {
1318+
log(
1319+
debug: "💾 Resetting AppData \(String(describing: keyPath))",
1320+
fileID: fileID,
1321+
function: function,
1322+
line: line,
1323+
column: column
1324+
)
1325+
1326+
var appData = shared.value(keyPath: keyPath)
1327+
appData.reset()
1328+
}
1329+
1330+
/// Retrieves an `AppData<Value>` instance from the shared `Application` using its `KeyPath`.
1331+
@MainActor
1332+
static func appData<Value: AppDataModel>(
1333+
_ keyPath: KeyPath<Application, AppData<Value>>,
1334+
_ fileID: StaticString = #fileID,
1335+
_ function: StaticString = #function,
1336+
_ line: Int = #line,
1337+
_ column: Int = #column
1338+
) -> AppData<Value> {
1339+
let appData = shared.value(keyPath: keyPath)
1340+
1341+
log(
1342+
debug: "💾 Getting AppData \(String(describing: keyPath)) -> \(appData.value)",
1343+
fileID: fileID,
1344+
function: function,
1345+
line: line,
1346+
column: column
1347+
)
1348+
1349+
return appData
1350+
}
1351+
1352+
/// Defines and retrieves an `AppData<Value>` instance, backed by SwiftData.
1353+
@MainActor
1354+
func appData<Value: AppDataModel>(
1355+
initial: @escaping @autoclosure () -> Value,
1356+
id: String
1357+
) -> AppData<Value> {
1358+
AppData(
1359+
initial: initial(),
1360+
scope: Scope(name: "AppData", id: id)
1361+
)
1362+
}
1363+
}
1364+
#endif
1365+
13041366
// MARK: - FileState Functions
13051367

13061368
public extension Application {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import SwiftData
2+
3+
#if !os(Linux) && !os(Windows)
4+
/// A protocol that types must conform to for use with `AppData`.
5+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
6+
public protocol AppDataModel: PersistentModel {
7+
/// An identifier used by AppState to track the model instance.
8+
/// This property should not be modified directly.
9+
var appStateID: String? { get set }
10+
}
11+
#endif
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import Foundation
2+
import SwiftData
3+
4+
#if !os(Linux) && !os(Windows)
5+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
6+
extension Application {
7+
private static var _modelContainer: ModelContainer?
8+
9+
/// The shared `ModelContainer` instance. This dependency is only available after `Application.configure(models:)` has been called.
10+
public var modelContainer: Dependency<ModelContainer> {
11+
dependency {
12+
guard let container = Application._modelContainer else {
13+
let message = "Fatal Error: `Application.configure(models:)` must be called before using AppData."
14+
log(message: message)
15+
fatalError(message)
16+
}
17+
return container
18+
}
19+
}
20+
21+
/// Configures the `ModelContainer` for `AppData`. This must be called once, typically at app launch, before any `AppData` state is accessed.
22+
/// - Parameters:
23+
/// - models: An array of `PersistentModel` types to be included in the data schema.
24+
/// - inMemory: A boolean to indicate if the container should be in-memory. Defaults to `false`.
25+
public static func configure(models: [any PersistentModel.Type], inMemory: Bool = false) {
26+
guard _modelContainer == nil else {
27+
log(message: "Warning: `Application.configure(models:)` was called more than once. Ignoring subsequent calls.")
28+
return
29+
}
30+
do {
31+
let schema = try Schema(for: models)
32+
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: inMemory)
33+
let container = try ModelContainer(for: schema, configurations: [configuration])
34+
_modelContainer = container
35+
} catch {
36+
let message = "Fatal Error: Failed to create ModelContainer. \(error.localizedDescription)"
37+
log(error: error, message: message)
38+
fatalError(message)
39+
}
40+
}
41+
42+
/// `AppData` encapsulates a SwiftData `PersistentModel` that conforms to `AppDataModel` within the application's scope.
43+
public struct AppData<Value: AppDataModel>: MutableApplicationState {
44+
public static var emoji: Character { "💾" }
45+
46+
@AppDependency(\.modelContainer) private var modelContainer
47+
48+
private var initial: () -> Value
49+
50+
public var value: Value {
51+
get {
52+
if let cachedValue = shared.cache.get(scope.key, as: State<Value>.self)?.value {
53+
return cachedValue
54+
}
55+
56+
let context = ModelContext(modelContainer)
57+
do {
58+
let appStateID = scope.id
59+
let predicate = #Predicate<Value> { $0.appStateID == appStateID }
60+
let fetchDescriptor = FetchDescriptor(predicate: predicate)
61+
62+
let storedValue = try context.fetch(fetchDescriptor).first ?? initial()
63+
64+
shared.cache.set(
65+
value: Application.State(
66+
type: .appData,
67+
initial: storedValue,
68+
scope: scope
69+
),
70+
forKey: scope.key
71+
)
72+
73+
return storedValue
74+
} catch {
75+
log(
76+
error: error,
77+
message: "\(Self.emoji) AppData Fetching",
78+
fileID: #fileID,
79+
function: #function,
80+
line: #line,
81+
column: #column
82+
)
83+
return initial()
84+
}
85+
}
86+
nonmutating set {
87+
var newValue = newValue
88+
newValue.appStateID = scope.id
89+
90+
shared.cache.set(
91+
value: Application.State(
92+
type: .appData,
93+
initial: newValue,
94+
scope: scope
95+
),
96+
forKey: scope.key
97+
)
98+
99+
let context = ModelContext(modelContainer)
100+
do {
101+
let appStateID = scope.id
102+
let predicate = #Predicate<Value> { $0.appStateID == appStateID }
103+
104+
// Delete any existing model with the same AppState ID
105+
try context.delete(model: Value.self, where: predicate)
106+
107+
// Insert the new one
108+
context.insert(newValue)
109+
try context.save()
110+
} catch {
111+
log(
112+
error: error,
113+
message: "\(Self.emoji) AppData Saving",
114+
fileID: #fileID,
115+
function: #function,
116+
line: #line,
117+
column: #column
118+
)
119+
}
120+
}
121+
}
122+
123+
let scope: Scope
124+
125+
init(
126+
initial: @escaping @autoclosure () -> Value,
127+
scope: Scope
128+
) {
129+
self.initial = initial
130+
self.scope = scope
131+
}
132+
133+
@MainActor
134+
public mutating func reset() {
135+
let context = ModelContext(modelContainer)
136+
do {
137+
let appStateID = scope.id
138+
let predicate = #Predicate<Value> { $0.appStateID == appStateID }
139+
try context.delete(model: Value.self, where: predicate)
140+
try context.save()
141+
142+
shared.cache.remove(scope.key)
143+
} catch {
144+
log(
145+
error: error,
146+
message: "\(Self.emoji) AppData Resetting",
147+
fileID: #fileID,
148+
function: #function,
149+
line: #line,
150+
column: #column
151+
)
152+
}
153+
}
154+
}
155+
}
156+
#endif

Sources/AppState/Application/Types/State/Application+State.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ extension Application {
88
case state
99
case stored
1010
case file
11+
case appData
1112
}
1213

1314
public static var emoji: Character {
@@ -110,6 +111,7 @@ extension Application {
110111
case .state: return "State<\(Value.self)>(\(value)) (\(scope.key))"
111112
case .stored: return "StoredState<\(Value.self)>(\(value)) (\(scope.key))"
112113
case .file: return "FileState<\(Value.self)>(\(value)) (\(scope.key))"
114+
case .appData: return "AppData<\(Value.self)>(\(value)) (\(scope.key))"
113115
}
114116
}
115117
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Foundation
2+
import SwiftData
3+
4+
#if !os(Linux) && !os(Windows)
5+
import Combine
6+
import SwiftUI
7+
8+
/// `AppData` is a property wrapper that allows SwiftUI views to subscribe to a SwiftData `PersistentModel` in Application's state.
9+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
10+
@propertyWrapper public struct AppData<Value: AppDataModel> {
11+
/// Holds the singleton instance of `Application`.
12+
@ObservedObject private var app: Application = Application.shared
13+
14+
/// Path for accessing `AppData` from Application.
15+
private let keyPath: KeyPath<Application, Application.AppData<Value>>
16+
17+
private let fileID: StaticString
18+
private let function: StaticString
19+
private let line: Int
20+
private let column: Int
21+
22+
/// Represents the current value of the `AppData`.
23+
@MainActor
24+
public var wrappedValue: Value {
25+
get {
26+
Application.appData(
27+
keyPath,
28+
fileID,
29+
function,
30+
line,
31+
column
32+
).value
33+
}
34+
nonmutating set {
35+
Application.log(
36+
debug: "💾 Setting AppData \(String(describing: keyPath)) = \(newValue)",
37+
fileID: fileID,
38+
function: function,
39+
line: line,
40+
column: column
41+
)
42+
43+
var state = app.value(keyPath: keyPath)
44+
state.value = newValue
45+
}
46+
}
47+
48+
/// A binding to the `AppData`'s value, which can be used with SwiftUI views.
49+
@MainActor
50+
public var projectedValue: Binding<Value> {
51+
Binding(
52+
get: { wrappedValue },
53+
set: { wrappedValue = $0 }
54+
)
55+
}
56+
57+
/**
58+
Initializes the AppData with a `keyPath` for accessing `AppData` in Application.
59+
60+
- Parameter keyPath: The `KeyPath` for accessing `AppData` in Application.
61+
*/
62+
@MainActor
63+
public init(
64+
_ keyPath: KeyPath<Application, Application.AppData<Value>>,
65+
_ fileID: StaticString = #fileID,
66+
_ function: StaticString = #function,
67+
_ line: Int = #line,
68+
_ column: Int = #column
69+
) {
70+
self.keyPath = keyPath
71+
self.fileID = fileID
72+
self.function = function
73+
self.line = line
74+
self.column = column
75+
}
76+
77+
/// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes
78+
@MainActor
79+
public static subscript<OuterSelf: ObservableObject>(
80+
_enclosingInstance observed: OuterSelf,
81+
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
82+
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
83+
) -> Value {
84+
get {
85+
observed[keyPath: storageKeyPath].wrappedValue
86+
}
87+
set {
88+
guard
89+
let publisher = observed.objectWillChange as? ObservableObjectPublisher
90+
else { return }
91+
92+
publisher.send()
93+
observed[keyPath: storageKeyPath].wrappedValue = newValue
94+
}
95+
}
96+
}
97+
98+
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
99+
extension AppData: DynamicProperty { }
100+
#endif

0 commit comments

Comments
 (0)