Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ included:
excluded:
- .build
- .derivedData
- Examples/DiaryApp/.derivedData
- Examples/DiaryApp/.derivedData/**

disabled_rules:
- static_over_final_class

opt_in_rules:
- explicit_init
Expand All @@ -27,6 +32,12 @@ identifier_name:
- x
- y

type_name:
min_length: 3
max_length:
warning: 60
error: 80

type_body_length:
warning: 350
error: 500
Expand Down
92 changes: 64 additions & 28 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@ This file is intended for both AI agents and human maintainers generating new sc

1. Context owns the coordinator reference (to avoid coordinator/context cycles).
2. All store mutations go through `ContextViewModel.updateStore(_:)` (or directly through `context.store.update(state:)`).
3. `ContextViewModel.updateState(_:)` is the only place local view state is mutated; follow-up async work is chained with `then(_:)`.
4. Store -> view mapping happens in `ContextViewModel.didStoreUpdate(_:)`.
5. Views wire SwiftUI controls to view state and view model methods via `ContextualView.bindTo`.
3. `ContextViewModel.send(_:)` is the canonical entry point for user/store actions.
4. `ContextViewModel.respond(to:state:)` is the only place local view state is mutated.
5. Async follow-up work runs in `ContextViewModel.handle(_:)`.
6. Store -> view mapping happens in `ContextViewModel.respond(to:state:)` via `.storeChanged` actions.
7. Views wire SwiftUI controls to view state and view model actions via `ContextualView.bindTo`.

## Canonical symbols to use

- Context: conform to `StoreContext`
- Store: `ContextualStore<StoreState>`
- View model: subclass `ContextViewModel<Context, ViewState>`
- Store->View mapping: `didStoreUpdate(_:)`
- Local state mutation: `updateState(_:) -> ContextualStateSideEffect`
- Async follow-ups: `then(_:)`
- View model: subclass `ContextViewModel<Context, ViewState, Action, Effect>`
- Action contract: `ContextualAction` (`Equatable`, `Sendable`, includes `.storeChanged(StoreState)`)
- Store->View mapping: `respond(to:state:)` handling `.storeChanged`
- Action entry point: `send(_:)` (sync; returns `Task<Void, Never>?` when an effect runs — await only when needed)
- State reducer: `respond(to:state:) -> Effect?`
- Effect runner: `handle(_:)`
- Local state primitive: `updateState(_:) -> ContextualStateChange`
- Snapshotting local state for async work: `scopeState(_:_:)`
- SwiftUI wiring: `ContextualView` + `bindTo(_ : action:)`
- SwiftUI wiring: `ContextualView` + `bindTo(_ : action:)` + optional `send(_:)` shorthand on `ContextualView`

## Step-by-step checklist

Expand All @@ -47,53 +52,83 @@ final class FeatureContext: StoreContext {

### 2. Create the ViewModel

- Subclass `ContextViewModel<FeatureContext, FeatureViewModel.State>`.
- Override `didStoreUpdate(_:)` to map `StoreState` into `State` via `updateState`.
- Subclass `ContextViewModel<FeatureContext, FeatureViewModel.State, FeatureViewModel.Action, FeatureViewModel.Effect>`.
- Define `Action` and `Effect`.
- Implement `respond(to:state:)` for pure state transitions.
- Implement `handle(_:)` for async side effects.
- Make `Action` conform to `ContextualAction` with a `.storeChanged(StoreState)` case.

```swift
final class FeatureViewModel: ContextViewModel<FeatureContext, FeatureViewModel.State> {
final class FeatureViewModel: ContextViewModel<
FeatureContext,
FeatureViewModel.State,
FeatureViewModel.Action,
FeatureViewModel.Effect
> {
struct State: ContextualViewState {
var value: String = ""
init() {}
}

nonisolated override func didStoreUpdate(_ storeState: FeatureStoreState) async {
await updateState { state in
enum Action: ContextualAction {
case storeChanged(FeatureStoreState)
case valueChanged(String)
case nextTapped
}

enum Effect {
case persistValue(String)
case navigateToNext
}

override class func respond(to action: Action, state: inout State) -> Effect? {
switch action {
case .storeChanged(let storeState):
state.value = storeState.value
}.then { _ in
// Optional: run async follow-ups (analytics, navigation prep, etc.)
return nil
case .valueChanged(let value):
state.value = value
return .persistValue(value)
case .nextTapped:
return .navigateToNext
}
}

@MainActor
func onNextTapped() {
context.coordinator.navigateToNextScreen()
override func handle(_ effect: Effect) async {
switch effect {
case .persistValue(let value):
await updateStore { $0.value = value }
case .navigateToNext:
context.coordinator.navigateToNextScreen()
}
}
}
```

Notes:
- Use `guard change.hasChanged else { return }` inside `then(_:)` if you only want follow-ups when values changed.
- `then(_:)` runs on the main actor.
- Keep `respond(to:state:)` pure (state in, effect out).
- Use `send(_:)` from views; store updates are delivered as `.storeChanged` actions automatically.

### 3. Build the View

- Conform to `ContextualView`.
- Declare `@StateObject var viewModel: YourViewModel`.
- Bind controls to view state properties using `bindTo` and forward writes to view model methods.
- Declare `@State var viewModel: YourViewModel`.
- Bind controls to view state properties using `bindTo`. The trailing closure returns the `Action` to dispatch for the new bound value — Chiui calls `send(_:)` internally.

```swift
struct FeatureView: ContextualView {
@StateObject var viewModel: FeatureViewModel
@State var viewModel: FeatureViewModel

init(_ context: FeatureContext) {
_viewModel = .init(wrappedValue: .init(context))
_viewModel = .init(initialValue: .init(context))
}

var body: some View {
VStack {
TextField("Value", text: bindTo(\.value) { viewModel.updateValue($0) })
Button("Next") { viewModel.onNextTapped() }
TextField("Value", text: bindTo(\.value) { .valueChanged($0) })
Button("Next") {
send(.nextTapped)
}
}
}
}
Expand All @@ -114,9 +149,10 @@ final class AppCoordinator {

## Patterns to avoid (stale names)

- `sideEffect` (use `updateState(_:)` + `.then(_:)` or `scopeState(_:_:)`)
- `sideEffect`
- `.then(_:)`
- `then(wait:)` (not supported)
- `scopeStateOnStoreChange` (store->view mapping is `didStoreUpdate(_:)`)
- `scopeStateOnStoreChange` (store->view mapping happens in `respond(.storeChanged(...))`)

## Optional: Where to look

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ swiftlint lint --strict
- Keep architecture aligned with `AGENTS.md` conventions.

## Commit and Review Notes
- Use clear commit messages describing intent.
- Use clear commit messages describing action.
- Ensure CI is green before requesting review.
- Reference issues or context in PR descriptions when relevant.
25 changes: 10 additions & 15 deletions Examples/DiaryApp/DiaryApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
77BF55082F7D4D2100E1D60D /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77BF55072F7D4D2100E1D60D /* Chiui */; };
77BF550B2F7D4D6400E1D60D /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77BF550A2F7D4D6400E1D60D /* Chiui */; };
77BF550E2F7D4D7200E1D60D /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77BF550D2F7D4D7200E1D60D /* Chiui */; };
77BF55652F8535DA00E1D60D /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77BF55642F8535DA00E1D60D /* Chiui */; };
77F7E7F12F97D8C200256528 /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77F7E7F02F97D8C200256528 /* Chiui */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -64,7 +64,7 @@
77BF55082F7D4D2100E1D60D /* Chiui in Frameworks */,
774570BB2DDD0216004AB32E /* Chiui in Frameworks */,
77BF54992F7579CE00E1D60D /* Chiui in Frameworks */,
77BF55652F8535DA00E1D60D /* Chiui in Frameworks */,
77F7E7F12F97D8C200256528 /* Chiui in Frameworks */,
77BF550B2F7D4D6400E1D60D /* Chiui in Frameworks */,
77BF550E2F7D4D7200E1D60D /* Chiui in Frameworks */,
);
Expand Down Expand Up @@ -132,7 +132,7 @@
77BF55072F7D4D2100E1D60D /* Chiui */,
77BF550A2F7D4D6400E1D60D /* Chiui */,
77BF550D2F7D4D7200E1D60D /* Chiui */,
77BF55642F8535DA00E1D60D /* Chiui */,
77F7E7F02F97D8C200256528 /* Chiui */,
);
productName = DiaryApp;
productReference = 774570542DDCFF04004AB32E /* DiaryApp.app */;
Expand Down Expand Up @@ -217,7 +217,7 @@
mainGroup = 7745704B2DDCFF04004AB32E;
minimizedProjectReferenceProxies = 1;
packageReferences = (
77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */,
77F7E7EF2F97D8C200256528 /* XCLocalSwiftPackageReference "../../../Chiui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 774570552DDCFF04004AB32E /* Products */;
Expand Down Expand Up @@ -622,16 +622,12 @@
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/den-ree/chiui";
requirement = {
kind = exactVersion;
version = 1.0.1;
};
/* Begin XCLocalSwiftPackageReference section */
77F7E7EF2F97D8C200256528 /* XCLocalSwiftPackageReference "../../../Chiui" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../../Chiui;
};
/* End XCRemoteSwiftPackageReference section */
/* End XCLocalSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
774570BA2DDD0216004AB32E /* Chiui */ = {
Expand All @@ -654,9 +650,8 @@
isa = XCSwiftPackageProductDependency;
productName = Chiui;
};
77BF55642F8535DA00E1D60D /* Chiui */ = {
77F7E7F02F97D8C200256528 /* Chiui */ = {
isa = XCSwiftPackageProductDependency;
package = 77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */;
productName = Chiui;
};
/* End XCSwiftPackageProductDependency section */
Expand Down
11 changes: 8 additions & 3 deletions Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,38 @@ struct DiaryEntry: Identifiable, Equatable, Sendable {
let content: String
let createdAt: Date
let mood: DiaryEntryMood
let location: String

init(
id: UUID,
title: String,
content: String,
createdAt: Date,
mood: DiaryEntryMood = .okay
mood: DiaryEntryMood = .okay,
location: String = ""
) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
self.mood = mood
self.location = location
}

func new(
title: String,
content: String,
createdAt: Date? = nil,
mood: DiaryEntryMood? = nil
mood: DiaryEntryMood? = nil,
location: String? = nil
) -> Self {
return .init(
id: id,
title: title,
content: content,
createdAt: createdAt ?? self.createdAt,
mood: mood ?? self.mood
mood: mood ?? self.mood,
location: location ?? self.location
)
}
}
Loading
Loading