import ComposableArchitecture2 import ComposableAuthoring import ComposableAuthoringWorkspace import ComposableWorkspace import Dependencies import Foundation import Sharing public struct DemoSession: Equatable, Sendable { public var displayName: String public init(displayName: String) { self.displayName = displayName } } public struct DemoGrant: Equatable, Sendable { public var author: String public init(author: String) { self.author = author } } public enum DemoContext: Equatable, Sendable { case festival } public struct DemoDocument: Codable, Equatable, Sendable { public var performanceTime: String public var stageName: String public init(performanceTime: String, stageName: String) { self.performanceTime = performanceTime self.stageName = stageName } } public struct DemoChangeSet: Codable, Equatable, Sendable { public var document: DemoDocument public init(document: DemoDocument) { self.document = document } } public struct DemoAuthenticationClient: Sendable { public var signIn: @Sendable () async throws -> DemoSession public var signOut: @Sendable (DemoSession) async throws -> Void public init( signIn: @escaping @Sendable () async throws -> DemoSession, signOut: @escaping @Sendable (DemoSession) async throws -> Void ) { self.signIn = signIn self.signOut = signOut } } extension DemoAuthenticationClient: TestDependencyKey { public static var testValue: Self { Self( signIn: { throw DemoSystemError.unimplemented }, signOut: { _ in throw DemoSystemError.unimplemented } ) } } enum DemoSessionChanged: FeatureEventKey { typealias Value = DemoSession? } public struct DemoProposalClient: Sendable { public var merge: @Sendable () async throws -> Void public init(merge: @escaping @Sendable () async throws -> Void) { self.merge = merge } } extension DemoProposalClient: TestDependencyKey { public static var testValue: Self { Self(merge: { throw DemoSystemError.unimplemented }) } } @Feature public struct DemoAuthentication { @Dependency(DemoAuthenticationClient.self) var client public init() {} public struct State { @StoreTaskID public var request @Shared public var session: DemoSession? public init(session: Shared) { self._session = session } public var phase: Phase { if request.isRunning { return session == nil ? .signingIn : .signingOut(session!) } return session.map(Phase.signedIn) ?? .signedOut } } public enum Phase: Equatable, Sendable { case signedIn(DemoSession) case signingIn case signingOut(DemoSession) case signedOut } public enum Action { case signInButtonTapped case signOutButtonTapped } public var body: some Feature { Update { state, action in switch action { case .signInButtonTapped: guard state.session == nil, !state.request.isRunning else { return } let client = client store.addTask(id: state.request) { let session = try await client.signIn() try Task.checkCancellation() var didSignIn = false let transition = try store.modify { guard $0.session == nil else { return } $0.$session.withLock { $0 = session } didSignIn = true } await transition?.value guard didSignIn else { return } let sessionChange = try store.post( key: DemoSessionChanged.self, value: session ) await sessionChange?.value } case .signOutButtonTapped: guard let session = state.session, !state.request.isRunning else { return } let client = client store.addTask(id: state.request) { try await client.signOut(session) try Task.checkCancellation() var didSignOut = false let transition = try store.modify { guard $0.session == session else { return } $0.$session.withLock { $0 = nil } didSignOut = true } await transition?.value guard didSignOut else { return } let sessionChange = try store.post( key: DemoSessionChanged.self, value: nil ) await sessionChange?.value } } } } } @Feature public struct DemoEventEditor { public init() {} public struct State { @Shared public var document: DemoDocument public var recordedProposal: WorkspaceProposal? @Trigger public var proposalRecorded public init(document: Shared) { self._document = document } } public enum Action { case movePerformanceButtonTapped case renameStageButtonTapped } public var body: some Feature { Update { state, action in switch action { case .movePerformanceButtonTapped: state.$document.withLock { $0.performanceTime = "11:00 AM" } case .renameStageButtonTapped: state.$document.withLock { $0.stageName = $0.stageName == "Meadow" ? "Meadow Stage" : "Forest Stage" } } } .onTrigger(store.proposalRecorded) { proposal, state in state.recordedProposal = proposal } } } @Feature public struct WorkspaceDemo { public typealias Editing = AuthoringWorkspace< DemoEventEditor, DemoContext, DemoSession, DemoGrant, WorkspaceDemoSystem, DemoChangeSet > @Dependency(DemoProposalClient.self) var proposalClient public init() {} public struct State { public var authentication: DemoAuthentication.State public var editing: Editing.State public init( session: Shared, grant: Shared, document: Shared ) { self.authentication = DemoAuthentication.State(session: session) self.editing = Editing.State( context: .festival, grant: grant ) { Workspace.State( workspaceID: WorkspaceID(rawValue: "demo-festival") ) { DemoEventEditor.State(document: document) } } } } public enum Action { case applicationStarted case authentication(DemoAuthentication.Action) case editing(Editing.Action) case mergeProposalButtonTapped } public var body: some Feature { Scope(\.authentication, action: \.authentication) { DemoAuthentication() } Scope(\.editing, action: \.editing) { AuthoringWorkspace(proposalRecordedTrigger: \.proposalRecorded) { DemoEventEditor() } } Update { state, action in switch action { case .applicationStarted: store.addTask { let restoration = try store.send( .editing(.authoring(.restoreAuthorization)) ) await restoration?.value } case .authentication, .editing: break case .mergeProposalButtonTapped: guard state.editing.content.content.recordedProposal != nil else { return } let proposalClient = proposalClient store.addTask { try await proposalClient.merge() let selection = try store.send( .editing( .authoring( .content(.contextRefreshRequested) ) ) ) await selection?.value } } } .onEvent(DemoSessionChanged.self) { session, _ in store.addTask { let invalidation = try store.send( .editing(.authoring(.authorizationInvalidated)) ) await invalidation?.value if session != nil { let refresh = try store.send( .editing(.authoring(.refreshAdmission)) ) await refresh?.value } } } } } public actor WorkspaceDemoSystem { private let document: SharedReader private let grant: SharedReader private let session: SharedReader private var prepared = false private var proposal: WorkspaceProposal? private var proposalDocument: DemoDocument? private var referenceDocument = DemoDocument( performanceTime: "10:00 AM", stageName: "Meadow" ) private var baseline = WorkspaceBranchReference( branch: WorkspaceBranch(rawValue: "main"), revision: WorkspaceRevision(rawValue: "main-1") ) private var revision = 1 public init( session: SharedReader, grant: SharedReader, document: SharedReader ) { self.session = session self.grant = grant self.document = document } public nonisolated var authenticationClient: DemoAuthenticationClient { DemoAuthenticationClient( signIn: { DemoSession(displayName: "Festival Editor") }, signOut: { _ in } ) } public nonisolated var authoringClient: AuthoringClient { AuthoringClient( admission: { _ in guard let session = self.session.wrappedValue else { return .unavailable } return .eligible(session) }, enter: { _, eligibility in guard self.session.wrappedValue == eligibility else { throw DemoSystemError.signedOut } return DemoGrant(author: eligibility.displayName) }, exit: { _, _ in }, restore: { _ in guard let session = self.session.wrappedValue else { return nil } return self.grant.wrappedValue.map { _ in DemoGrant(author: session.displayName) } } ) } public nonisolated var proposalClient: DemoProposalClient { DemoProposalClient(merge: { try await self.mergeProposal() }) } public nonisolated var workspaceClient: WorkspaceClient { WorkspaceClient( prepareSource: { workspaceID in let baseline = try await self.prepareSource(workspaceID: workspaceID) return WorkspaceSession(source: self, baseline: baseline) }, captureChanges: { source, workspaceID in return try await source.captureChanges( workspaceID: workspaceID ) }, publishProposal: { source, workspaceID, attempt in return try await source.publishProposal( workspaceID: workspaceID, attempt: attempt ) }, publishChanges: { source, workspaceID, attempt in return try await source.publishChanges( workspaceID: workspaceID, attempt: attempt ) }, reconcile: { current, workspaceID in return try await current.source.reconcile( workspaceID: workspaceID, current: current ) }, discardChangesAndUpdate: { current, workspaceID in return try await current.source.discardChangesAndUpdate( workspaceID: workspaceID, current: current ) }, selectBranch: { source, workspaceID, branch in return try await source.selectBranch( workspaceID: workspaceID, branch: branch ) } ) } private func authorize(_ workspaceID: WorkspaceID) throws { guard workspaceID == WorkspaceID(rawValue: "demo-festival") else { throw DemoSystemError.wrongWorkspace } guard session.wrappedValue != nil, grant.wrappedValue != nil else { throw WorkspaceAuthorizationError.invalidated } } private func captureChanges( workspaceID: WorkspaceID ) throws -> WorkspaceChangeCapture { try authorize(workspaceID) return document.wrappedValue == referenceDocument ? .noChanges : .changes(DemoChangeSet(document: document.wrappedValue)) } private func prepareSource( workspaceID: WorkspaceID ) throws -> WorkspaceBranchReference { try authorize(workspaceID) prepared = true return baseline } private func publishProposal( workspaceID: WorkspaceID, attempt: WorkspaceProposalAttempt ) throws -> WorkspaceProposalPublication { try authorize(workspaceID) guard prepared else { throw DemoSystemError.sourceNotPrepared } revision += 1 let revisionNumber = revision let proposalRevision = WorkspaceRevision(rawValue: "proposal-\(revisionNumber)") let publication = WorkspaceProposal( id: WorkspaceProposalID(rawValue: "demo-proposal-\(revisionNumber)"), revision: proposalRevision, target: attempt.target ) proposal = publication proposalDocument = attempt.changes.document return WorkspaceProposalPublication(proposal: publication) } private func publishChanges( workspaceID: WorkspaceID, attempt: WorkspacePublicationAttempt ) throws -> WorkspacePublishedChanges { try authorize(workspaceID) guard prepared else { throw DemoSystemError.sourceNotPrepared } guard baseline == attempt.target else { throw DemoSystemError.contextDoesNotMatchReference } revision += 1 referenceDocument = attempt.changes.document let published = WorkspaceBranchReference( branch: attempt.target.branch, revision: WorkspaceRevision(rawValue: "published-\(revision)") ) baseline = published return WorkspacePublishedChanges( published: published, session: WorkspaceSession(source: self, baseline: baseline) ) } private func mergeProposal() throws { guard session.wrappedValue != nil, grant.wrappedValue != nil else { throw WorkspaceAuthorizationError.invalidated } guard proposal != nil, proposalDocument != nil else { throw DemoSystemError.proposalMissing } revision += 1 referenceDocument = proposalDocument! baseline = WorkspaceBranchReference( branch: baseline.branch, revision: WorkspaceRevision(rawValue: "main-\(revision)") ) self.proposal = nil self.proposalDocument = nil } private func reconcile( workspaceID: WorkspaceID, current: WorkspaceSession ) throws -> WorkspaceReconciliationResult { try authorize(workspaceID) guard baseline.branch == current.baseline.branch else { throw DemoSystemError.contextDoesNotMatchReference } return .current(WorkspaceSession(source: self, baseline: baseline)) } private func discardChangesAndUpdate( workspaceID: WorkspaceID, current: WorkspaceSession ) throws -> WorkspaceSession { try authorize(workspaceID) guard baseline.branch == current.baseline.branch else { throw DemoSystemError.contextDoesNotMatchReference } return WorkspaceSession(source: self, baseline: baseline) } private func selectBranch( workspaceID: WorkspaceID, branch: WorkspaceBranch ) throws -> WorkspaceBranchReference { try authorize(workspaceID) baseline = WorkspaceBranchReference( branch: branch, revision: WorkspaceRevision(rawValue: "\(branch.rawValue)-\(revision)") ) return baseline } } @MainActor public func makeWorkspaceDemoStore() -> StoreOf { let session = Shared(value: nil) let grant = Shared(value: nil) let document = Shared( value: DemoDocument( performanceTime: "10:00 AM", stageName: "Meadow" ) ) let system = WorkspaceDemoSystem( session: SharedReader(session), grant: SharedReader(grant), document: SharedReader(document) ) return Store( initialState: WorkspaceDemo.State( session: session, grant: grant, document: document ) ) { WorkspaceDemo() .dependency(system.authenticationClient) .dependency(system.authoringClient) .dependency(system.proposalClient) .dependency(system.workspaceClient) } } private enum DemoSystemError: Error { case proposalMissing case contextDoesNotMatchReference case signedOut case sourceNotPrepared case unimplemented case wrongWorkspace }