diff --git a/.gitignore b/.gitignore
index b7fab93..069f758 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ Package.resolved
*.xcuserstate
**/*.xcworkspace/xcshareddata/
**/*.xcworkspace/contents.xcworkspacedata
+**/.derivedData
# blanche
context/
\ No newline at end of file
diff --git a/Examples/DiaryApp/DiaryApp.xcodeproj/project.pbxproj b/Examples/DiaryApp/DiaryApp.xcodeproj/project.pbxproj
index 0fb1a8a..502c846 100644
--- a/Examples/DiaryApp/DiaryApp.xcodeproj/project.pbxproj
+++ b/Examples/DiaryApp/DiaryApp.xcodeproj/project.pbxproj
@@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */
774570BB2DDD0216004AB32E /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 774570BA2DDD0216004AB32E /* Chiui */; };
77BF54992F7579CE00E1D60D /* Chiui in Frameworks */ = {isa = PBXBuildFile; productRef = 77BF54982F7579CE00E1D60D /* Chiui */; };
+ 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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -57,8 +61,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 77BF55082F7D4D2100E1D60D /* Chiui in Frameworks */,
774570BB2DDD0216004AB32E /* Chiui in Frameworks */,
77BF54992F7579CE00E1D60D /* Chiui in Frameworks */,
+ 77BF55652F8535DA00E1D60D /* Chiui in Frameworks */,
+ 77BF550B2F7D4D6400E1D60D /* Chiui in Frameworks */,
+ 77BF550E2F7D4D7200E1D60D /* Chiui in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -121,6 +129,10 @@
packageProductDependencies = (
774570BA2DDD0216004AB32E /* Chiui */,
77BF54982F7579CE00E1D60D /* Chiui */,
+ 77BF55072F7D4D2100E1D60D /* Chiui */,
+ 77BF550A2F7D4D6400E1D60D /* Chiui */,
+ 77BF550D2F7D4D7200E1D60D /* Chiui */,
+ 77BF55642F8535DA00E1D60D /* Chiui */,
);
productName = DiaryApp;
productReference = 774570542DDCFF04004AB32E /* DiaryApp.app */;
@@ -180,7 +192,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
- LastUpgradeCheck = 1620;
+ LastUpgradeCheck = 2630;
TargetAttributes = {
774570532DDCFF04004AB32E = {
CreatedOnToolsVersion = 16.2;
@@ -205,7 +217,7 @@
mainGroup = 7745704B2DDCFF04004AB32E;
minimizedProjectReferenceProxies = 1;
packageReferences = (
- 77BF54972F7579CE00E1D60D /* XCLocalSwiftPackageReference "../../../Chiui" */,
+ 77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 774570552DDCFF04004AB32E /* Products */;
@@ -315,6 +327,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -337,6 +350,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -376,6 +390,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -391,6 +406,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -403,8 +419,11 @@
CODE_SIGN_ENTITLEMENTS = DiaryApp/DiaryApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"DiaryApp/Preview Content\"";
+ ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -440,8 +459,11 @@
CODE_SIGN_ENTITLEMENTS = DiaryApp/DiaryApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"DiaryApp/Preview Content\"";
+ ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -475,6 +497,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MACOSX_DEPLOYMENT_TARGET = 15.2;
@@ -497,6 +520,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MACOSX_DEPLOYMENT_TARGET = 15.2;
@@ -518,6 +542,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MACOSX_DEPLOYMENT_TARGET = 15.2;
@@ -539,6 +564,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MACOSX_DEPLOYMENT_TARGET = 15.2;
@@ -596,12 +622,16 @@
};
/* End XCConfigurationList section */
-/* Begin XCLocalSwiftPackageReference section */
- 77BF54972F7579CE00E1D60D /* XCLocalSwiftPackageReference "../../../Chiui" */ = {
- isa = XCLocalSwiftPackageReference;
- relativePath = ../../../Chiui;
+/* Begin XCRemoteSwiftPackageReference section */
+ 77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/den-ree/chiui";
+ requirement = {
+ kind = exactVersion;
+ version = 1.0.1;
+ };
};
-/* End XCLocalSwiftPackageReference section */
+/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
774570BA2DDD0216004AB32E /* Chiui */ = {
@@ -612,6 +642,23 @@
isa = XCSwiftPackageProductDependency;
productName = Chiui;
};
+ 77BF55072F7D4D2100E1D60D /* Chiui */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Chiui;
+ };
+ 77BF550A2F7D4D6400E1D60D /* Chiui */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Chiui;
+ };
+ 77BF550D2F7D4D7200E1D60D /* Chiui */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Chiui;
+ };
+ 77BF55642F8535DA00E1D60D /* Chiui */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 77BF55632F8535DA00E1D60D /* XCRemoteSwiftPackageReference "chiui" */;
+ productName = Chiui;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 7745704C2DDCFF04004AB32E /* Project object */;
diff --git a/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift b/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift
index 8885d8b..a8d8ae9 100644
--- a/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift
+++ b/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift
@@ -1,22 +1,60 @@
import Foundation
-/// Represents a single diary entry
+enum DiaryEntryMood: String, CaseIterable, Equatable, Sendable {
+ case awful
+ case bad
+ case meh
+ case okay
+ case good
+ case great
+ case amazing
+
+ var title: String {
+ switch self {
+ case .awful: "Awful"
+ case .bad: "Bad"
+ case .meh: "Meh"
+ case .okay: "Okay"
+ case .good: "Good"
+ case .great: "Great"
+ case .amazing: "Amazing"
+ }
+ }
+}
+
struct DiaryEntry: Identifiable, Equatable, Sendable {
- /// Unique identifier for the entry
let id: UUID
- /// Title of the diary entry
let title: String
- /// Content of the diary entry
let content: String
- /// Date when the entry was created
let createdAt: Date
+ let mood: DiaryEntryMood
+
+ init(
+ id: UUID,
+ title: String,
+ content: String,
+ createdAt: Date,
+ mood: DiaryEntryMood = .okay
+ ) {
+ self.id = id
+ self.title = title
+ self.content = content
+ self.createdAt = createdAt
+ self.mood = mood
+ }
- func new(title: String, content: String) -> Self {
+ func new(
+ title: String,
+ content: String,
+ createdAt: Date? = nil,
+ mood: DiaryEntryMood? = nil
+ ) -> Self {
return .init(
id: id,
title: title,
content: content,
- createdAt: createdAt
+ createdAt: createdAt ?? self.createdAt,
+ mood: mood ?? self.mood
)
}
}
diff --git a/Examples/DiaryApp/DiaryApp/DiaryApp.entitlements b/Examples/DiaryApp/DiaryApp/DiaryApp.entitlements
index f2ef3ae..0c67376 100644
--- a/Examples/DiaryApp/DiaryApp/DiaryApp.entitlements
+++ b/Examples/DiaryApp/DiaryApp/DiaryApp.entitlements
@@ -1,10 +1,5 @@
-
- com.apple.security.app-sandbox
-
- com.apple.security.files.user-selected.read-only
-
-
+
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift
index dfdde0d..bc374e0 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift
@@ -1,13 +1,8 @@
import SwiftUI
import Chiui
-/// View for adding a new diary entry
struct DiaryEntryView: ContextualView {
- /// View model for adding diary entries
@StateObject var viewModel: DiaryEntryViewModel
- /// Environment presentation mode for dismissing the view
- @Environment(\.presentationMode) private var presentationMode
- /// Focus state for tracking which field is being edited
@FocusState private var focusedField: Field?
enum Field: Equatable {
@@ -15,8 +10,6 @@ struct DiaryEntryView: ContextualView {
case content
}
- /// Creates a new add diary entry view
- /// - Parameter viewModel: View model to use
init(_ context: DiaryContext) {
_viewModel = .init(wrappedValue: .init(context))
}
@@ -24,6 +17,34 @@ struct DiaryEntryView: ContextualView {
var body: some View {
ZStack {
Form {
+ Section(header: Text("Date")) {
+ Button {
+ focusedField = nil
+ viewModel.openDateSelection()
+ } label: {
+ HStack {
+ Text("Entry Date")
+ Spacer()
+ Text(state.selectedDate, style: .date)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Section(header: Text("Mood")) {
+ Button {
+ focusedField = nil
+ viewModel.openMoodSelection()
+ } label: {
+ HStack {
+ Text("Mood")
+ Spacer()
+ Text(state.selectedMood.title)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
Section(header: Text("Title")) {
TextField("Enter title", text: bindTo(\.title) { viewModel.updateTitle($0) })
.focused($focusedField, equals: .title)
@@ -68,10 +89,13 @@ struct DiaryEntryView: ContextualView {
}
}
}
- .onChange(of: state.shouldDismiss) { _, newValue in
- if newValue {
- presentationMode.wrappedValue.dismiss()
- }
+ .navigationDestination(
+ isPresented: bindTo(\.isDateSelectionPresented) { _ in }
+ ) {
+ DiaryEntryDateSelectionView(viewModel.context)
+ }
+ .sheet(isPresented: bindTo(\.isMoodSelectionPresented) { _ in }) {
+ DiaryEntryMoodSelectionView(viewModel.context)
}
if state.savingStatus == .saving {
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift
index 46e050e..a06626b 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift
@@ -9,14 +9,16 @@ final class DiaryEntryViewModel: ContextViewModel {
+ struct State: ContextualViewState {
+ var selectedDate: Date = .now
+ }
+
+ nonisolated override func didStoreUpdate(_ storeState: DiaryStoreState) async {
+ await updateState { state in
+ if let draftDate = storeState.entryDraftDate {
+ state.selectedDate = draftDate
+ return
+ }
+
+ switch storeState.entrySelectionMode {
+ case let .selecting(entry):
+ state.selectedDate = entry.createdAt
+ case .addingNew, .no:
+ state.selectedDate = .now
+ }
+ }
+ }
+
+ func updateSelectedDate(_ date: Date) {
+ Task {
+ await updateState { state in
+ state.selectedDate = date
+ }.then { [weak self] change in
+ guard let self, change.hasChanged else { return }
+
+ self.updateStore { storeState in
+ storeState.entryDraftDate = change.newState.selectedDate
+ }
+ }
+ }
+ }
+
+ func confirmSelection() {
+ updateStore { storeState in
+ storeState.isSelectingEntryDate = false
+ }
+ }
+
+ func cancelSelection() {
+ updateStore { storeState in
+ storeState.isSelectingEntryDate = false
+ }
+ }
+}
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionView.swift
new file mode 100644
index 0000000..2057661
--- /dev/null
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionView.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+import Chiui
+
+struct DiaryEntryMoodSelectionView: ContextualView {
+ @StateObject var viewModel: DiaryEntryMoodSelectionViewModel
+
+ init(_ context: DiaryContext) {
+ _viewModel = .init(wrappedValue: .init(context))
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(DiaryEntryMood.allCases, id: \.self) { mood in
+ moodRow(mood)
+ }
+ }
+ .navigationTitle("Select Mood")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Cancel") {
+ viewModel.cancelSelection()
+ }
+ }
+
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ viewModel.confirmSelection()
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func moodRow(_ mood: DiaryEntryMood) -> some View {
+ Button {
+ viewModel.updateSelectedMood(mood)
+ } label: {
+ HStack {
+ Text(mood.title)
+ Spacer()
+ if state.selectedMood == mood {
+ Image(systemName: "checkmark")
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift
new file mode 100644
index 0000000..eac8c44
--- /dev/null
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift
@@ -0,0 +1,50 @@
+import Foundation
+import Chiui
+
+final class DiaryEntryMoodSelectionViewModel: ContextViewModel {
+ struct State: ContextualViewState {
+ var selectedMood: DiaryEntryMood = .okay
+ }
+
+ nonisolated override func didStoreUpdate(_ storeState: DiaryStoreState) async {
+ await updateState { state in
+ if let draftMood = storeState.entryDraftMood {
+ state.selectedMood = draftMood
+ return
+ }
+
+ switch storeState.entrySelectionMode {
+ case let .selecting(entry):
+ state.selectedMood = entry.mood
+ case .addingNew, .no:
+ state.selectedMood = .okay
+ }
+ }
+ }
+
+ func updateSelectedMood(_ mood: DiaryEntryMood) {
+ Task {
+ await updateState { state in
+ state.selectedMood = mood
+ }.then { [weak self] change in
+ guard let self, change.hasChanged else { return }
+
+ self.updateStore { storeState in
+ storeState.entryDraftMood = change.newState.selectedMood
+ }
+ }
+ }
+ }
+
+ func confirmSelection() {
+ updateStore { storeState in
+ storeState.isSelectingEntryMood = false
+ }
+ }
+
+ func cancelSelection() {
+ updateStore { storeState in
+ storeState.isSelectingEntryMood = false
+ }
+ }
+}
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift
index 354a26c..180bffd 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift
@@ -1,34 +1,19 @@
import SwiftUI
import Chiui
-/// View for displaying the list of diary entries
struct DiaryListView: ContextualView {
- /// View model for the diary list
@StateObject var viewModel: DiaryListViewModel
- /// Creates a new diary list view
- /// - Parameter viewModel: View model to use
init(_ context: DiaryContext) {
_viewModel = .init(wrappedValue: .init(context))
}
var body: some View {
- NavigationView {
+ NavigationStack {
List {
ForEach(state.entries) { entry in
- NavigationLink(
- isActive: Binding(
- get: { state.selectedEntryId == entry.id },
- set: { isActive in
- if isActive {
- viewModel.selectEntry(entry)
- } else {
- viewModel.clearSelection()
- }
- }
- )
- ) {
- DiaryEntryView(viewModel.context)
+ Button {
+ viewModel.selectEntry(entry)
} label: {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title)
@@ -44,6 +29,7 @@ struct DiaryListView: ContextualView {
}
.padding(.vertical, 4)
}
+ .buttonStyle(.plain)
}
.onDelete { indexSet in
for index in indexSet {
@@ -54,19 +40,26 @@ struct DiaryListView: ContextualView {
.navigationTitle("Diary")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
- NavigationLink(
- isActive: bindTo(\.isAddingNew) { newValue in
- if newValue {
- viewModel.startAddingNew()
- }
- }
- ) {
- DiaryEntryView(viewModel.context)
+ Button {
+ viewModel.startAddingNew()
} label: {
Image(systemName: "plus")
}
}
}
+ .navigationDestination(
+ isPresented: Binding(
+ get: { viewModel.isEntryDestinationPresented() },
+ set: { viewModel.setEntryDestinationPresented($0) }
+ )
+ ) {
+ DiaryEntryView(viewModel.context)
+ }
+ .navigationDestination(
+ isPresented: bindTo(\.isAddingNew) { viewModel.setAddingNewDestinationPresented($0) }
+ ) {
+ DiaryEntryView(viewModel.context)
+ }
}
}
}
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift
index 5700c24..07ecad1 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift
@@ -2,17 +2,11 @@ import Foundation
import Chiui
import SwiftUI
-/// View model for the diary list screen
final class DiaryListViewModel: ContextViewModel {
- /// State for the diary list screen
struct State: ContextualViewState {
- /// Collection of diary entries to display
var entries: [DiaryEntry] = []
- /// Currently selected entry for navigation
var selectedEntryId: UUID?
- /// Whether we're adding a new entry
var isAddingNew: Bool = false
- /// Local UI state
var isRefreshing: Bool = false
func entry(at index: Int) -> DiaryEntry {
@@ -22,14 +16,10 @@ final class DiaryListViewModel: ContextViewModel Bool {
+ state.selectedEntryId != nil
+ }
+
+ func setEntryDestinationPresented(_ isPresented: Bool) {
+ if !isPresented {
+ clearSelection()
+ }
+ }
+
func startAddingNew() {
updateStore { storeState in
storeState.entrySelectionMode = .addingNew
@@ -65,8 +63,16 @@ final class DiaryListViewModel: ContextViewModel
-
- /// Example of a non-Sendable dependency.
- /// We keep access confined to `@MainActor` via the view model's `then { @MainActor ... }` chain.
let loadingClient: DiaryLoadingClient
- /// Creates a new diary context
- /// - Parameter initialState: Initial state for the store
init(initialState: DiaryStoreState = .init()) {
self.store = ContextualStore(initialState)
self.loadingClient = DiaryLoadingClient()
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryLoadingClient.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryLoadingClient.swift
index ebfb8eb..daddb54 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryLoadingClient.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryLoadingClient.swift
@@ -7,12 +7,10 @@ import Foundation
/// - wrap it behind an actor, or
/// - carefully use `@unchecked Sendable` at the boundary.
final class DiaryLoadingClient {
- /// `DateFormatter` is not `Sendable`.
private let formatter = DateFormatter()
@MainActor
func simulateLoadingWork() async {
- // Touch non-Sendable state on the main actor.
_ = formatter.string(from: Date())
try? await Task.sleep(for: .seconds(2))
}
diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift
index 576721b..3818ab3 100644
--- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift
+++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift
@@ -1,11 +1,13 @@
import Foundation
import Chiui
-/// Represents the global state of the diary application
struct DiaryStoreState: ContextualStoreState {
- /// Collection of all diary entries
var entries: [DiaryEntry] = []
var entrySelectionMode: EntrySelectionMode = .no
+ var entryDraftDate: Date?
+ var entryDraftMood: DiaryEntryMood?
+ var isSelectingEntryDate: Bool = false
+ var isSelectingEntryMood: Bool = false
var isSavingChanges: Bool = false
}
@@ -28,12 +30,8 @@ struct EntryInput: Equatable {
}
}
-/// Actions that can be performed on the diary store
enum DiaryAction: Equatable {
- /// Add a new diary entry
case addEntry(DiaryEntry)
- /// Remove a diary entry by its ID
case removeEntry(UUID)
- /// Update an existing diary entry
case updateEntry(DiaryEntry)
}
diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryAppTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryAppTests.swift
deleted file mode 100644
index 0ee97a2..0000000
--- a/Examples/DiaryApp/DiaryAppTests/DiaryAppTests.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-//
-// DiaryAppTests.swift
-// DiaryAppTests
-//
-// Created by Den Ree on 20/05/2025.
-//
-
-import Testing
-
-struct DiaryAppTests {
-
- @Test func example() async throws {
- // Write your test here and use APIs like `#expect(...)` to check expected conditions.
- }
-
-}
diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift
new file mode 100644
index 0000000..279e481
--- /dev/null
+++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift
@@ -0,0 +1,77 @@
+import XCTest
+@testable import DiaryApp
+@testable import Chiui
+
+final class DiaryEntryDateSelectionViewModelTests: XCTestCase {
+ var sut: DiaryEntryDateSelectionViewModel!
+ var context: DiaryContext!
+
+ override func setUp() async throws {
+ try await super.setUp()
+ await MainActor.run {
+ context = DiaryContext(initialState: DiaryStoreState())
+ sut = DiaryEntryDateSelectionViewModel(context)
+ }
+ }
+
+ override func tearDown() async throws {
+ await MainActor.run {
+ sut = nil
+ context = nil
+ }
+ try await super.tearDown()
+ }
+
+ @MainActor
+ func testDidStoreUpdateUsesDraftDate() async {
+ let draftDate = Date(timeIntervalSince1970: 1_750_000_000)
+
+ await context.store.update { state in
+ state.entrySelectionMode = .addingNew
+ state.entryDraftDate = draftDate
+ state.isSelectingEntryDate = true
+ }
+
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedDate, draftDate)
+ }
+
+ @MainActor
+ func testUpdateSelectedDateUpdatesStoreDraft() async {
+ let selectedDate = Date(timeIntervalSince1970: 1_760_000_000)
+
+ sut.updateSelectedDate(selectedDate)
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedDate, selectedDate)
+ let storeState = await context.store.state
+ XCTAssertEqual(storeState.entryDraftDate, selectedDate)
+ }
+
+ @MainActor
+ func testConfirmSelectionDismissesDateSelection() async {
+ await context.store.update { state in
+ state.isSelectingEntryDate = true
+ }
+
+ sut.confirmSelection()
+ try? await Task.sleep(for: .seconds(0.1))
+
+ let storeState = await context.store.state
+ XCTAssertFalse(storeState.isSelectingEntryDate)
+ }
+
+ @MainActor
+ func testCancelSelectionDismissesDateSelection() async {
+ await context.store.update { state in
+ state.isSelectingEntryDate = true
+ }
+
+ sut.cancelSelection()
+ try? await Task.sleep(for: .seconds(0.1))
+
+ let storeState = await context.store.state
+ XCTAssertFalse(storeState.isSelectingEntryDate)
+ }
+}
diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift
new file mode 100644
index 0000000..54b91e0
--- /dev/null
+++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift
@@ -0,0 +1,93 @@
+import XCTest
+@testable import DiaryApp
+@testable import Chiui
+
+final class DiaryEntryMoodSelectionViewModelTests: XCTestCase {
+ var sut: DiaryEntryMoodSelectionViewModel!
+ var context: DiaryContext!
+
+ override func setUp() async throws {
+ try await super.setUp()
+ await MainActor.run {
+ context = DiaryContext(initialState: DiaryStoreState())
+ sut = DiaryEntryMoodSelectionViewModel(context)
+ }
+ }
+
+ override func tearDown() async throws {
+ await MainActor.run {
+ sut = nil
+ context = nil
+ }
+ try await super.tearDown()
+ }
+
+ @MainActor
+ func testDidStoreUpdateUsesDraftMood() async {
+ await context.store.update { state in
+ state.entrySelectionMode = .addingNew
+ state.entryDraftMood = .great
+ state.isSelectingEntryMood = true
+ }
+
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedMood, .great)
+ }
+
+ @MainActor
+ func testDidStoreUpdateUsesSelectedEntryMoodWhenNoDraft() async {
+ let entry = DiaryEntry(
+ id: UUID(),
+ title: "Title",
+ content: "Content",
+ createdAt: .now,
+ mood: .bad
+ )
+
+ await context.store.update { state in
+ state.entrySelectionMode = .selecting(entry)
+ state.entryDraftMood = nil
+ }
+
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedMood, .bad)
+ }
+
+ @MainActor
+ func testUpdateSelectedMoodUpdatesStoreDraft() async {
+ sut.updateSelectedMood(.amazing)
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedMood, .amazing)
+ let storeState = await context.store.state
+ XCTAssertEqual(storeState.entryDraftMood, .amazing)
+ }
+
+ @MainActor
+ func testConfirmSelectionDismissesMoodSelection() async {
+ await context.store.update { state in
+ state.isSelectingEntryMood = true
+ }
+
+ sut.confirmSelection()
+ try? await Task.sleep(for: .seconds(0.1))
+
+ let storeState = await context.store.state
+ XCTAssertFalse(storeState.isSelectingEntryMood)
+ }
+
+ @MainActor
+ func testCancelSelectionDismissesMoodSelection() async {
+ await context.store.update { state in
+ state.isSelectingEntryMood = true
+ }
+
+ sut.cancelSelection()
+ try? await Task.sleep(for: .seconds(0.1))
+
+ let storeState = await context.store.state
+ XCTAssertFalse(storeState.isSelectingEntryMood)
+ }
+}
diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift
index 10e8ba2..b5b3a55 100644
--- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift
+++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift
@@ -8,116 +8,117 @@ final class DiaryEntryViewModelTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
- context = DiaryContext(initialState: DiaryStoreState())
- sut = DiaryEntryViewModel(context)
+ await MainActor.run {
+ context = DiaryContext(initialState: DiaryStoreState())
+ sut = DiaryEntryViewModel(context)
+ }
}
override func tearDown() async throws {
- sut = nil
- context = nil
+ await MainActor.run {
+ sut = nil
+ context = nil
+ }
try await super.tearDown()
}
// MARK: - State Tests
+ @MainActor
func testInitialState() async {
XCTAssertEqual(sut.viewState.title, "")
XCTAssertEqual(sut.viewState.content, "")
XCTAssertEqual(sut.viewState.savingStatus, .no)
XCTAssertFalse(sut.viewState.isEditing)
+ XCTAssertFalse(sut.viewState.isDateSelectionPresented)
+ XCTAssertFalse(sut.viewState.isMoodSelectionPresented)
+ XCTAssertEqual(sut.viewState.selectedMood, .okay)
XCTAssertEqual(sut.viewState.entryTitle, "")
- XCTAssertFalse(sut.viewState.shouldDismiss)
}
+ @MainActor
func testIsSavingDisabled() async {
- // Empty title should disable saving
- await sut.onAction(.updateTitle(""))
+ sut.updateTitle("")
XCTAssertTrue(sut.viewState.isSavingDisabled)
- // Non-empty title should enable saving
- await sut.onAction(.updateTitle("Test Title"))
+ sut.updateTitle("Test Title")
XCTAssertFalse(sut.viewState.isSavingDisabled)
- // Saving in progress should disable saving
- await sut.onAction(.finishEditing(save: true))
- XCTAssertTrue(sut.viewState.isSavingDisabled)
+ await sut.finishEditing(save: true)
+ XCTAssertFalse(sut.viewState.isSavingDisabled)
}
- // MARK: - Action Tests
-
+ @MainActor
func testUpdateTitle() async {
- await sut.onAction(.startEditing)
- await sut.onAction(.updateTitle("Test Title"))
+ sut.startEditing()
+ sut.updateTitle("Test Title")
XCTAssertEqual(sut.viewState.title, "Test Title")
XCTAssertTrue(sut.viewState.isEditing)
}
+ @MainActor
func testUpdateContent() async {
- await sut.onAction(.updateContent("Test Content"))
+ sut.updateContent("Test Content")
XCTAssertEqual(sut.viewState.content, "Test Content")
}
+ @MainActor
func testStartEditing() async {
- await sut.onAction(.startEditing)
+ sut.startEditing()
try? await Task.sleep(for: .seconds(0.1))
XCTAssertTrue(sut.viewState.isEditing)
}
+ @MainActor
func testFinishEditingWithoutSave() async {
- // Setup initial editing state
- await sut.onAction(.startEditing)
- await sut.onAction(.updateTitle("Test Title"))
- await sut.onAction(.updateContent("Test Content"))
+ sut.startEditing()
+ sut.updateTitle("Test Title")
+ sut.updateContent("Test Content")
XCTAssertTrue(sut.viewState.isEditing)
- // Cancel editing
- await sut.onAction(.finishEditing(save: false))
+ await sut.finishEditing(save: false)
XCTAssertFalse(sut.viewState.isEditing)
- XCTAssertTrue(sut.viewState.shouldDismiss)
}
+ @MainActor
func testFinishEditingWithSave() async {
- // Setup initial editing state
- await sut.onAction(.updateTitle("Test Title"))
- await sut.onAction(.updateContent("Test Content"))
+ sut.updateTitle("Test Title")
+ sut.updateContent("Test Content")
try? await Task.sleep(for: .seconds(0.1))
XCTAssertFalse(sut.viewState.isEditing)
- await sut.onAction(.startEditing)
+ sut.startEditing()
try? await Task.sleep(for: .seconds(0.1))
XCTAssertTrue(sut.viewState.isEditing)
- // Save entry
- await sut.onAction(.finishEditing(save: true))
- XCTAssertEqual(sut.viewState.savingStatus, .saving)
-
- // Wait for saving to complete
- try? await Task.sleep(for: .seconds(2.1))
+ await sut.finishEditing(save: true)
XCTAssertEqual(sut.viewState.savingStatus, .saved)
XCTAssertFalse(sut.viewState.isEditing)
- XCTAssertTrue(sut.viewState.shouldDismiss)
}
+ @MainActor
func testFinishEditingWithEmptyTitle() async {
- await sut.onAction(.updateTitle(""))
- await sut.onAction(.finishEditing(save: true))
- XCTAssertEqual(sut.viewState.savingStatus, .saving)
+ sut.updateTitle("")
+ await sut.finishEditing(save: true)
+ XCTAssertEqual(sut.viewState.savingStatus, .saved)
+ XCTAssertFalse(sut.viewState.isEditing)
let state = await context.store.state
XCTAssertEqual(state.entries.count, 0)
}
- // MARK: - Store Integration Tests
-
+ @MainActor
func testStoreUpdateOnNewEntry() async {
+ let selectedDate = Date(timeIntervalSince1970: 1_700_000_000)
await context.store.update { state in
state.entrySelectionMode = .addingNew
+ state.entryDraftDate = selectedDate
+ state.entryDraftMood = .great
}
try? await Task.sleep(for: .seconds(0.1))
- await sut.onAction(.updateTitle("Test Title"))
- await sut.onAction(.updateContent("Test Content"))
- await sut.onAction(.finishEditing(save: true))
+ sut.updateTitle("Test Title")
+ sut.updateContent("Test Content")
+ await sut.finishEditing(save: true)
- // Wait for saving to complete
try? await Task.sleep(for: .seconds(2.1))
let state = await context.store.state
@@ -125,29 +126,33 @@ final class DiaryEntryViewModelTests: XCTestCase {
let entry = state.entries[0]
XCTAssertEqual(entry.title, "Test Title")
XCTAssertEqual(entry.content, "Test Content")
+ XCTAssertEqual(entry.createdAt, selectedDate)
+ XCTAssertEqual(entry.mood, .great)
}
+ @MainActor
func testStoreUpdateOnEditEntry() async {
- // Create initial entry
let initialEntry = DiaryEntry(
id: UUID(),
title: "Initial Title",
content: "Initial Content",
- createdAt: .now
+ createdAt: .now,
+ mood: .bad
)
+ let updatedDate = Date(timeIntervalSince1970: 1_800_000_000)
await context.store.update { state in
state.entries = [initialEntry]
state.entrySelectionMode = .selecting(initialEntry)
+ state.entryDraftDate = updatedDate
+ state.entryDraftMood = .amazing
}
try? await Task.sleep(for: .seconds(0.1))
- // Edit entry
- await sut.onAction(.updateTitle("Updated Title"))
- await sut.onAction(.updateContent("Updated Content"))
- await sut.onAction(.finishEditing(save: true))
+ sut.updateTitle("Updated Title")
+ sut.updateContent("Updated Content")
+ await sut.finishEditing(save: true)
- // Wait for saving to complete
try? await Task.sleep(for: .seconds(2.1))
let state = await context.store.state
@@ -155,44 +160,77 @@ final class DiaryEntryViewModelTests: XCTestCase {
let entry = state.entries[0]
XCTAssertEqual(entry.title, "Updated Title")
XCTAssertEqual(entry.content, "Updated Content")
+ XCTAssertEqual(entry.createdAt, updatedDate)
+ XCTAssertEqual(entry.mood, .amazing)
}
+ @MainActor
func testStoreStateMapping() async {
- // Test adding new mode
await context.store.update { state in
state.entrySelectionMode = .addingNew
}
- // Wait for saving to complete
try? await Task.sleep(for: .seconds(0.2))
XCTAssertEqual(sut.viewState.entryTitle, "New Entry")
- XCTAssertFalse(sut.viewState.shouldDismiss)
- // Test selecting mode
let entry = DiaryEntry(
id: UUID(),
title: "Test Title",
content: "Test Content",
- createdAt: .now
+ createdAt: .now,
+ mood: .good
)
await context.store.update { state in
state.entrySelectionMode = .selecting(entry)
}
- // Wait for saving to complete
try? await Task.sleep(for: .seconds(0.2))
XCTAssertEqual(sut.viewState.entryTitle, "Test Title")
XCTAssertEqual(sut.viewState.title, "Test Title")
XCTAssertEqual(sut.viewState.content, "Test Content")
- XCTAssertFalse(sut.viewState.shouldDismiss)
+ XCTAssertEqual(sut.viewState.selectedDate, entry.createdAt)
+ XCTAssertEqual(sut.viewState.selectedMood, entry.mood)
- // Test no selection mode
await context.store.update { state in
state.entrySelectionMode = .no
}
- // Wait for saving to complete
try? await Task.sleep(for: .seconds(0.2))
- XCTAssertEqual(sut.viewState.entryTitle, "")
- XCTAssertEqual(sut.viewState.title, "")
- XCTAssertEqual(sut.viewState.content, "")
- XCTAssertTrue(sut.viewState.shouldDismiss)
+ XCTAssertEqual(sut.viewState.entryTitle, "Test Title")
+ XCTAssertEqual(sut.viewState.title, "Test Title")
+ XCTAssertEqual(sut.viewState.content, "Test Content")
+ }
+
+ @MainActor
+ func testOpenMoodSelectionSeedsDraftAndPresentsModal() async {
+ sut.updateSelectedMood(.great)
+ try? await Task.sleep(for: .seconds(0.1))
+
+ sut.openMoodSelection()
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertTrue(sut.viewState.isMoodSelectionPresented)
+ let storeState = await context.store.state
+ XCTAssertTrue(storeState.isSelectingEntryMood)
+ XCTAssertEqual(storeState.entryDraftMood, .great)
+ }
+
+ @MainActor
+ func testUpdateSelectedMoodPersistsDraftMood() async {
+ sut.updateSelectedMood(.amazing)
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedMood, .amazing)
+ let storeState = await context.store.state
+ XCTAssertEqual(storeState.entryDraftMood, .amazing)
+ }
+
+ @MainActor
+ func testUpdateSelectedDatePersistsDraftDate() async {
+ let date = Date(timeIntervalSince1970: 1_850_000_000)
+ sut.updateSelectedDate(date)
+
+ try? await Task.sleep(for: .seconds(0.1))
+
+ XCTAssertEqual(sut.viewState.selectedDate, date)
+ let storeState = await context.store.state
+ XCTAssertEqual(storeState.entryDraftDate, date)
}
}
diff --git a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift
index b970904..f4b7b63 100644
--- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift
+++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift
@@ -10,31 +10,39 @@ import XCTest
final class DiaryAppUITests: XCTestCase {
override func setUpWithError() throws {
- // Put setup code here. This method is called before the invocation of each test method in the class.
-
- // In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
-
- // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
- // Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
- func testExample() throws {
- // UI tests must launch the application that they test.
+ func testCreateEntryAndVerifyInList() throws {
let app = XCUIApplication()
app.launch()
- // Use XCTAssert and related functions to verify your tests produce the correct results.
+ XCTAssertTrue(app.navigationBars["Diary"].waitForExistence(timeout: 2))
+
+ let addButton = app.navigationBars["Diary"].buttons.element(boundBy: 0)
+ XCTAssertTrue(addButton.waitForExistence(timeout: 2))
+ addButton.tap()
+
+ let titleField = app.textFields["Enter title"]
+ XCTAssertTrue(titleField.waitForExistence(timeout: 2))
+ titleField.tap()
+ titleField.typeText("UI Test Entry")
+
+ let saveButton = app.navigationBars.buttons["Save"]
+ XCTAssertTrue(saveButton.waitForExistence(timeout: 2))
+ XCTAssertTrue(saveButton.isEnabled)
+ saveButton.tap()
+
+ XCTAssertTrue(app.staticTexts["UI Test Entry"].waitForExistence(timeout: 5))
}
@MainActor
func testLaunchPerformance() throws {
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
- // This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
diff --git a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift
index b47dae7..8602594 100644
--- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift
+++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift
@@ -21,9 +21,7 @@ class DiaryAppUITestsLaunchTests: XCTestCase {
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
-
- // Insert steps here to perform after app launch but before taking a screenshot,
- // such as logging into a test account or navigating somewhere in the app
+ XCTAssertTrue(app.navigationBars["Diary"].waitForExistence(timeout: 2))
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
diff --git a/Sources/Chiui/Chiui.docc/Documentation.md b/Sources/Chiui/Chiui.docc/Documentation.md
index 9d37bc9..4660df8 100644
--- a/Sources/Chiui/Chiui.docc/Documentation.md
+++ b/Sources/Chiui/Chiui.docc/Documentation.md
@@ -55,7 +55,7 @@ final class ProfileViewModel: ContextViewModel {
}
func updateName(_ name: String) {
- Task { in
+ Task {
await updateState { state in
state.displayName = name
state.validationError = validateName(name)
@@ -84,6 +84,12 @@ final class ProfileViewModel: ContextViewModel {
}
```
+### Concurrency Semantics
+
+- `updateState(_:)` is synchronous and can be used directly in sync action methods.
+- Chaining with `.then(_:)` is asynchronous and must be called from an async context (for example `Task { await ... }`).
+- Inside `.then`, prefer data from `change.newState`/`change.oldState` instead of reading actor-isolated `state` from a sendable closure.
+
### 4. Use in SwiftUI
```swift