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