From 651a01878141c402dcea75fc9bb44792e9e0c126 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 15:00:27 +0200 Subject: [PATCH 01/11] Replace example project with remote repo --- .../DiaryApp.xcodeproj/project.pbxproj | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) 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 */; From 79aff933da02b86154c2923fdf68ea954c0a43fc Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 15:00:49 +0200 Subject: [PATCH 02/11] Clean up entitilemnts --- Examples/DiaryApp/DiaryApp/DiaryApp.entitlements | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 - - + From 7f17a1c5ba05321eac86997f9ceb1f1b9f9b0b2e Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 15:01:10 +0200 Subject: [PATCH 03/11] Fixe bug with navigation --- .../Screens/DiaryEntry/DiaryEntryView.swift | 7 ---- .../DiaryEntry/DiaryEntryViewModel.swift | 8 +--- .../Screens/DiaryList/DiaryListView.swift | 41 +++++++++---------- .../DiaryList/DiaryListViewModel.swift | 22 +++++++++- 4 files changed, 40 insertions(+), 38 deletions(-) diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift index dfdde0d..c889d75 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift @@ -5,8 +5,6 @@ import Chiui 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? @@ -68,11 +66,6 @@ struct DiaryEntryView: ContextualView { } } } - .onChange(of: state.shouldDismiss) { _, newValue in - if newValue { - presentationMode.wrappedValue.dismiss() - } - } if state.savingStatus == .saving { Color.black.opacity(0.2) diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift index 46e050e..cfa4089 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift @@ -16,7 +16,6 @@ final class DiaryEntryViewModel: ContextViewModel Bool { + state.selectedEntryId != nil + } + + func setEntryDestinationPresented(_ isPresented: Bool) { + if !isPresented { + clearSelection() + } + } + func startAddingNew() { updateStore { storeState in storeState.entrySelectionMode = .addingNew @@ -65,8 +75,16 @@ final class DiaryListViewModel: ContextViewModel Date: Fri, 10 Apr 2026 15:01:18 +0200 Subject: [PATCH 04/11] Updated tests --- .../DiaryEntryViewModelTests.swift | 90 ++++++++++--------- .../DiaryAppUITests/DiaryAppUITests.swift | 22 ++++- .../DiaryAppUITestsLaunchTests.swift | 1 + 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift index 10e8ba2..174334f 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift @@ -8,114 +8,121 @@ 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) 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 { await context.store.update { state in state.entrySelectionMode = .addingNew } 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)) @@ -127,6 +134,7 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(entry.content, "Test Content") } + @MainActor func testStoreUpdateOnEditEntry() async { // Create initial entry let initialEntry = DiaryEntry( @@ -143,9 +151,9 @@ final class DiaryEntryViewModelTests: XCTestCase { 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)) @@ -157,6 +165,7 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(entry.content, "Updated Content") } + @MainActor func testStoreStateMapping() async { // Test adding new mode await context.store.update { state in @@ -165,7 +174,6 @@ final class DiaryEntryViewModelTests: XCTestCase { // 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( @@ -182,7 +190,6 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(sut.viewState.entryTitle, "Test Title") XCTAssertEqual(sut.viewState.title, "Test Title") XCTAssertEqual(sut.viewState.content, "Test Content") - XCTAssertFalse(sut.viewState.shouldDismiss) // Test no selection mode await context.store.update { state in @@ -190,9 +197,8 @@ final class DiaryEntryViewModelTests: XCTestCase { } // 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") } } diff --git a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift index b970904..0599b2e 100644 --- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift +++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift @@ -23,12 +23,28 @@ final class DiaryAppUITests: XCTestCase { } @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)) + + // Open add-entry flow from the trailing navigation bar button. + 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.buttons["UI Test Entry"].waitForExistence(timeout: 5)) } @MainActor diff --git a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift index b47dae7..48f0c81 100644 --- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift +++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift @@ -21,6 +21,7 @@ class DiaryAppUITestsLaunchTests: XCTestCase { func testLaunch() throws { let app = XCUIApplication() app.launch() + XCTAssertTrue(app.navigationBars["Diary"].waitForExistence(timeout: 2)) // 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 From c76a66971c12a5d3e3dd9b1086776906b256c442 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 19:47:37 +0200 Subject: [PATCH 05/11] Updated docs after iteration --- Sources/Chiui/Chiui.docc/Documentation.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From 75a919897d3ea605e91f4d14717333e4f130d902 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 19:47:55 +0200 Subject: [PATCH 06/11] Added new screen for date selection --- .../DiaryApp/Core/Entities/DiaryEntry.swift | 4 +- .../Screens/DiaryEntry/DiaryEntryView.swift | 19 +++++++ .../DiaryEntry/DiaryEntryViewModel.swift | 44 +++++++++++++++- .../DiaryEntryDateSelectionView.swift | 35 +++++++++++++ .../DiaryEntryDateSelectionViewModel.swift | 52 +++++++++++++++++++ .../Flows/Main/Store/DiaryStoreState.swift | 2 + 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionView.swift create mode 100644 Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionViewModel.swift diff --git a/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift b/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift index 8885d8b..2ec2768 100644 --- a/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift +++ b/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift @@ -11,12 +11,12 @@ struct DiaryEntry: Identifiable, Equatable, Sendable { /// Date when the entry was created let createdAt: Date - func new(title: String, content: String) -> Self { + func new(title: String, content: String, createdAt: Date? = nil) -> Self { return .init( id: id, title: title, content: content, - createdAt: createdAt + createdAt: createdAt ?? self.createdAt ) } } diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift index c889d75..8b530b5 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift @@ -22,6 +22,20 @@ 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("Title")) { TextField("Enter title", text: bindTo(\.title) { viewModel.updateTitle($0) }) .focused($focusedField, equals: .title) @@ -66,6 +80,11 @@ struct DiaryEntryView: ContextualView { } } } + .navigationDestination( + isPresented: bindTo(\.isDateSelectionPresented) { _ in } + ) { + DiaryEntryDateSelectionView(viewModel.context) + } if state.savingStatus == .saving { Color.black.opacity(0.2) diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift index cfa4089..74d6346 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift @@ -13,8 +13,10 @@ final class DiaryEntryViewModel: ContextViewModel { + struct State: ContextualViewState { + var selectedDate: Date = .now + + init() {} + } + + 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/Store/DiaryStoreState.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift index 576721b..3ebb294 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift @@ -6,6 +6,8 @@ struct DiaryStoreState: ContextualStoreState { /// Collection of all diary entries var entries: [DiaryEntry] = [] var entrySelectionMode: EntrySelectionMode = .no + var entryDraftDate: Date? = nil + var isSelectingEntryDate: Bool = false var isSavingChanges: Bool = false } From f1abfef52dd3782002a8fe314e056df61b20a765 Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 19:48:11 +0200 Subject: [PATCH 07/11] Added unittests --- ...iaryEntryDateSelectionViewModelTests.swift | 77 +++++++++++++++++++ .../DiaryEntryViewModelTests.swift | 20 +++++ 2 files changed, 97 insertions(+) create mode 100644 Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift 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/DiaryEntryViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift index 174334f..57db40c 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift @@ -30,6 +30,7 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(sut.viewState.content, "") XCTAssertEqual(sut.viewState.savingStatus, .no) XCTAssertFalse(sut.viewState.isEditing) + XCTAssertFalse(sut.viewState.isDateSelectionPresented) XCTAssertEqual(sut.viewState.entryTitle, "") } @@ -116,8 +117,10 @@ final class DiaryEntryViewModelTests: XCTestCase { @MainActor func testStoreUpdateOnNewEntry() async { + let selectedDate = Date(timeIntervalSince1970: 1_700_000_000) await context.store.update { state in state.entrySelectionMode = .addingNew + state.entryDraftDate = selectedDate } try? await Task.sleep(for: .seconds(0.1)) sut.updateTitle("Test Title") @@ -132,6 +135,7 @@ final class DiaryEntryViewModelTests: XCTestCase { let entry = state.entries[0] XCTAssertEqual(entry.title, "Test Title") XCTAssertEqual(entry.content, "Test Content") + XCTAssertEqual(entry.createdAt, selectedDate) } @MainActor @@ -143,9 +147,11 @@ final class DiaryEntryViewModelTests: XCTestCase { content: "Initial Content", createdAt: .now ) + let updatedDate = Date(timeIntervalSince1970: 1_800_000_000) await context.store.update { state in state.entries = [initialEntry] state.entrySelectionMode = .selecting(initialEntry) + state.entryDraftDate = updatedDate } try? await Task.sleep(for: .seconds(0.1)) @@ -163,6 +169,7 @@ final class DiaryEntryViewModelTests: XCTestCase { let entry = state.entries[0] XCTAssertEqual(entry.title, "Updated Title") XCTAssertEqual(entry.content, "Updated Content") + XCTAssertEqual(entry.createdAt, updatedDate) } @MainActor @@ -190,6 +197,7 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(sut.viewState.entryTitle, "Test Title") XCTAssertEqual(sut.viewState.title, "Test Title") XCTAssertEqual(sut.viewState.content, "Test Content") + XCTAssertEqual(sut.viewState.selectedDate, entry.createdAt) // Test no selection mode await context.store.update { state in @@ -201,4 +209,16 @@ final class DiaryEntryViewModelTests: XCTestCase { XCTAssertEqual(sut.viewState.title, "Test Title") XCTAssertEqual(sut.viewState.content, "Test Content") } + + @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) + } } From c4bd616cb5fa16bb0e9fe9a891df19c23eaf95bd Mon Sep 17 00:00:00 2001 From: Denis Date: Fri, 10 Apr 2026 19:48:14 +0200 Subject: [PATCH 08/11] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From fe4221d9e988ec6cb47a15a127b38ac2eb2f058f Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 21 Apr 2026 13:59:09 +0200 Subject: [PATCH 09/11] Added mood selection example screen --- .../DiaryApp/Core/Entities/DiaryEntry.swift | 52 +++++++++-- .../Screens/DiaryEntry/DiaryEntryView.swift | 22 ++++- .../DiaryEntry/DiaryEntryViewModel.swift | 51 +++++++--- .../DiaryEntryMoodSelectionView.swift | 51 ++++++++++ .../DiaryEntryMoodSelectionViewModel.swift | 52 +++++++++++ .../Flows/Main/Store/DiaryStoreState.swift | 4 +- ...iaryEntryMoodSelectionViewModelTests.swift | 93 +++++++++++++++++++ 7 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionView.swift create mode 100644 Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift create mode 100644 Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift diff --git a/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift b/Examples/DiaryApp/DiaryApp/Core/Entities/DiaryEntry.swift index 2ec2768..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, createdAt: Date? = nil) -> Self { + func new( + title: String, + content: String, + createdAt: Date? = nil, + mood: DiaryEntryMood? = nil + ) -> Self { return .init( id: id, title: title, content: content, - createdAt: createdAt ?? self.createdAt + createdAt: createdAt ?? self.createdAt, + mood: mood ?? self.mood ) } } diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift index 8b530b5..bc374e0 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryView.swift @@ -1,11 +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 - /// Focus state for tracking which field is being edited @FocusState private var focusedField: Field? enum Field: Equatable { @@ -13,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)) } @@ -36,6 +31,20 @@ struct DiaryEntryView: ContextualView { } } + 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) @@ -85,6 +94,9 @@ struct DiaryEntryView: ContextualView { ) { DiaryEntryDateSelectionView(viewModel.context) } + .sheet(isPresented: bindTo(\.isMoodSelectionPresented) { _ in }) { + DiaryEntryMoodSelectionView(viewModel.context) + } if state.savingStatus == .saving { Color.black.opacity(0.2) diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntry/DiaryEntryViewModel.swift index 74d6346..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,15 @@ final class DiaryEntryViewModel: ContextViewModel 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..2e51eaa --- /dev/null +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import Chiui + +final class DiaryEntryMoodSelectionViewModel: ContextViewModel { + struct State: ContextualViewState { + var selectedMood: DiaryEntryMood = .okay + + init() {} + } + + 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/Store/DiaryStoreState.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift index 3ebb294..486d4ef 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift @@ -1,13 +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? = nil + var entryDraftMood: DiaryEntryMood? = nil var isSelectingEntryDate: Bool = false + var isSelectingEntryMood: Bool = false var isSavingChanges: Bool = false } 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) + } +} From 819f92eb4b5f29ea08d1185d0c595cf3e4460b5c Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 21 Apr 2026 13:59:21 +0200 Subject: [PATCH 10/11] Cleaned up comments from blanche --- .../Screens/DiaryList/DiaryListView.swift | 4 -- .../DiaryList/DiaryListViewModel.swift | 12 ---- .../Flows/Main/Store/DiaryContext.swift | 7 --- .../Flows/Main/Store/DiaryLoadingClient.swift | 2 - .../Flows/Main/Store/DiaryStoreState.swift | 4 -- .../DiaryAppTests/DiaryAppTests.swift | 16 ----- .../DiaryEntryViewModelTests.swift | 58 +++++++++++-------- .../DiaryAppUITests/DiaryAppUITests.swift | 10 +--- .../DiaryAppUITestsLaunchTests.swift | 3 - 9 files changed, 36 insertions(+), 80 deletions(-) delete mode 100644 Examples/DiaryApp/DiaryAppTests/DiaryAppTests.swift diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift index ff8c00e..180bffd 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListView.swift @@ -1,13 +1,9 @@ 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)) } diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryList/DiaryListViewModel.swift index 6d6bc21..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 - - /// 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 486d4ef..d92a0f0 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift @@ -30,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/DiaryEntryViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift index 57db40c..b5b3a55 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift @@ -31,26 +31,23 @@ final class DiaryEntryViewModelTests: XCTestCase { 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, "") } @MainActor func testIsSavingDisabled() async { - // Empty title should disable saving sut.updateTitle("") XCTAssertTrue(sut.viewState.isSavingDisabled) - // Non-empty title should enable saving sut.updateTitle("Test Title") XCTAssertFalse(sut.viewState.isSavingDisabled) - // Saving in progress should disable saving await sut.finishEditing(save: true) XCTAssertFalse(sut.viewState.isSavingDisabled) } - // MARK: - Action Tests - @MainActor func testUpdateTitle() async { sut.startEditing() @@ -74,20 +71,17 @@ final class DiaryEntryViewModelTests: XCTestCase { @MainActor func testFinishEditingWithoutSave() async { - // Setup initial editing state sut.startEditing() sut.updateTitle("Test Title") sut.updateContent("Test Content") XCTAssertTrue(sut.viewState.isEditing) - // Cancel editing await sut.finishEditing(save: false) XCTAssertFalse(sut.viewState.isEditing) } @MainActor func testFinishEditingWithSave() async { - // Setup initial editing state sut.updateTitle("Test Title") sut.updateContent("Test Content") try? await Task.sleep(for: .seconds(0.1)) @@ -97,7 +91,6 @@ final class DiaryEntryViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) XCTAssertTrue(sut.viewState.isEditing) - // Save entry await sut.finishEditing(save: true) XCTAssertEqual(sut.viewState.savingStatus, .saved) XCTAssertFalse(sut.viewState.isEditing) @@ -113,21 +106,19 @@ final class DiaryEntryViewModelTests: XCTestCase { 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)) 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 @@ -136,32 +127,32 @@ final class DiaryEntryViewModelTests: XCTestCase { 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 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 @@ -170,46 +161,67 @@ final class DiaryEntryViewModelTests: XCTestCase { 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") - // 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") 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, "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) diff --git a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift index 0599b2e..f4b7b63 100644 --- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift +++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITests.swift @@ -10,16 +10,10 @@ 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 @@ -29,7 +23,6 @@ final class DiaryAppUITests: XCTestCase { XCTAssertTrue(app.navigationBars["Diary"].waitForExistence(timeout: 2)) - // Open add-entry flow from the trailing navigation bar button. let addButton = app.navigationBars["Diary"].buttons.element(boundBy: 0) XCTAssertTrue(addButton.waitForExistence(timeout: 2)) addButton.tap() @@ -44,13 +37,12 @@ final class DiaryAppUITests: XCTestCase { XCTAssertTrue(saveButton.isEnabled) saveButton.tap() - XCTAssertTrue(app.buttons["UI Test Entry"].waitForExistence(timeout: 5)) + 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 48f0c81..8602594 100644 --- a/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift +++ b/Examples/DiaryApp/DiaryAppUITests/DiaryAppUITestsLaunchTests.swift @@ -23,9 +23,6 @@ class DiaryAppUITestsLaunchTests: XCTestCase { app.launch() XCTAssertTrue(app.navigationBars["Diary"].waitForExistence(timeout: 2)) - // 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 - let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "Launch Screen" attachment.lifetime = .keepAlways From b5d04df65967a904eedc88f9664d7be072ef2183 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 21 Apr 2026 14:04:34 +0200 Subject: [PATCH 11/11] Linting issues --- .../DiaryEntryDateSelectionViewModel.swift | 2 -- .../DiaryEntryMoodSelectionViewModel.swift | 2 -- .../DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift | 4 ++-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionViewModel.swift index 066c624..be7c450 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionViewModel.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryDateSelection/DiaryEntryDateSelectionViewModel.swift @@ -4,8 +4,6 @@ import Chiui final class DiaryEntryDateSelectionViewModel: ContextViewModel { struct State: ContextualViewState { var selectedDate: Date = .now - - init() {} } nonisolated override func didStoreUpdate(_ storeState: DiaryStoreState) async { diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift index 2e51eaa..eac8c44 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Screens/DiaryEntryMoodSelection/DiaryEntryMoodSelectionViewModel.swift @@ -4,8 +4,6 @@ import Chiui final class DiaryEntryMoodSelectionViewModel: ContextViewModel { struct State: ContextualViewState { var selectedMood: DiaryEntryMood = .okay - - init() {} } nonisolated override func didStoreUpdate(_ storeState: DiaryStoreState) async { diff --git a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift index d92a0f0..3818ab3 100644 --- a/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift +++ b/Examples/DiaryApp/DiaryApp/Flows/Main/Store/DiaryStoreState.swift @@ -4,8 +4,8 @@ import Chiui struct DiaryStoreState: ContextualStoreState { var entries: [DiaryEntry] = [] var entrySelectionMode: EntrySelectionMode = .no - var entryDraftDate: Date? = nil - var entryDraftMood: DiaryEntryMood? = nil + var entryDraftDate: Date? + var entryDraftMood: DiaryEntryMood? var isSelectingEntryDate: Bool = false var isSelectingEntryMood: Bool = false var isSavingChanges: Bool = false