diff --git a/Examples/Search/Search/SearchView.swift b/Examples/Search/Search/SearchView.swift index e7ecd1f..87bcf2c 100644 --- a/Examples/Search/Search/SearchView.swift +++ b/Examples/Search/Search/SearchView.swift @@ -23,7 +23,7 @@ struct SearchView: View { Image(systemName: "magnifyingglass") TextField( "New York, San Francisco, ...", - text: Binding { + text: Binding { state.searchQuery } set: { text in $state.searchQueryChanged(query: text) diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift index b0b9179..38fc5a5 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Client.swift @@ -1,90 +1,90 @@ -import VDStore import Speech +import VDStore struct SpeechClient { - var finishTask: @Sendable () async -> Void = { } - var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus = { .notDetermined } - var startTask: - @Sendable (_ request: SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< - SpeechRecognitionResult, Error - > = { _ in - AsyncThrowingStream { nil } - } + var finishTask: @Sendable () async -> Void = {} + var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus = { .notDetermined } + var startTask: + @Sendable (_ request: SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< + SpeechRecognitionResult, Error + > = { _ in + AsyncThrowingStream { nil } + } - enum Failure: Error, Equatable { - case taskError - case couldntStartAudioEngine - case couldntConfigureAudioSession - } + enum Failure: Error, Equatable { + case taskError + case couldntStartAudioEngine + case couldntConfigureAudioSession + } } extension SpeechClient { - static let previewValue: Self = { - let isRecording = ActorIsolated(false) + static let previewValue: Self = { + let isRecording = ActorIsolated(false) - return Self( - finishTask: { await isRecording.set(false) }, - requestAuthorization: { .authorized }, - startTask: { _ in - AsyncThrowingStream { continuation in - Task { - await isRecording.set(true) - var finalText = """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ - officia deserunt mollit anim id est laborum. - """ - var text = "" - while await isRecording.value { - let word = finalText.prefix { $0 != " " } - try await Task.sleep(for: .milliseconds(word.count * 50 + .random(in: 0...200))) - finalText.removeFirst(word.count) - if finalText.first == " " { - finalText.removeFirst() - } - text += word + " " - continuation.yield( - SpeechRecognitionResult( - bestTranscription: Transcription( - formattedString: text, - segments: [] - ), - isFinal: false, - transcriptions: [] - ) - ) - } - } - } - } - ) - }() + return Self( + finishTask: { await isRecording.set(false) }, + requestAuthorization: { .authorized }, + startTask: { _ in + AsyncThrowingStream { continuation in + Task { + await isRecording.set(true) + var finalText = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ + officia deserunt mollit anim id est laborum. + """ + var text = "" + while await isRecording.value { + let word = finalText.prefix { $0 != " " } + try await Task.sleep(for: .milliseconds(word.count * 50 + .random(in: 0 ... 200))) + finalText.removeFirst(word.count) + if finalText.first == " " { + finalText.removeFirst() + } + text += word + " " + continuation.yield( + SpeechRecognitionResult( + bestTranscription: Transcription( + formattedString: text, + segments: [] + ), + isFinal: false, + transcriptions: [] + ) + ) + } + } + } + } + ) + }() } final actor ActorIsolated { - - var value: T - - init(_ value: T) { - self.value = value - } - - func `set`(_ value: T) { - self.value = value - } + + var value: T + + init(_ value: T) { + self.value = value + } + + func set(_ value: T) { + self.value = value + } } extension StoreDIValues { - @StoreDIValue - var speechClient: SpeechClient = valueFor( - live: .liveValue, - test: SpeechClient(), - preview: .previewValue - ) + @StoreDIValue + var speechClient: SpeechClient = valueFor( + live: .liveValue, + test: SpeechClient(), + preview: .previewValue + ) } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift index 14dc5e7..faa6d54 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift @@ -1,97 +1,97 @@ -import VDStore import Speech +import VDStore extension SpeechClient { - static let liveValue: Self = { - let speech = Speech() - return Self( - finishTask: { - await speech.finishTask() - }, - requestAuthorization: { - await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status) - } - } - }, - startTask: { request in - await speech.startTask(request: request) - } - ) - }() + static let liveValue: Self = { + let speech = Speech() + return Self( + finishTask: { + await speech.finishTask() + }, + requestAuthorization: { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + }, + startTask: { request in + await speech.startTask(request: request) + } + ) + }() } private actor Speech { - var audioEngine: AVAudioEngine? = nil - var recognitionTask: SFSpeechRecognitionTask? = nil - var recognitionContinuation: AsyncThrowingStream.Continuation? + var audioEngine: AVAudioEngine? + var recognitionTask: SFSpeechRecognitionTask? + var recognitionContinuation: AsyncThrowingStream.Continuation? - func finishTask() { - self.audioEngine?.stop() - self.audioEngine?.inputNode.removeTap(onBus: 0) - self.recognitionTask?.finish() - self.recognitionContinuation?.finish() - } + func finishTask() { + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + recognitionTask?.finish() + recognitionContinuation?.finish() + } - func startTask( - request: SFSpeechAudioBufferRecognitionRequest - ) -> AsyncThrowingStream { + func startTask( + request: SFSpeechAudioBufferRecognitionRequest + ) -> AsyncThrowingStream { - return AsyncThrowingStream { continuation in - self.recognitionContinuation = continuation - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - } catch { - continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) - return - } + AsyncThrowingStream { continuation in + self.recognitionContinuation = continuation + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) + return + } - self.audioEngine = AVAudioEngine() - let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! - self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in - switch (result, error) { - case let (.some(result), _): - continuation.yield(SpeechRecognitionResult(result)) - case (_, .some): - continuation.finish(throwing: SpeechClient.Failure.taskError) - case (.none, .none): - fatalError("It should not be possible to have both a nil result and nil error.") - } - } + self.audioEngine = AVAudioEngine() + let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! + self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in + switch (result, error) { + case let (.some(result), _): + continuation.yield(SpeechRecognitionResult(result)) + case (_, .some): + continuation.finish(throwing: SpeechClient.Failure.taskError) + case (.none, .none): + fatalError("It should not be possible to have both a nil result and nil error.") + } + } - continuation.onTermination = { - [ - speechRecognizer = speechRecognizer, - audioEngine = audioEngine, - recognitionTask = recognitionTask - ] - _ in + continuation.onTermination = { + [ + speechRecognizer = speechRecognizer, + audioEngine = audioEngine, + recognitionTask = recognitionTask + ] + _ in - _ = speechRecognizer - audioEngine?.stop() - audioEngine?.inputNode.removeTap(onBus: 0) - recognitionTask?.finish() - } + _ = speechRecognizer + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + recognitionTask?.finish() + } - self.audioEngine?.inputNode.installTap( - onBus: 0, - bufferSize: 1024, - format: self.audioEngine?.inputNode.outputFormat(forBus: 0) - ) { buffer, when in - request.append(buffer) - } + self.audioEngine?.inputNode.installTap( + onBus: 0, + bufferSize: 1024, + format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + ) { buffer, _ in + request.append(buffer) + } - self.audioEngine?.prepare() - do { - try self.audioEngine?.start() - } catch { - continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) - return - } - } - } + self.audioEngine?.prepare() + do { + try self.audioEngine?.start() + } catch { + continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) + return + } + } + } } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift index 6347cc7..b8bac07 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Models.swift @@ -5,90 +5,90 @@ import Speech // them easier to use and test. struct SpeechRecognitionMetadata: Equatable { - var averagePauseDuration: TimeInterval - var speakingRate: Double - var voiceAnalytics: VoiceAnalytics? + var averagePauseDuration: TimeInterval + var speakingRate: Double + var voiceAnalytics: VoiceAnalytics? } struct SpeechRecognitionResult: Equatable { - var bestTranscription: Transcription - var isFinal: Bool - var speechRecognitionMetadata: SpeechRecognitionMetadata? - var transcriptions: [Transcription] + var bestTranscription: Transcription + var isFinal: Bool + var speechRecognitionMetadata: SpeechRecognitionMetadata? + var transcriptions: [Transcription] } struct Transcription: Equatable { - var formattedString: String - var segments: [TranscriptionSegment] + var formattedString: String + var segments: [TranscriptionSegment] } struct TranscriptionSegment: Equatable { - var alternativeSubstrings: [String] - var confidence: Float - var duration: TimeInterval - var substring: String - var timestamp: TimeInterval + var alternativeSubstrings: [String] + var confidence: Float + var duration: TimeInterval + var substring: String + var timestamp: TimeInterval } struct VoiceAnalytics: Equatable { - var jitter: AcousticFeature - var pitch: AcousticFeature - var shimmer: AcousticFeature - var voicing: AcousticFeature + var jitter: AcousticFeature + var pitch: AcousticFeature + var shimmer: AcousticFeature + var voicing: AcousticFeature } struct AcousticFeature: Equatable { - var acousticFeatureValuePerFrame: [Double] - var frameDuration: TimeInterval + var acousticFeatureValuePerFrame: [Double] + var frameDuration: TimeInterval } extension SpeechRecognitionMetadata { - init(_ speechRecognitionMetadata: SFSpeechRecognitionMetadata) { - self.averagePauseDuration = speechRecognitionMetadata.averagePauseDuration - self.speakingRate = speechRecognitionMetadata.speakingRate - self.voiceAnalytics = speechRecognitionMetadata.voiceAnalytics.map(VoiceAnalytics.init) - } + init(_ speechRecognitionMetadata: SFSpeechRecognitionMetadata) { + averagePauseDuration = speechRecognitionMetadata.averagePauseDuration + speakingRate = speechRecognitionMetadata.speakingRate + voiceAnalytics = speechRecognitionMetadata.voiceAnalytics.map(VoiceAnalytics.init) + } } extension SpeechRecognitionResult { - init(_ speechRecognitionResult: SFSpeechRecognitionResult) { - self.bestTranscription = Transcription(speechRecognitionResult.bestTranscription) - self.isFinal = speechRecognitionResult.isFinal - self.speechRecognitionMetadata = speechRecognitionResult.speechRecognitionMetadata - .map(SpeechRecognitionMetadata.init) - self.transcriptions = speechRecognitionResult.transcriptions.map(Transcription.init) - } + init(_ speechRecognitionResult: SFSpeechRecognitionResult) { + bestTranscription = Transcription(speechRecognitionResult.bestTranscription) + isFinal = speechRecognitionResult.isFinal + speechRecognitionMetadata = speechRecognitionResult.speechRecognitionMetadata + .map(SpeechRecognitionMetadata.init) + transcriptions = speechRecognitionResult.transcriptions.map(Transcription.init) + } } extension Transcription { - init(_ transcription: SFTranscription) { - self.formattedString = transcription.formattedString - self.segments = transcription.segments.map(TranscriptionSegment.init) - } + init(_ transcription: SFTranscription) { + formattedString = transcription.formattedString + segments = transcription.segments.map(TranscriptionSegment.init) + } } extension TranscriptionSegment { - init(_ transcriptionSegment: SFTranscriptionSegment) { - self.alternativeSubstrings = transcriptionSegment.alternativeSubstrings - self.confidence = transcriptionSegment.confidence - self.duration = transcriptionSegment.duration - self.substring = transcriptionSegment.substring - self.timestamp = transcriptionSegment.timestamp - } + init(_ transcriptionSegment: SFTranscriptionSegment) { + alternativeSubstrings = transcriptionSegment.alternativeSubstrings + confidence = transcriptionSegment.confidence + duration = transcriptionSegment.duration + substring = transcriptionSegment.substring + timestamp = transcriptionSegment.timestamp + } } extension VoiceAnalytics { - init(_ voiceAnalytics: SFVoiceAnalytics) { - self.jitter = AcousticFeature(voiceAnalytics.jitter) - self.pitch = AcousticFeature(voiceAnalytics.pitch) - self.shimmer = AcousticFeature(voiceAnalytics.shimmer) - self.voicing = AcousticFeature(voiceAnalytics.voicing) - } + init(_ voiceAnalytics: SFVoiceAnalytics) { + jitter = AcousticFeature(voiceAnalytics.jitter) + pitch = AcousticFeature(voiceAnalytics.pitch) + shimmer = AcousticFeature(voiceAnalytics.shimmer) + voicing = AcousticFeature(voiceAnalytics.voicing) + } } extension AcousticFeature { - init(_ acousticFeature: SFAcousticFeature) { - self.acousticFeatureValuePerFrame = acousticFeature.acousticFeatureValuePerFrame - self.frameDuration = acousticFeature.frameDuration - } + init(_ acousticFeature: SFAcousticFeature) { + acousticFeatureValuePerFrame = acousticFeature.acousticFeatureValuePerFrame + frameDuration = acousticFeature.frameDuration + } } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift index 0bd170f..82647b1 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognition.swift @@ -1,143 +1,143 @@ -import VDStore import Speech import SwiftUI +import VDStore private let readMe = """ - This application demonstrates how to work with a complex dependency in the Composable \ - Architecture. It uses the `SFSpeechRecognizer` API from the Speech framework to listen to audio \ - on the device and live-transcribe it to the UI. - """ +This application demonstrates how to work with a complex dependency in the Composable \ +Architecture. It uses the `SFSpeechRecognizer` API from the Speech framework to listen to audio \ +on the device and live-transcribe it to the UI. +""" // MARK: - State struct SpeechRecognition: Equatable { - var alert: String? - var isRecording = false - var transcribedText = "" + var alert: String? + var isRecording = false + var transcribedText = "" } // MARK: - Actions extension Store { - func recordButtonTapped() async { - state.isRecording.toggle() - if state.isRecording { - do { - try await startRecording() - } catch { - speechFailed(failure: error) - } - } else { - await di.speechClient.finishTask() - } - } - - func startRecording() async throws { - let status = await di.speechClient.requestAuthorization() - speechRecognizerAuthorizationStatusResponse(status: status) - - guard status == .authorized - else { return } - - let request = SFSpeechAudioBufferRecognitionRequest() - for try await result in await di.speechClient.startTask(request) { - state.transcribedText = result.bestTranscription.formattedString - } - } - - func speechFailed(failure: Error) { - switch failure { - case SpeechClient.Failure.couldntConfigureAudioSession, - SpeechClient.Failure.couldntStartAudioEngine: - state.alert = "Problem with audio device. Please try again." - default: - state.alert = "An error occurred while transcribing. Please try again." - } - } - - func speechRecognizerAuthorizationStatusResponse(status: SFSpeechRecognizerAuthorizationStatus) { - state.isRecording = status == .authorized - - switch status { - case .denied: - state.alert = """ - You denied access to speech recognition. This app needs access to transcribe your \ - speech. - """ - - case .restricted: - state.alert = "Your device does not allow speech recognition." - default: - break - } - } + func recordButtonTapped() async { + state.isRecording.toggle() + if state.isRecording { + do { + try await startRecording() + } catch { + speechFailed(failure: error) + } + } else { + await di.speechClient.finishTask() + } + } + + func startRecording() async throws { + let status = await di.speechClient.requestAuthorization() + speechRecognizerAuthorizationStatusResponse(status: status) + + guard status == .authorized + else { return } + + let request = SFSpeechAudioBufferRecognitionRequest() + for try await result in await di.speechClient.startTask(request) { + state.transcribedText = result.bestTranscription.formattedString + } + } + + func speechFailed(failure: Error) { + switch failure { + case SpeechClient.Failure.couldntConfigureAudioSession, + SpeechClient.Failure.couldntStartAudioEngine: + state.alert = "Problem with audio device. Please try again." + default: + state.alert = "An error occurred while transcribing. Please try again." + } + } + + func speechRecognizerAuthorizationStatusResponse(status: SFSpeechRecognizerAuthorizationStatus) { + state.isRecording = status == .authorized + + switch status { + case .denied: + state.alert = """ + You denied access to speech recognition. This app needs access to transcribe your \ + speech. + """ + + case .restricted: + state.alert = "Your device does not allow speech recognition." + default: + break + } + } } // MARK: - View struct SpeechRecognitionView: View { - @ViewStore var state = SpeechRecognition() - - var body: some View { - VStack { - VStack(alignment: .leading) { - Text(readMe) - .padding(.bottom, 32) - } - - ScrollView { - ScrollViewReader { proxy in - Text(state.transcribedText) - .font(.largeTitle) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - - Spacer() - - Button { - Task { - await $state.recordButtonTapped() - } - } label: { - HStack { - Image( - systemName: state.isRecording - ? "stop.circle.fill" : "arrowtriangle.right.circle.fill" - ) - .font(.title) - Text(state.isRecording ? "Stop Recording" : "Start Recording") - } - .foregroundColor(.white) - .padding() - .background(state.isRecording ? Color.red : .green) - .cornerRadius(16) - } - } - .padding() - .animation(.linear, value: state.transcribedText) - .alert( - state.alert ?? "", - isPresented: Binding { - state.alert != nil - } set: { newValue in - if !newValue { - state.alert = nil - } - } - ) { - Button("OK") { - state.alert = nil - } - } - } + @ViewStore var state = SpeechRecognition() + + var body: some View { + VStack { + VStack(alignment: .leading) { + Text(readMe) + .padding(.bottom, 32) + } + + ScrollView { + ScrollViewReader { _ in + Text(state.transcribedText) + .font(.largeTitle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + Spacer() + + Button { + Task { + await $state.recordButtonTapped() + } + } label: { + HStack { + Image( + systemName: state.isRecording + ? "stop.circle.fill" : "arrowtriangle.right.circle.fill" + ) + .font(.title) + Text(state.isRecording ? "Stop Recording" : "Start Recording") + } + .foregroundColor(.white) + .padding() + .background(state.isRecording ? Color.red : .green) + .cornerRadius(16) + } + } + .padding() + .animation(.linear, value: state.transcribedText) + .alert( + state.alert ?? "", + isPresented: Binding { + state.alert != nil + } set: { newValue in + if !newValue { + state.alert = nil + } + } + ) { + Button("OK") { + state.alert = nil + } + } + } } #Preview { - SpeechRecognitionView( - state: SpeechRecognition(transcribedText: "Test test 123") - ) + SpeechRecognitionView( + state: SpeechRecognition(transcribedText: "Test test 123") + ) } diff --git a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift index 84db7fb..4a109c2 100644 --- a/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift +++ b/Examples/SpeechRecognition/SpeechRecognition/SpeechRecognitionApp.swift @@ -1,11 +1,11 @@ -import VDStore import SwiftUI +import VDStore @main struct SpeechRecognitionApp: App { - var body: some Scene { - WindowGroup { - SpeechRecognitionView() - } - } + var body: some Scene { + WindowGroup { + SpeechRecognitionView() + } + } } diff --git a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift index ddf526c..c8db0d1 100644 --- a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift +++ b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift @@ -4,132 +4,132 @@ import XCTest @testable import SpeechRecognition final class SpeechRecognitionTests: XCTestCase { - @MainActor - func testDenyAuthorization() async { - let store = TestStore(initialState: SpeechRecognition.State()) { - SpeechRecognition() - } withDependencies: { - $0.speechClient.requestAuthorization = { .denied } - } - - await store.send(.recordButtonTapped) { - $0.isRecording = true - } - await store.receive(\.speechRecognizerAuthorizationStatusResponse) { - $0.alert = AlertState { - TextState( - """ - You denied access to speech recognition. This app needs access to transcribe your speech. - """ - ) - } - $0.isRecording = false - } - } - - @MainActor - func testRestrictedAuthorization() async { - let store = TestStore(initialState: SpeechRecognition.State()) { - SpeechRecognition() - } withDependencies: { - $0.speechClient.requestAuthorization = { .restricted } - } - - await store.send(.recordButtonTapped) { - $0.isRecording = true - } - await store.receive(\.speechRecognizerAuthorizationStatusResponse) { - $0.alert = AlertState { TextState("Your device does not allow speech recognition.") } - $0.isRecording = false - } - } - - @MainActor - func testAllowAndRecord() async { - let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) - let store = TestStore(initialState: SpeechRecognition.State()) { - SpeechRecognition() - } withDependencies: { - $0.speechClient.finishTask = { recognitionTask.continuation.finish() } - $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } - $0.speechClient.requestAuthorization = { .authorized } - } - - let firstResult = SpeechRecognitionResult( - bestTranscription: Transcription( - formattedString: "Hello", - segments: [] - ), - isFinal: false, - transcriptions: [] - ) - var secondResult = firstResult - secondResult.bestTranscription.formattedString = "Hello world" - - await store.send(.recordButtonTapped) { - $0.isRecording = true - } - - await store.receive(\.speechRecognizerAuthorizationStatusResponse) - - recognitionTask.continuation.yield(firstResult) - await store.receive(\.speech.success) { - $0.transcribedText = "Hello" - } - - recognitionTask.continuation.yield(secondResult) - await store.receive(\.speech.success) { - $0.transcribedText = "Hello world" - } - - await store.send(.recordButtonTapped) { - $0.isRecording = false - } - - await store.finish() - } - - @MainActor - func testAudioSessionFailure() async { - let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) - let store = TestStore(initialState: SpeechRecognition.State()) { - SpeechRecognition() - } withDependencies: { - $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } - $0.speechClient.requestAuthorization = { .authorized } - } - - await store.send(.recordButtonTapped) { - $0.isRecording = true - } - - await store.receive(\.speechRecognizerAuthorizationStatusResponse) - - recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) - await store.receive(\.speech.failure) { - $0.alert = AlertState { TextState("Problem with audio device. Please try again.") } - } - } - - @MainActor - func testAudioEngineFailure() async { - let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) - let store = TestStore(initialState: SpeechRecognition.State()) { - SpeechRecognition() - } withDependencies: { - $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } - $0.speechClient.requestAuthorization = { .authorized } - } - - await store.send(.recordButtonTapped) { - $0.isRecording = true - } - - await store.receive(\.speechRecognizerAuthorizationStatusResponse) - - recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) - await store.receive(\.speech.failure) { - $0.alert = AlertState { TextState("Problem with audio device. Please try again.") } - } - } + @MainActor + func testDenyAuthorization() async { + let store = TestStore(initialState: SpeechRecognition.State()) { + SpeechRecognition() + } withDependencies: { + $0.speechClient.requestAuthorization = { .denied } + } + + await store.send(.recordButtonTapped) { + $0.isRecording = true + } + await store.receive(\.speechRecognizerAuthorizationStatusResponse) { + $0.alert = AlertState { + TextState( + """ + You denied access to speech recognition. This app needs access to transcribe your speech. + """ + ) + } + $0.isRecording = false + } + } + + @MainActor + func testRestrictedAuthorization() async { + let store = TestStore(initialState: SpeechRecognition.State()) { + SpeechRecognition() + } withDependencies: { + $0.speechClient.requestAuthorization = { .restricted } + } + + await store.send(.recordButtonTapped) { + $0.isRecording = true + } + await store.receive(\.speechRecognizerAuthorizationStatusResponse) { + $0.alert = AlertState { TextState("Your device does not allow speech recognition.") } + $0.isRecording = false + } + } + + @MainActor + func testAllowAndRecord() async { + let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) + let store = TestStore(initialState: SpeechRecognition.State()) { + SpeechRecognition() + } withDependencies: { + $0.speechClient.finishTask = { recognitionTask.continuation.finish() } + $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } + + let firstResult = SpeechRecognitionResult( + bestTranscription: Transcription( + formattedString: "Hello", + segments: [] + ), + isFinal: false, + transcriptions: [] + ) + var secondResult = firstResult + secondResult.bestTranscription.formattedString = "Hello world" + + await store.send(.recordButtonTapped) { + $0.isRecording = true + } + + await store.receive(\.speechRecognizerAuthorizationStatusResponse) + + recognitionTask.continuation.yield(firstResult) + await store.receive(\.speech.success) { + $0.transcribedText = "Hello" + } + + recognitionTask.continuation.yield(secondResult) + await store.receive(\.speech.success) { + $0.transcribedText = "Hello world" + } + + await store.send(.recordButtonTapped) { + $0.isRecording = false + } + + await store.finish() + } + + @MainActor + func testAudioSessionFailure() async { + let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) + let store = TestStore(initialState: SpeechRecognition.State()) { + SpeechRecognition() + } withDependencies: { + $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } + + await store.send(.recordButtonTapped) { + $0.isRecording = true + } + + await store.receive(\.speechRecognizerAuthorizationStatusResponse) + + recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntConfigureAudioSession) + await store.receive(\.speech.failure) { + $0.alert = AlertState { TextState("Problem with audio device. Please try again.") } + } + } + + @MainActor + func testAudioEngineFailure() async { + let recognitionTask = AsyncThrowingStream.makeStream(of: SpeechRecognitionResult.self) + let store = TestStore(initialState: SpeechRecognition.State()) { + SpeechRecognition() + } withDependencies: { + $0.speechClient.startTask = { @Sendable _ in recognitionTask.stream } + $0.speechClient.requestAuthorization = { .authorized } + } + + await store.send(.recordButtonTapped) { + $0.isRecording = true + } + + await store.receive(\.speechRecognizerAuthorizationStatusResponse) + + recognitionTask.continuation.finish(throwing: SpeechClient.Failure.couldntStartAudioEngine) + await store.receive(\.speech.failure) { + $0.alert = AlertState { TextState("Problem with audio device. Please try again.") } + } + } } diff --git a/Examples/SyncUps/SyncUps/App.swift b/Examples/SyncUps/SyncUps/App.swift index 3f62dec..a9aee1d 100644 --- a/Examples/SyncUps/SyncUps/App.swift +++ b/Examples/SyncUps/SyncUps/App.swift @@ -1,31 +1,31 @@ -import VDStore import SwiftUI +import VDStore @main struct SyncUpsApp: App { - let store = Store(AppFeature()) + let store = Store(AppFeature()) - var body: some Scene { - WindowGroup { - // NB: This conditional is here only to facilitate UI testing so that we can mock out certain - // dependencies for the duration of the test (e.g. the data manager). We do not really - // recommend performing UI tests in general, but we do want to demonstrate how it can be - // done. - if _XCTIsTesting { - // NB: Don't run application when testing so that it doesn't interfere with tests. - EmptyView() - } else { - AppView( - store: store - .transformDI { - if ProcessInfo.processInfo.environment["UITesting"] == "true" { - $0.dataManager = .mock() - } - } - .saveOnChange - ) - } - } - } + var body: some Scene { + WindowGroup { + // NB: This conditional is here only to facilitate UI testing so that we can mock out certain + // dependencies for the duration of the test (e.g. the data manager). We do not really + // recommend performing UI tests in general, but we do want to demonstrate how it can be + // done. + if _XCTIsTesting { + // NB: Don't run application when testing so that it doesn't interfere with tests. + EmptyView() + } else { + AppView( + store: store + .transformDI { + if ProcessInfo.processInfo.environment["UITesting"] == "true" { + $0.dataManager = .mock() + } + } + .saveOnChange + ) + } + } + } } diff --git a/Examples/SyncUps/SyncUps/AppFeature.swift b/Examples/SyncUps/SyncUps/AppFeature.swift index 4a15305..ac81d68 100644 --- a/Examples/SyncUps/SyncUps/AppFeature.swift +++ b/Examples/SyncUps/SyncUps/AppFeature.swift @@ -1,129 +1,129 @@ -import VDStore import SwiftUI import VDFlow +import VDStore struct AppFeature: Equatable { - var path = Path(.list) - var syncUpsList = SyncUpsList() - - @Steps - struct Path: Equatable { - var list - var detail = SyncUpDetail(syncUp: .engineeringMock) - var meeting = MeetingSyncUp() - var record: RecordMeeting = .mock - - struct MeetingSyncUp: Equatable { - var meeting: Meeting = .mock - var syncUp: SyncUp = .engineeringMock - } - } + var path = Path(.list) + var syncUpsList = SyncUpsList() + + @Steps + struct Path: Equatable { + var list + var detail = SyncUpDetail(syncUp: .engineeringMock) + var meeting = MeetingSyncUp() + var record: RecordMeeting = .mock + + struct MeetingSyncUp: Equatable { + var meeting: Meeting = .mock + var syncUp: SyncUp = .engineeringMock + } + } } @Actions extension Store: SyncUpDetailDelegate { - func deleteSyncUp(syncUp: SyncUp) { - state.syncUpsList.syncUps.removeAll { - $0.id == syncUp.id - } - } - - func syncUpUpdated(syncUp: SyncUp) { - if let i = state.syncUpsList.syncUps.firstIndex(where: { $0.id == syncUp.id }) { - state.syncUpsList.syncUps[i] = syncUp - } - } - - func startMeeting(syncUp: SyncUp) { - state.path.record = RecordMeeting(syncUp: syncUp) - } + func deleteSyncUp(syncUp: SyncUp) { + state.syncUpsList.syncUps.removeAll { + $0.id == syncUp.id + } + } + + func syncUpUpdated(syncUp: SyncUp) { + if let i = state.syncUpsList.syncUps.firstIndex(where: { $0.id == syncUp.id }) { + state.syncUpsList.syncUps[i] = syncUp + } + } + + func startMeeting(syncUp: SyncUp) { + state.path.record = RecordMeeting(syncUp: syncUp) + } } @Actions extension Store: RecordMeetingDelegate { - func savePath(transcript: String) { - guard let i = state.syncUpsList.syncUps.firstIndex(where: { $0.id == state.path.detail.syncUp.id }) else { return } - state.syncUpsList.syncUps[i] = state.path.detail.syncUp - } + func savePath(transcript: String) { + guard let i = state.syncUpsList.syncUps.firstIndex(where: { $0.id == state.path.detail.syncUp.id }) else { return } + state.syncUpsList.syncUps[i] = state.path.detail.syncUp + } - func debounceSave(syncUps: [SyncUp]) async throws { - cancel(Self.debounceSave) -// try await di.clock.sleep(for: .seconds(1)) - try await di.dataManager.save(JSONEncoder().encode(syncUps), .syncUps) - } + func debounceSave(syncUps: [SyncUp]) async throws { + cancel(Self.debounceSave) + // try await di.clock.sleep(for: .seconds(1)) + try await di.dataManager.save(JSONEncoder().encode(syncUps), .syncUps) + } } extension Store { - var saveOnChange: Self { - onChange(of: \.syncUpsList.syncUps) { _, syncUps, _ in - Task { - try await debounceSave(syncUps: syncUps) - } - } - } + var saveOnChange: Self { + onChange(of: \.syncUpsList.syncUps) { _, syncUps, _ in + Task { + try await debounceSave(syncUps: syncUps) + } + } + } } struct AppView: View { - @ViewStore var state: AppFeature - - init(state: AppFeature) { - self.state = state - } - - init(store: Store) { - _state = ViewStore(store: store) - } - - var body: some View { - NavigationSteps(selection: $state.binding.path.selected) { - listView - detailView - - if state.path.selected == .record { - recordView - } - if state.path.selected == .meeting { - meetingView - } - } - .stepEnvironment($state.binding.path) - } - - private var listView: some View { - SyncUpsListView(store: $state.syncUpsList) - .step($state.binding.path, \.$list) - } - - private var detailView: some View { - SyncUpDetailView( - store: $state.path.detail - .di(\.syncUpDetailDelegate, $state) - ) - .step($state.binding.path, \.$detail) - } - - private var meetingView: some View { - MeetingView( - meeting: state.path.meeting.meeting, - syncUp: state.path.meeting.syncUp - ) - .step($state.binding.path, \.$meeting) - } - - private var recordView: some View { - RecordMeetingView( - store: $state.path.record - .di(\.recordMeetingDelegate, $state) - ) - .step($state.binding.path, \.$record) - } + @ViewStore var state: AppFeature + + init(state: AppFeature) { + self.state = state + } + + init(store: Store) { + _state = ViewStore(store: store) + } + + var body: some View { + NavigationSteps(selection: $state.binding.path.selected) { + listView + detailView + + if state.path.selected == .record { + recordView + } + if state.path.selected == .meeting { + meetingView + } + } + .stepEnvironment($state.binding.path) + } + + private var listView: some View { + SyncUpsListView(store: $state.syncUpsList) + .step($state.binding.path, \.$list) + } + + private var detailView: some View { + SyncUpDetailView( + store: $state.path.detail + .di(\.syncUpDetailDelegate, $state) + ) + .step($state.binding.path, \.$detail) + } + + private var meetingView: some View { + MeetingView( + meeting: state.path.meeting.meeting, + syncUp: state.path.meeting.syncUp + ) + .step($state.binding.path, \.$meeting) + } + + private var recordView: some View { + RecordMeetingView( + store: $state.path.record + .di(\.recordMeetingDelegate, $state) + ) + .step($state.binding.path, \.$record) + } } extension URL { - static let syncUps = Self.documentsDirectory.appending(component: "sync-ups.json") + static let syncUps = Self.documentsDirectory.appending(component: "sync-ups.json") } diff --git a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift b/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift index 889d1f0..c22c9d9 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/DataManager.swift @@ -1,59 +1,59 @@ -import VDStore import Foundation +import VDStore struct DataManager: Sendable { - var load: @Sendable (_ from: URL) async throws -> Data - var save: @Sendable (Data, _ to: URL) async throws -> Void + var load: @Sendable (_ from: URL) async throws -> Data + var save: @Sendable (Data, _ to: URL) async throws -> Void } extension DataManager { - static let liveValue = Self( - load: { url in try Data(contentsOf: url) }, - save: { data, url in try data.write(to: url) } - ) - - static let testValue = Self { _ in - Data() - } save: { _, _ in - } + static let liveValue = Self( + load: { url in try Data(contentsOf: url) }, + save: { data, url in try data.write(to: url) } + ) + + static let testValue = Self { _ in + Data() + } save: { _, _ in + } } extension StoreDIValues { - @StoreDIValue - var dataManager: DataManager = valueFor(live: DataManager.liveValue, test: DataManager.testValue) + @StoreDIValue + var dataManager: DataManager = valueFor(live: DataManager.liveValue, test: DataManager.testValue) } extension DataManager { - static func mock(initialData: Data? = nil) -> Self { - let data = ActorIsolated(initialData) - return Self( - load: { _ in - guard let data = await data.value - else { - struct FileNotFound: Error {} - throw FileNotFound() - } - return data - }, - save: { newData, _ in await data.set(newData) } - ) - } - - static let failToWrite = Self( - load: { _ in Data() }, - save: { _, _ in - struct SaveError: Error {} - throw SaveError() - } - ) - - static let failToLoad = Self( - load: { _ in - struct LoadError: Error {} - throw LoadError() - }, - save: { _, _ in } - ) + static func mock(initialData: Data? = nil) -> Self { + let data = ActorIsolated(initialData) + return Self( + load: { _ in + guard let data = await data.value + else { + struct FileNotFound: Error {} + throw FileNotFound() + } + return data + }, + save: { newData, _ in await data.set(newData) } + ) + } + + static let failToWrite = Self( + load: { _ in Data() }, + save: { _, _ in + struct SaveError: Error {} + throw SaveError() + } + ) + + static let failToLoad = Self( + load: { _ in + struct LoadError: Error {} + throw LoadError() + }, + save: { _, _ in } + ) } diff --git a/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift b/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift index c9c8073..481d7c1 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/OpenSettings.swift @@ -1,16 +1,16 @@ -import VDStore import UIKit +import VDStore extension StoreDIValues { - var openSettings: @Sendable () async -> Void { - get { self[\.openSettings] ?? Self.openSettings } - set { self[\.openSettings] = newValue } - } + var openSettings: @Sendable () async -> Void { + get { self[\.openSettings] ?? Self.openSettings } + set { self[\.openSettings] = newValue } + } - private static let openSettings: @Sendable () async -> Void = { - await MainActor.run { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } - } + private static let openSettings: @Sendable () async -> Void = { + await MainActor.run { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } } diff --git a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift index e332169..60d1031 100644 --- a/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift +++ b/Examples/SyncUps/SyncUps/Dependencies/SpeechRecognizer.swift @@ -1,202 +1,202 @@ -import VDStore @preconcurrency import Speech +import VDStore struct SpeechClient { - var authorizationStatus: @Sendable () -> SFSpeechRecognizerAuthorizationStatus = { .denied } - var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus = { .denied } - var startTask: - @Sendable (_ request: SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< - SpeechRecognitionResult, Error - > = { _ in AsyncThrowingStream { nil } } + var authorizationStatus: @Sendable () -> SFSpeechRecognizerAuthorizationStatus = { .denied } + var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus = { .denied } + var startTask: + @Sendable (_ request: SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< + SpeechRecognitionResult, Error + > = { _ in AsyncThrowingStream { nil } } } extension SpeechClient { - static let liveValue: SpeechClient = { - let speech = Speech() - return SpeechClient( - authorizationStatus: { SFSpeechRecognizer.authorizationStatus() }, - requestAuthorization: { - await withUnsafeContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status) - } - } - }, - startTask: { request in - await speech.startTask(request: request) - } - ) - }() - - static let previewValue: SpeechClient = { - let isRecording = ActorIsolated(false) - return Self( - authorizationStatus: { .authorized }, - requestAuthorization: { .authorized }, - startTask: { _ in - AsyncThrowingStream { continuation in - Task { @MainActor in - await isRecording.set(true) - var finalText = """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ - officia deserunt mollit anim id est laborum. - """ - var text = "" - while await isRecording.value { - let word = finalText.prefix { $0 != " " } - try await Task.sleep(for: .milliseconds(word.count * 50 + .random(in: 0...200))) - finalText.removeFirst(word.count) - if finalText.first == " " { - finalText.removeFirst() - } - text += word + " " - continuation.yield( - SpeechRecognitionResult( - bestTranscription: Transcription( - formattedString: text - ), - isFinal: false - ) - ) - } - } - } - } - ) - }() - - static let testValue = SpeechClient() - - static func fail(after duration: Duration) -> Self { - return Self( - authorizationStatus: { .authorized }, - requestAuthorization: { .authorized }, - startTask: { request in - AsyncThrowingStream { continuation in - Task { - let start = ContinuousClock.now - do { - for try await result in await Self.previewValue.startTask(request) { - if ContinuousClock.now - start > duration { - struct SpeechRecognitionFailed: Error {} - continuation.finish(throwing: SpeechRecognitionFailed()) - break - } else { - continuation.yield(result) - } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } - ) - } + static let liveValue: SpeechClient = { + let speech = Speech() + return SpeechClient( + authorizationStatus: { SFSpeechRecognizer.authorizationStatus() }, + requestAuthorization: { + await withUnsafeContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + }, + startTask: { request in + await speech.startTask(request: request) + } + ) + }() + + static let previewValue: SpeechClient = { + let isRecording = ActorIsolated(false) + return Self( + authorizationStatus: { .authorized }, + requestAuthorization: { .authorized }, + startTask: { _ in + AsyncThrowingStream { continuation in + Task { @MainActor in + await isRecording.set(true) + var finalText = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ + officia deserunt mollit anim id est laborum. + """ + var text = "" + while await isRecording.value { + let word = finalText.prefix { $0 != " " } + try await Task.sleep(for: .milliseconds(word.count * 50 + .random(in: 0 ... 200))) + finalText.removeFirst(word.count) + if finalText.first == " " { + finalText.removeFirst() + } + text += word + " " + continuation.yield( + SpeechRecognitionResult( + bestTranscription: Transcription( + formattedString: text + ), + isFinal: false + ) + ) + } + } + } + } + ) + }() + + static let testValue = SpeechClient() + + static func fail(after duration: Duration) -> Self { + Self( + authorizationStatus: { .authorized }, + requestAuthorization: { .authorized }, + startTask: { request in + AsyncThrowingStream { continuation in + Task { + let start = ContinuousClock.now + do { + for try await result in await Self.previewValue.startTask(request) { + if ContinuousClock.now - start > duration { + struct SpeechRecognitionFailed: Error {} + continuation.finish(throwing: SpeechRecognitionFailed()) + break + } else { + continuation.yield(result) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + ) + } } final actor ActorIsolated { - - var value: T - - init(_ value: T) { - self.value = value - } - - func `set`(_ value: T) { - self.value = value - } + + var value: T + + init(_ value: T) { + self.value = value + } + + func set(_ value: T) { + self.value = value + } } extension StoreDIValues { - @StoreDIValue - var speechClient: SpeechClient = valueFor(live: .liveValue, test: .testValue, preview: .previewValue) + @StoreDIValue + var speechClient: SpeechClient = valueFor(live: .liveValue, test: .testValue, preview: .previewValue) } struct SpeechRecognitionResult: Equatable { - var bestTranscription: Transcription - var isFinal: Bool + var bestTranscription: Transcription + var isFinal: Bool } struct Transcription: Equatable { - var formattedString: String + var formattedString: String } extension SpeechRecognitionResult { - init(_ speechRecognitionResult: SFSpeechRecognitionResult) { - self.bestTranscription = Transcription(speechRecognitionResult.bestTranscription) - self.isFinal = speechRecognitionResult.isFinal - } + init(_ speechRecognitionResult: SFSpeechRecognitionResult) { + bestTranscription = Transcription(speechRecognitionResult.bestTranscription) + isFinal = speechRecognitionResult.isFinal + } } extension Transcription { - init(_ transcription: SFTranscription) { - self.formattedString = transcription.formattedString - } + init(_ transcription: SFTranscription) { + formattedString = transcription.formattedString + } } private actor Speech { - private var audioEngine: AVAudioEngine? = nil - private var recognitionTask: SFSpeechRecognitionTask? = nil - private var recognitionContinuation: - AsyncThrowingStream.Continuation? - - func startTask( - request: SFSpeechAudioBufferRecognitionRequest - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - self.recognitionContinuation = continuation - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - } catch { - continuation.finish(throwing: error) - return - } - - self.audioEngine = AVAudioEngine() - let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! - self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in - switch (result, error) { - case let (.some(result), _): - continuation.yield(SpeechRecognitionResult(result)) - case (_, .some): - continuation.finish(throwing: error) - case (.none, .none): - fatalError("It should not be possible to have both a nil result and nil error.") - } - } - - continuation.onTermination = { [audioEngine, recognitionTask] _ in - _ = speechRecognizer - audioEngine?.stop() - audioEngine?.inputNode.removeTap(onBus: 0) - recognitionTask?.finish() - } - - self.audioEngine?.inputNode.installTap( - onBus: 0, - bufferSize: 1024, - format: self.audioEngine?.inputNode.outputFormat(forBus: 0) - ) { buffer, when in - request.append(buffer) - } - - self.audioEngine?.prepare() - do { - try self.audioEngine?.start() - } catch { - continuation.finish(throwing: error) - return - } - } - } + private var audioEngine: AVAudioEngine? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionContinuation: + AsyncThrowingStream.Continuation? + + func startTask( + request: SFSpeechAudioBufferRecognitionRequest + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + self.recognitionContinuation = continuation + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + continuation.finish(throwing: error) + return + } + + self.audioEngine = AVAudioEngine() + let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! + self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in + switch (result, error) { + case let (.some(result), _): + continuation.yield(SpeechRecognitionResult(result)) + case (_, .some): + continuation.finish(throwing: error) + case (.none, .none): + fatalError("It should not be possible to have both a nil result and nil error.") + } + } + + continuation.onTermination = { [audioEngine, recognitionTask] _ in + _ = speechRecognizer + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + recognitionTask?.finish() + } + + self.audioEngine?.inputNode.installTap( + onBus: 0, + bufferSize: 1024, + format: self.audioEngine?.inputNode.outputFormat(forBus: 0) + ) { buffer, _ in + request.append(buffer) + } + + self.audioEngine?.prepare() + do { + try self.audioEngine?.start() + } catch { + continuation.finish(throwing: error) + return + } + } + } } diff --git a/Examples/SyncUps/SyncUps/Meeting.swift b/Examples/SyncUps/SyncUps/Meeting.swift index 205d156..9c72c3d 100644 --- a/Examples/SyncUps/SyncUps/Meeting.swift +++ b/Examples/SyncUps/SyncUps/Meeting.swift @@ -1,28 +1,28 @@ -import VDStore import SwiftUI +import VDStore struct MeetingView: View { - - let meeting: Meeting - let syncUp: SyncUp - - var body: some View { - ScrollView { - VStack(alignment: .leading) { - Divider() - .padding(.bottom) - Text("Attendees") - .font(.headline) - ForEach(syncUp.attendees) { attendee in - Text(attendee.name) - } - Text("Transcript") - .font(.headline) - .padding(.top) - Text(meeting.transcript) - } - } - .navigationTitle(Text(meeting.date, style: .date)) - .padding() - } + + let meeting: Meeting + let syncUp: SyncUp + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + Divider() + .padding(.bottom) + Text("Attendees") + .font(.headline) + ForEach(syncUp.attendees) { attendee in + Text(attendee.name) + } + Text("Transcript") + .font(.headline) + .padding(.top) + Text(meeting.transcript) + } + } + .navigationTitle(Text(meeting.date, style: .date)) + .padding() + } } diff --git a/Examples/SyncUps/SyncUps/Models.swift b/Examples/SyncUps/SyncUps/Models.swift index a667b05..db6dde8 100644 --- a/Examples/SyncUps/SyncUps/Models.swift +++ b/Examples/SyncUps/SyncUps/Models.swift @@ -2,115 +2,115 @@ import SwiftUI struct SyncUp: Equatable, Identifiable, Codable { - let id: UUID - var attendees: [Attendee] = [] - var duration: Duration = .seconds(60 * 5) - var meetings: [Meeting] = [] - var theme: Theme = .bubblegum - var title = "" + let id: UUID + var attendees: [Attendee] = [] + var duration: Duration = .seconds(60 * 5) + var meetings: [Meeting] = [] + var theme: Theme = .bubblegum + var title = "" - var durationPerAttendee: Duration { - duration / attendees.count - } + var durationPerAttendee: Duration { + duration / attendees.count + } } struct Attendee: Equatable, Identifiable, Codable { - let id: UUID - var name = "" + let id: UUID + var name = "" } struct Meeting: Equatable, Identifiable, Codable { - let id: UUID - let date: Date - var transcript: String - - static let mock = Meeting(id: UUID(), date: Date(), transcript: "Lorem ipsum dolor sit amet") + let id: UUID + let date: Date + var transcript: String + + static let mock = Meeting(id: UUID(), date: Date(), transcript: "Lorem ipsum dolor sit amet") } enum Theme: String, CaseIterable, Equatable, Identifiable, Codable { - case bubblegum - case buttercup - case indigo - case lavender - case magenta - case navy - case orange - case oxblood - case periwinkle - case poppy - case purple - case seafoam - case sky - case tan - case teal - case yellow + case bubblegum + case buttercup + case indigo + case lavender + case magenta + case navy + case orange + case oxblood + case periwinkle + case poppy + case purple + case seafoam + case sky + case tan + case teal + case yellow - var id: Self { self } + var id: Self { self } - var accentColor: Color { - switch self { - case .bubblegum, .buttercup, .lavender, .orange, .periwinkle, .poppy, .seafoam, .sky, .tan, - .teal, .yellow: - return .black - case .indigo, .magenta, .navy, .oxblood, .purple: - return .white - } - } + var accentColor: Color { + switch self { + case .bubblegum, .buttercup, .lavender, .orange, .periwinkle, .poppy, .seafoam, .sky, .tan, + .teal, .yellow: + return .black + case .indigo, .magenta, .navy, .oxblood, .purple: + return .white + } + } - var mainColor: Color { Color(self.rawValue) } + var mainColor: Color { Color(rawValue) } - var name: String { self.rawValue.capitalized } + var name: String { rawValue.capitalized } } extension SyncUp { - static let mock = Self( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID(), name: "Blob"), - Attendee(id: Attendee.ID(), name: "Blob Jr"), - Attendee(id: Attendee.ID(), name: "Blob Sr"), - Attendee(id: Attendee.ID(), name: "Blob Esq"), - Attendee(id: Attendee.ID(), name: "Blob III"), - Attendee(id: Attendee.ID(), name: "Blob I"), - ], - duration: .seconds(60), - meetings: [ - Meeting( - id: Meeting.ID(), - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ - dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ - mollit anim id est laborum. - """ - ) - ], - theme: .orange, - title: "Design" - ) + static let mock = Self( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID(), name: "Blob"), + Attendee(id: Attendee.ID(), name: "Blob Jr"), + Attendee(id: Attendee.ID(), name: "Blob Sr"), + Attendee(id: Attendee.ID(), name: "Blob Esq"), + Attendee(id: Attendee.ID(), name: "Blob III"), + Attendee(id: Attendee.ID(), name: "Blob I"), + ], + duration: .seconds(60), + meetings: [ + Meeting( + id: Meeting.ID(), + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ + dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ + mollit anim id est laborum. + """ + ), + ], + theme: .orange, + title: "Design" + ) - static let engineeringMock = Self( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID(), name: "Blob"), - Attendee(id: Attendee.ID(), name: "Blob Jr"), - ], - duration: .seconds(60 * 10), - theme: .periwinkle, - title: "Engineering" - ) + static let engineeringMock = Self( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID(), name: "Blob"), + Attendee(id: Attendee.ID(), name: "Blob Jr"), + ], + duration: .seconds(60 * 10), + theme: .periwinkle, + title: "Engineering" + ) - static let designMock = Self( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID(), name: "Blob Sr"), - Attendee(id: Attendee.ID(), name: "Blob Jr"), - ], - duration: .seconds(60 * 30), - theme: .poppy, - title: "Product" - ) + static let designMock = Self( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID(), name: "Blob Sr"), + Attendee(id: Attendee.ID(), name: "Blob Jr"), + ], + duration: .seconds(60 * 30), + theme: .poppy, + title: "Product" + ) } diff --git a/Examples/SyncUps/SyncUps/RecordMeeting.swift b/Examples/SyncUps/SyncUps/RecordMeeting.swift index 7420de8..1a8cae8 100644 --- a/Examples/SyncUps/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/SyncUps/RecordMeeting.swift @@ -1,375 +1,376 @@ -import VDStore @preconcurrency import Speech import SwiftUI import VDFlow +import VDStore struct RecordMeeting: Equatable { - var alert = Alert() - var secondsElapsed = 0 - var speakerIndex = 0 - var syncUp: SyncUp - var transcript = "" - - var durationRemaining: Duration { - self.syncUp.duration - .seconds(self.secondsElapsed) - } - - @Steps - struct Alert: Equatable { - var endMeeting = true - var speechRecognizerFailed - } - - static let mock = RecordMeeting(syncUp: .engineeringMock) + var alert = Alert() + var secondsElapsed = 0 + var speakerIndex = 0 + var syncUp: SyncUp + var transcript = "" + + var durationRemaining: Duration { + syncUp.duration - .seconds(secondsElapsed) + } + + @Steps + struct Alert: Equatable { + var endMeeting = true + var speechRecognizerFailed + } + + static let mock = RecordMeeting(syncUp: .engineeringMock) } @MainActor protocol RecordMeetingDelegate { - func savePath(transcript: String) + func savePath(transcript: String) } @StoreDIValuesList extension StoreDIValues { - var recordMeetingDelegate: RecordMeetingDelegate? + var recordMeetingDelegate: RecordMeetingDelegate? } extension Store { - func confirmDiscard() { - di.dismiss() - } - - func save() { - state.syncUp.meetings.insert( - Meeting( - id: di.uuid(), - date: Date(),//di.now, - transcript: state.transcript - ), - at: 0 - ) - di.recordMeetingDelegate?.savePath(transcript: state.transcript) - di.dismiss() - } - - func endMeetingButtonTapped() { - state.alert.endMeeting = true - } - - func nextButtonTapped() { - guard state.speakerIndex < state.syncUp.attendees.count - 1 - else { - state.alert.endMeeting = false - return - } - state.speakerIndex += 1 - state.secondsElapsed = - state.speakerIndex * Int(state.syncUp.durationPerAttendee.components.seconds) - } - - func onTask() async { - let authorization = - await di.speechClient.authorizationStatus() == .notDetermined - ? di.speechClient.requestAuthorization() - : di.speechClient.authorizationStatus() - - await withTaskGroup(of: Void.self) { group in - if authorization == .authorized { - group.addTask { - await startSpeechRecognition() - } - } - group.addTask { -// for await _ in di.clock.timer(interval: .seconds(1)) { -// await send(.timerTick) -// } - } - } - } - - func timerTick() { - guard state.alert.selected == nil else { return } - - state.secondsElapsed += 1 - - let secondsPerAttendee = Int(state.syncUp.durationPerAttendee.components.seconds) - if state.secondsElapsed.isMultiple(of: secondsPerAttendee) { - if state.speakerIndex == state.syncUp.attendees.count - 1 { - save() - return - } - state.speakerIndex += 1 - } - - } - - func startSpeechRecognition() async { - do { - let speechTask = await di.speechClient.startTask(SFSpeechAudioBufferRecognitionRequest()) - for try await result in speechTask { - state.transcript = result.bestTranscription.formattedString - } - } catch { - speechFailure() - } - } - - func speechFailure() { - if !state.transcript.isEmpty { - state.transcript += " ❌" - } - state.alert.speechRecognizerFailed.select() - } + func confirmDiscard() { + di.dismiss() + } + + func save() { + state.syncUp.meetings.insert( + Meeting( + id: di.uuid(), + date: Date(), // di.now, + transcript: state.transcript + ), + at: 0 + ) + di.recordMeetingDelegate?.savePath(transcript: state.transcript) + di.dismiss() + } + + func endMeetingButtonTapped() { + state.alert.endMeeting = true + } + + func nextButtonTapped() { + guard state.speakerIndex < state.syncUp.attendees.count - 1 + else { + state.alert.endMeeting = false + return + } + state.speakerIndex += 1 + state.secondsElapsed = + state.speakerIndex * Int(state.syncUp.durationPerAttendee.components.seconds) + } + + func onTask() async { + let authorization = + await di.speechClient.authorizationStatus() == .notDetermined + ? di.speechClient.requestAuthorization() + : di.speechClient.authorizationStatus() + + await withTaskGroup(of: Void.self) { group in + if authorization == .authorized { + group.addTask { + await startSpeechRecognition() + } + } + group.addTask { + // for await _ in di.clock.timer(interval: .seconds(1)) { + // await send(.timerTick) + // } + } + } + } + + func timerTick() { + guard state.alert.selected == nil else { return } + + state.secondsElapsed += 1 + + let secondsPerAttendee = Int(state.syncUp.durationPerAttendee.components.seconds) + if state.secondsElapsed.isMultiple(of: secondsPerAttendee) { + if state.speakerIndex == state.syncUp.attendees.count - 1 { + save() + return + } + state.speakerIndex += 1 + } + } + + func startSpeechRecognition() async { + do { + let speechTask = await di.speechClient.startTask(SFSpeechAudioBufferRecognitionRequest()) + for try await result in speechTask { + state.transcript = result.bestTranscription.formattedString + } + } catch { + speechFailure() + } + } + + func speechFailure() { + if !state.transcript.isEmpty { + state.transcript += " ❌" + } + state.alert.speechRecognizerFailed.select() + } } struct RecordMeetingView: View { - - @ViewStore var state: RecordMeeting - - init(state: RecordMeeting) { - self.state = state - } - - init(store: Store) { - _state = ViewStore(store: store) - } - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(state.syncUp.theme.mainColor) - - VStack { - MeetingHeaderView( - secondsElapsed: state.secondsElapsed, - durationRemaining: state.durationRemaining, - theme: state.syncUp.theme - ) - MeetingTimerView( - syncUp: state.syncUp, - speakerIndex: state.speakerIndex - ) - MeetingFooterView( - syncUp: state.syncUp, - nextButtonTapped: { - $state.nextButtonTapped() - }, - speakerIndex: state.speakerIndex - ) - } - } - .padding() - .foregroundColor(state.syncUp.theme.accentColor) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("End meeting") { - $state.endMeetingButtonTapped() - } - } - } - .navigationBarBackButtonHidden(true) - .endMeetingAlert(store: $state) - .speechRecognizerFailedAlert(store: $state) - .task { - await $state.onTask() - } - } + + @ViewStore var state: RecordMeeting + + init(state: RecordMeeting) { + self.state = state + } + + init(store: Store) { + _state = ViewStore(store: store) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(state.syncUp.theme.mainColor) + + VStack { + MeetingHeaderView( + secondsElapsed: state.secondsElapsed, + durationRemaining: state.durationRemaining, + theme: state.syncUp.theme + ) + MeetingTimerView( + syncUp: state.syncUp, + speakerIndex: state.speakerIndex + ) + MeetingFooterView( + syncUp: state.syncUp, + nextButtonTapped: { + $state.nextButtonTapped() + }, + speakerIndex: state.speakerIndex + ) + } + } + .padding() + .foregroundColor(state.syncUp.theme.accentColor) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("End meeting") { + $state.endMeetingButtonTapped() + } + } + } + .navigationBarBackButtonHidden(true) + .endMeetingAlert(store: $state) + .speechRecognizerFailedAlert(store: $state) + .task { + await $state.onTask() + } + } } @MainActor extension View { - func endMeetingAlert(store: Store) -> some View { - alert( - "End meeting?", - isPresented: store.binding.alert.isSelected(.endMeeting) - ) { - Button("Save and end") { - store.save() - } - if store.state.alert.endMeeting { - Button("Discard", role: .destructive) { - store.confirmDiscard() - } - } - Button("Resume", role: .cancel) {} - } message: { - Text("You are ending the meeting early. What would you like to do?") - } - } - - func speechRecognizerFailedAlert(store: Store) -> some View { - alert( - "Speech recognition failure", - isPresented: store.binding.alert.isSelected(.speechRecognizerFailed) - ) { - Button("Continue meeting", role: .cancel) {} - Button("Discard meeting", role: .destructive) { - store.confirmDiscard() - } - } message: { - Text( - """ - The speech recognizer has failed for some reason and so your meeting will no longer be \ - recorded. What do you want to do? - """ - ) - } - } + func endMeetingAlert(store: Store) -> some View { + alert( + "End meeting?", + isPresented: store.binding.alert.isSelected(.endMeeting) + ) { + Button("Save and end") { + store.save() + } + if store.state.alert.endMeeting { + Button("Discard", role: .destructive) { + store.confirmDiscard() + } + } + Button("Resume", role: .cancel) {} + } message: { + Text("You are ending the meeting early. What would you like to do?") + } + } + + func speechRecognizerFailedAlert(store: Store) -> some View { + alert( + "Speech recognition failure", + isPresented: store.binding.alert.isSelected(.speechRecognizerFailed) + ) { + Button("Continue meeting", role: .cancel) {} + Button("Discard meeting", role: .destructive) { + store.confirmDiscard() + } + } message: { + Text( + """ + The speech recognizer has failed for some reason and so your meeting will no longer be \ + recorded. What do you want to do? + """ + ) + } + } } struct MeetingHeaderView: View { - let secondsElapsed: Int - let durationRemaining: Duration - let theme: Theme - - var body: some View { - VStack { - ProgressView(value: self.progress) - .progressViewStyle(MeetingProgressViewStyle(theme: self.theme)) - HStack { - VStack(alignment: .leading) { - Text("Time Elapsed") - .font(.caption) - Label( - Duration.seconds(self.secondsElapsed).formatted(.units()), - systemImage: "hourglass.bottomhalf.fill" - ) - } - Spacer() - VStack(alignment: .trailing) { - Text("Time Remaining") - .font(.caption) - Label(self.durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill") - .font(.body.monospacedDigit()) - .labelStyle(.trailingIcon) - } - } - } - .padding([.top, .horizontal]) - } - - private var totalDuration: Duration { - .seconds(self.secondsElapsed) + self.durationRemaining - } - - private var progress: Double { - guard self.totalDuration > .seconds(0) else { return 0 } - return Double(self.secondsElapsed) / Double(self.totalDuration.components.seconds) - } + let secondsElapsed: Int + let durationRemaining: Duration + let theme: Theme + + var body: some View { + VStack { + ProgressView(value: progress) + .progressViewStyle(MeetingProgressViewStyle(theme: theme)) + HStack { + VStack(alignment: .leading) { + Text("Time Elapsed") + .font(.caption) + Label( + Duration.seconds(secondsElapsed).formatted(.units()), + systemImage: "hourglass.bottomhalf.fill" + ) + } + Spacer() + VStack(alignment: .trailing) { + Text("Time Remaining") + .font(.caption) + Label(durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill") + .font(.body.monospacedDigit()) + .labelStyle(.trailingIcon) + } + } + } + .padding([.top, .horizontal]) + } + + private var totalDuration: Duration { + .seconds(secondsElapsed) + durationRemaining + } + + private var progress: Double { + guard totalDuration > .seconds(0) else { return 0 } + return Double(secondsElapsed) / Double(totalDuration.components.seconds) + } } struct MeetingProgressViewStyle: ProgressViewStyle { - var theme: Theme - - func makeBody(configuration: Configuration) -> some View { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(self.theme.accentColor) - .frame(height: 20) - - ProgressView(configuration) - .tint(self.theme.mainColor) - .frame(height: 12) - .padding(.horizontal) - } - } + var theme: Theme + + func makeBody(configuration: Configuration) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(theme.accentColor) + .frame(height: 20) + + ProgressView(configuration) + .tint(theme.mainColor) + .frame(height: 12) + .padding(.horizontal) + } + } } struct MeetingTimerView: View { - let syncUp: SyncUp - let speakerIndex: Int - - var body: some View { - Circle() - .strokeBorder(lineWidth: 24) - .overlay { - VStack { - Group { - if self.speakerIndex < self.syncUp.attendees.count { - Text(self.syncUp.attendees[self.speakerIndex].name) - } else { - Text("Someone") - } - } - .font(.title) - Text("is speaking") - Image(systemName: "mic.fill") - .font(.largeTitle) - .padding(.top) - } - .foregroundStyle(self.syncUp.theme.accentColor) - } - .overlay { - ForEach(Array(self.syncUp.attendees.enumerated()), id: \.element.id) { index, attendee in - if index < self.speakerIndex + 1 { - SpeakerArc(totalSpeakers: self.syncUp.attendees.count, speakerIndex: index) - .rotation(Angle(degrees: -90)) - .stroke(self.syncUp.theme.mainColor, lineWidth: 12) - } - } - } - .padding(.horizontal) - } + let syncUp: SyncUp + let speakerIndex: Int + + var body: some View { + Circle() + .strokeBorder(lineWidth: 24) + .overlay { + VStack { + Group { + if speakerIndex < syncUp.attendees.count { + Text(syncUp.attendees[speakerIndex].name) + } else { + Text("Someone") + } + } + .font(.title) + Text("is speaking") + Image(systemName: "mic.fill") + .font(.largeTitle) + .padding(.top) + } + .foregroundStyle(syncUp.theme.accentColor) + } + .overlay { + ForEach(Array(syncUp.attendees.enumerated()), id: \.element.id) { index, _ in + if index < speakerIndex + 1 { + SpeakerArc(totalSpeakers: syncUp.attendees.count, speakerIndex: index) + .rotation(Angle(degrees: -90)) + .stroke(syncUp.theme.mainColor, lineWidth: 12) + } + } + } + .padding(.horizontal) + } } struct SpeakerArc: Shape { - let totalSpeakers: Int - let speakerIndex: Int - - func path(in rect: CGRect) -> Path { - let diameter = min(rect.size.width, rect.size.height) - 24 - let radius = diameter / 2 - let center = CGPoint(x: rect.midX, y: rect.midY) - return Path { path in - path.addArc( - center: center, - radius: radius, - startAngle: self.startAngle, - endAngle: self.endAngle, - clockwise: false - ) - } - } - - private var degreesPerSpeaker: Double { - 360 / Double(self.totalSpeakers) - } - private var startAngle: Angle { - Angle(degrees: self.degreesPerSpeaker * Double(self.speakerIndex) + 1) - } - private var endAngle: Angle { - Angle(degrees: self.startAngle.degrees + self.degreesPerSpeaker - 1) - } + let totalSpeakers: Int + let speakerIndex: Int + + func path(in rect: CGRect) -> Path { + let diameter = min(rect.size.width, rect.size.height) - 24 + let radius = diameter / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + return Path { path in + path.addArc( + center: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: false + ) + } + } + + private var degreesPerSpeaker: Double { + 360 / Double(totalSpeakers) + } + + private var startAngle: Angle { + Angle(degrees: degreesPerSpeaker * Double(speakerIndex) + 1) + } + + private var endAngle: Angle { + Angle(degrees: startAngle.degrees + degreesPerSpeaker - 1) + } } struct MeetingFooterView: View { - let syncUp: SyncUp - var nextButtonTapped: () -> Void - let speakerIndex: Int - - var body: some View { - VStack { - HStack { - if self.speakerIndex < self.syncUp.attendees.count - 1 { - Text("Speaker \(self.speakerIndex + 1) of \(self.syncUp.attendees.count)") - } else { - Text("No more speakers.") - } - Spacer() - Button(action: self.nextButtonTapped) { - Image(systemName: "forward.fill") - } - } - } - .padding([.bottom, .horizontal]) - } + let syncUp: SyncUp + var nextButtonTapped: () -> Void + let speakerIndex: Int + + var body: some View { + VStack { + HStack { + if speakerIndex < syncUp.attendees.count - 1 { + Text("Speaker \(speakerIndex + 1) of \(syncUp.attendees.count)") + } else { + Text("No more speakers.") + } + Spacer() + Button(action: nextButtonTapped) { + Image(systemName: "forward.fill") + } + } + } + .padding([.bottom, .horizontal]) + } } #Preview { - NavigationStack { - RecordMeetingView(state: RecordMeeting(syncUp: .mock)) - } + NavigationStack { + RecordMeetingView(state: RecordMeeting(syncUp: .mock)) + } } diff --git a/Examples/SyncUps/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUps/SyncUpDetail.swift index a9ea21c..81e640d 100644 --- a/Examples/SyncUps/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUps/SyncUpDetail.swift @@ -1,270 +1,270 @@ -import VDStore import SwiftUI import VDFlow +import VDStore struct SyncUpDetail: Equatable { - - var destination = Destination() - var syncUp: SyncUp - - @Steps - struct Destination: Equatable { - var alert = Alert() - var edit = SyncUpForm(syncUp: SyncUp(id: .init())) - - @Steps - struct Alert: Equatable { - var confirmDeletion - var speechRecognitionDenied - var speechRecognitionRestricted - } - } + + var destination = Destination() + var syncUp: SyncUp + + @Steps + struct Destination: Equatable { + var alert = Alert() + var edit = SyncUpForm(syncUp: SyncUp(id: .init())) + + @Steps + struct Alert: Equatable { + var confirmDeletion + var speechRecognitionDenied + var speechRecognitionRestricted + } + } } @MainActor protocol SyncUpDetailDelegate { - func deleteSyncUp(syncUp: SyncUp) - func syncUpUpdated(syncUp: SyncUp) - func startMeeting(syncUp: SyncUp) + func deleteSyncUp(syncUp: SyncUp) + func syncUpUpdated(syncUp: SyncUp) + func startMeeting(syncUp: SyncUp) } @StoreDIValuesList extension StoreDIValues { - var syncUpDetailDelegate: SyncUpDetailDelegate? + var syncUpDetailDelegate: SyncUpDetailDelegate? } @Actions extension Store { - func cancelEditButtonTapped() { - state.destination.selected = nil - } - - func deleteButtonTapped() { - state.destination.alert.confirmDeletion.select() - } - - func deleteMeetings(atOffsets indices: IndexSet) { - state.syncUp.meetings.remove(atOffsets: indices) - } - - func confirmDeletion() async { - withAnimation { - di.syncUpDetailDelegate?.deleteSyncUp(syncUp: state.syncUp) - } - di.dismiss() - } - - func continueWithoutRecording() { - di.syncUpDetailDelegate?.startMeeting(syncUp: state.syncUp) - } - - func openSettings() async { - await di.openSettings() - } - - func doneEditingButtonTapped() { - state.syncUp = state.destination.edit.syncUp - di.syncUpDetailDelegate?.syncUpUpdated(syncUp: state.syncUp) - state.destination.selected = nil - } - - func editButtonTapped() { - state.destination.edit = SyncUpForm(syncUp: state.syncUp) - } - - func startMeetingButtonTapped() { - switch di.speechClient.authorizationStatus() { - case .notDetermined, .authorized: - di.syncUpDetailDelegate?.startMeeting(syncUp: state.syncUp) - - case .denied: - state.destination.alert.speechRecognitionDenied.select() - - case .restricted: - state.destination.alert.speechRecognitionRestricted.select() - - @unknown default: - break - } - } + func cancelEditButtonTapped() { + state.destination.selected = nil + } + + func deleteButtonTapped() { + state.destination.alert.confirmDeletion.select() + } + + func deleteMeetings(atOffsets indices: IndexSet) { + state.syncUp.meetings.remove(atOffsets: indices) + } + + func confirmDeletion() async { + withAnimation { + di.syncUpDetailDelegate?.deleteSyncUp(syncUp: state.syncUp) + } + di.dismiss() + } + + func continueWithoutRecording() { + di.syncUpDetailDelegate?.startMeeting(syncUp: state.syncUp) + } + + func openSettings() async { + await di.openSettings() + } + + func doneEditingButtonTapped() { + state.syncUp = state.destination.edit.syncUp + di.syncUpDetailDelegate?.syncUpUpdated(syncUp: state.syncUp) + state.destination.selected = nil + } + + func editButtonTapped() { + state.destination.edit = SyncUpForm(syncUp: state.syncUp) + } + + func startMeetingButtonTapped() { + switch di.speechClient.authorizationStatus() { + case .notDetermined, .authorized: + di.syncUpDetailDelegate?.startMeeting(syncUp: state.syncUp) + + case .denied: + state.destination.alert.speechRecognitionDenied.select() + + case .restricted: + state.destination.alert.speechRecognitionRestricted.select() + + @unknown default: + break + } + } } struct SyncUpDetailView: View { - - @ViewStore var state: SyncUpDetail - @StateStep var feature = AppFeature.Path() - - init(state: SyncUpDetail) { - _state = ViewStore(wrappedValue: state) - } - - init(store: Store) { - _state = ViewStore(store: store) - } - - var body: some View { - Form { - Section { - Button { - $state.startMeetingButtonTapped() - } label: { - Label("Start Meeting", systemImage: "timer") - .font(.headline) - .foregroundColor(.accentColor) - } - HStack { - Label("Length", systemImage: "clock") - Spacer() - Text(state.syncUp.duration.formatted(.units())) - } - - HStack { - Label("Theme", systemImage: "paintpalette") - Spacer() - Text(state.syncUp.theme.name) - .padding(4) - .foregroundColor(state.syncUp.theme.accentColor) - .background(state.syncUp.theme.mainColor) - .cornerRadius(4) - } - } header: { - Text("Sync-up Info") - } - - if !state.syncUp.meetings.isEmpty { - Section { - ForEach(state.syncUp.meetings) { meeting in - Button { - feature.meeting = AppFeature.Path.MeetingSyncUp(meeting: meeting, syncUp: state.syncUp) - } label: { - HStack { - Image(systemName: "calendar") - Text(meeting.date, style: .date) - Text(meeting.date, style: .time) - } - } - } - .onDelete { indices in - $state.deleteMeetings(atOffsets: indices) - } - } header: { - Text("Past meetings") - } - } - - Section { - ForEach(state.syncUp.attendees) { attendee in - Label(attendee.name, systemImage: "person") - } - } header: { - Text("Attendees") - } - - Section { - Button("Delete") { - $state.deleteButtonTapped() - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - } - } - .toolbar { - Button("Edit") { - $state.editButtonTapped() - } - } - .navigationTitle(state.syncUp.title) - .deleteSyncUpAlert(store: $state) - .speechRecognitionDeniedAlert(store: $state) - .speechRecognitionRestrictedAlert(store: $state) - .sheet( - isPresented: $state.binding.destination.isSelected(.edit) - ) { - NavigationStack { - SyncUpFormView(store: $state.destination.edit) - .navigationTitle(state.syncUp.title) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - $state.cancelEditButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - $state.doneEditingButtonTapped() - } - } - } - } - } - } + + @ViewStore var state: SyncUpDetail + @StateStep var feature = AppFeature.Path() + + init(state: SyncUpDetail) { + _state = ViewStore(wrappedValue: state) + } + + init(store: Store) { + _state = ViewStore(store: store) + } + + var body: some View { + Form { + Section { + Button { + $state.startMeetingButtonTapped() + } label: { + Label("Start Meeting", systemImage: "timer") + .font(.headline) + .foregroundColor(.accentColor) + } + HStack { + Label("Length", systemImage: "clock") + Spacer() + Text(state.syncUp.duration.formatted(.units())) + } + + HStack { + Label("Theme", systemImage: "paintpalette") + Spacer() + Text(state.syncUp.theme.name) + .padding(4) + .foregroundColor(state.syncUp.theme.accentColor) + .background(state.syncUp.theme.mainColor) + .cornerRadius(4) + } + } header: { + Text("Sync-up Info") + } + + if !state.syncUp.meetings.isEmpty { + Section { + ForEach(state.syncUp.meetings) { meeting in + Button { + feature.meeting = AppFeature.Path.MeetingSyncUp(meeting: meeting, syncUp: state.syncUp) + } label: { + HStack { + Image(systemName: "calendar") + Text(meeting.date, style: .date) + Text(meeting.date, style: .time) + } + } + } + .onDelete { indices in + $state.deleteMeetings(atOffsets: indices) + } + } header: { + Text("Past meetings") + } + } + + Section { + ForEach(state.syncUp.attendees) { attendee in + Label(attendee.name, systemImage: "person") + } + } header: { + Text("Attendees") + } + + Section { + Button("Delete") { + $state.deleteButtonTapped() + } + .foregroundColor(.red) + .frame(maxWidth: .infinity) + } + } + .toolbar { + Button("Edit") { + $state.editButtonTapped() + } + } + .navigationTitle(state.syncUp.title) + .deleteSyncUpAlert(store: $state) + .speechRecognitionDeniedAlert(store: $state) + .speechRecognitionRestrictedAlert(store: $state) + .sheet( + isPresented: $state.binding.destination.isSelected(.edit) + ) { + NavigationStack { + SyncUpFormView(store: $state.destination.edit) + .navigationTitle(state.syncUp.title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + $state.cancelEditButtonTapped() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + $state.doneEditingButtonTapped() + } + } + } + } + } + } } @MainActor extension View { - - func deleteSyncUpAlert(store: Store) -> some View { - alert( - "Delete?", - isPresented: store.binding.destination.alert.isSelected(.confirmDeletion) - ) { - Button("Yes", role: .destructive) { - Task { - await store.confirmDeletion() - } - } - Button("Nevermind", role: .cancel) {} - } message: { - Text("Are you sure you want to delete this meeting?") - } - } - - func speechRecognitionDeniedAlert(store: Store) -> some View { - alert( - "Speech recognition denied", - isPresented: store.binding.destination.alert.isSelected(.speechRecognitionDenied) - ) { - Button("Continue without recording") { - store.continueWithoutRecording() - } - Button("Open settings") { - Task { - await store.openSettings() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text( - """ - You previously denied speech recognition and so your meeting will not be recorded. You can \ - enable speech recognition in settings, or you can continue without recording. - """ - ) - } - } - - func speechRecognitionRestrictedAlert(store: Store) -> some View { - alert( - "Speech recognition restricted", - isPresented: store.binding.destination.alert.isSelected(.speechRecognitionRestricted) - ) { - Button("Continue without recording") { - store.continueWithoutRecording() - } - Button("Cancel", role: .cancel) {} - } message: { - Text( - """ - Your device does not support speech recognition and so your meeting will not be recorded. - """ - ) - } - } + + func deleteSyncUpAlert(store: Store) -> some View { + alert( + "Delete?", + isPresented: store.binding.destination.alert.isSelected(.confirmDeletion) + ) { + Button("Yes", role: .destructive) { + Task { + await store.confirmDeletion() + } + } + Button("Nevermind", role: .cancel) {} + } message: { + Text("Are you sure you want to delete this meeting?") + } + } + + func speechRecognitionDeniedAlert(store: Store) -> some View { + alert( + "Speech recognition denied", + isPresented: store.binding.destination.alert.isSelected(.speechRecognitionDenied) + ) { + Button("Continue without recording") { + store.continueWithoutRecording() + } + Button("Open settings") { + Task { + await store.openSettings() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + """ + You previously denied speech recognition and so your meeting will not be recorded. You can \ + enable speech recognition in settings, or you can continue without recording. + """ + ) + } + } + + func speechRecognitionRestrictedAlert(store: Store) -> some View { + alert( + "Speech recognition restricted", + isPresented: store.binding.destination.alert.isSelected(.speechRecognitionRestricted) + ) { + Button("Continue without recording") { + store.continueWithoutRecording() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + """ + Your device does not support speech recognition and so your meeting will not be recorded. + """ + ) + } + } } #Preview { - NavigationStack { - SyncUpDetailView(state: SyncUpDetail(syncUp: .mock)) - } + NavigationStack { + SyncUpDetailView(state: SyncUpDetail(syncUp: .mock)) + } } diff --git a/Examples/SyncUps/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUps/SyncUpForm.swift index 63f6294..3e3a368 100644 --- a/Examples/SyncUps/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUps/SyncUpForm.swift @@ -1,130 +1,130 @@ -import VDStore import SwiftUI +import VDStore struct SyncUpForm: Equatable { - var focus: Field? = .title - var syncUp: SyncUp - - init( - focus: Field? = .title, - syncUp: SyncUp - ) { - self.focus = focus - self.syncUp = syncUp -// if self.syncUp.attendees.isEmpty { -// @Dependency(\.uuid) var uuid -// self.syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) -// } - } - - enum Field: Hashable { - case attendee(Attendee.ID) - case title - } + var focus: Field? = .title + var syncUp: SyncUp + + init( + focus: Field? = .title, + syncUp: SyncUp + ) { + self.focus = focus + self.syncUp = syncUp + // if self.syncUp.attendees.isEmpty { + // @Dependency(\.uuid) var uuid + // self.syncUp.attendees.append(Attendee(id: Attendee.ID(uuid()))) + // } + } + + enum Field: Hashable { + case attendee(Attendee.ID) + case title + } } @Actions extension Store { - func addAttendeeButtonTapped() { - let attendee = Attendee(id: di.uuid()) - state.syncUp.attendees.append(attendee) - state.focus = .attendee(attendee.id) - } - - func deleteAttendees(atOffsets indices: IndexSet) { - state.syncUp.attendees.remove(atOffsets: indices) - if state.syncUp.attendees.isEmpty { - state.syncUp.attendees.append(Attendee(id: di.uuid())) - } - guard let firstIndex = indices.first else { return } - let index = min(firstIndex, state.syncUp.attendees.count - 1) - state.focus = .attendee(state.syncUp.attendees[index].id) - } + func addAttendeeButtonTapped() { + let attendee = Attendee(id: di.uuid()) + state.syncUp.attendees.append(attendee) + state.focus = .attendee(attendee.id) + } + + func deleteAttendees(atOffsets indices: IndexSet) { + state.syncUp.attendees.remove(atOffsets: indices) + if state.syncUp.attendees.isEmpty { + state.syncUp.attendees.append(Attendee(id: di.uuid())) + } + guard let firstIndex = indices.first else { return } + let index = min(firstIndex, state.syncUp.attendees.count - 1) + state.focus = .attendee(state.syncUp.attendees[index].id) + } } struct SyncUpFormView: View { - @ViewStore var state: SyncUpForm - @FocusState var focus: SyncUpForm.Field? - - init(state: SyncUpForm, focus: SyncUpForm.Field? = nil) { - _state = ViewStore(wrappedValue: state) - self.focus = focus - } - - init(store: Store, focus: SyncUpForm.Field? = nil) { - _state = ViewStore(store: store) - self.focus = focus - } - - var body: some View { - Form { - Section { - TextField("Title", text: $state.binding.syncUp.title) - .focused($focus, equals: .title) - HStack { - Slider(value: $state.binding.syncUp.duration.minutes, in: 5...30, step: 1) { - Text("Length") - } - Spacer() - Text(state.syncUp.duration.formatted(.units())) - } - ThemePicker(selection: $state.binding.syncUp.theme) - } header: { - Text("Sync-up Info") - } - Section { - ForEach($state.binding.syncUp.attendees) { attendee in - TextField("Name", text: attendee.name) - .focused($focus, equals: .attendee(attendee.id)) - } - .onDelete { indices in - $state.deleteAttendees(atOffsets: indices) - } - - Button("New attendee") { - $state.addAttendeeButtonTapped() - } - } header: { - Text("Attendees") - } - } -// .bind($state.binding.focus, to: $focus) - } + @ViewStore var state: SyncUpForm + @FocusState var focus: SyncUpForm.Field? + + init(state: SyncUpForm, focus: SyncUpForm.Field? = nil) { + _state = ViewStore(wrappedValue: state) + self.focus = focus + } + + init(store: Store, focus: SyncUpForm.Field? = nil) { + _state = ViewStore(store: store) + self.focus = focus + } + + var body: some View { + Form { + Section { + TextField("Title", text: $state.binding.syncUp.title) + .focused($focus, equals: .title) + HStack { + Slider(value: $state.binding.syncUp.duration.minutes, in: 5 ... 30, step: 1) { + Text("Length") + } + Spacer() + Text(state.syncUp.duration.formatted(.units())) + } + ThemePicker(selection: $state.binding.syncUp.theme) + } header: { + Text("Sync-up Info") + } + Section { + ForEach($state.binding.syncUp.attendees) { attendee in + TextField("Name", text: attendee.name) + .focused($focus, equals: .attendee(attendee.id)) + } + .onDelete { indices in + $state.deleteAttendees(atOffsets: indices) + } + + Button("New attendee") { + $state.addAttendeeButtonTapped() + } + } header: { + Text("Attendees") + } + } + // .bind($state.binding.focus, to: $focus) + } } struct ThemePicker: View { - @Binding var selection: Theme - - var body: some View { - Picker("Theme", selection: self.$selection) { - ForEach(Theme.allCases) { theme in - ZStack { - RoundedRectangle(cornerRadius: 4) - .fill(theme.mainColor) - Label(theme.name, systemImage: "paintpalette") - .padding(4) - } - .foregroundColor(theme.accentColor) - .fixedSize(horizontal: false, vertical: true) - .tag(theme) - } - } - } + @Binding var selection: Theme + + var body: some View { + Picker("Theme", selection: $selection) { + ForEach(Theme.allCases) { theme in + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(theme.mainColor) + Label(theme.name, systemImage: "paintpalette") + .padding(4) + } + .foregroundColor(theme.accentColor) + .fixedSize(horizontal: false, vertical: true) + .tag(theme) + } + } + } } -extension Duration { - fileprivate var minutes: Double { - get { Double(self.components.seconds / 60) } - set { self = .seconds(newValue * 60) } - } +private extension Duration { + var minutes: Double { + get { Double(components.seconds / 60) } + set { self = .seconds(newValue * 60) } + } } #Preview { - NavigationStack { - SyncUpFormView(state: SyncUpForm(syncUp: .mock)) - } + NavigationStack { + SyncUpFormView(state: SyncUpForm(syncUp: .mock)) + } } diff --git a/Examples/SyncUps/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUps/SyncUpsList.swift index a564357..3f7edad 100644 --- a/Examples/SyncUps/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUps/SyncUpsList.swift @@ -1,231 +1,231 @@ -import VDStore import SwiftUI import VDFlow +import VDStore struct SyncUpsList: Equatable { - var destination = Destination() - var syncUps: [SyncUp] = [] - - init( - destination: Destination.Steps? = nil, - syncUps: () throws -> [SyncUp] = { [] } - ) { - self.destination = Destination(destination) - do { - self.syncUps = try syncUps() - } catch is DecodingError { - self.destination.selected = .confirmLoadMockData - } catch { - self.syncUps = [] - } - } - - @Steps - struct Destination: Equatable { - - var add = SyncUpForm(syncUp: SyncUp(id: .init())) - var confirmLoadMockData - } + var destination = Destination() + var syncUps: [SyncUp] = [] + + init( + destination: Destination.Steps? = nil, + syncUps: () throws -> [SyncUp] = { [] } + ) { + self.destination = Destination(destination) + do { + self.syncUps = try syncUps() + } catch is DecodingError { + self.destination.selected = .confirmLoadMockData + } catch { + self.syncUps = [] + } + } + + @Steps + struct Destination: Equatable { + + var add = SyncUpForm(syncUp: SyncUp(id: .init())) + var confirmLoadMockData + } } @Actions extension Store { - func addSyncUpButtonTapped() { - state.destination.add = SyncUpForm(syncUp: SyncUp(id: di.uuid())) - } - - func confirmAddSyncUpButtonTapped() { - var syncUp = state.destination.add.syncUp - syncUp.attendees.removeAll { attendee in - attendee.name.allSatisfy(\.isWhitespace) - } - if syncUp.attendees.isEmpty { - syncUp.attendees.append( - state.destination.add.syncUp.attendees.first ?? Attendee(id: di.uuid()) - ) - } - state.syncUps.append(syncUp) - state.destination.selected = nil - } - - func destinationPresented() { - state.destination.confirmLoadMockData.select() - state.syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - } - - func dismissAddSyncUpButtonTapped() { - state.destination.selected = nil - } - - func onDelete(indexSet: IndexSet) { - state.syncUps.remove(atOffsets: indexSet) - } + func addSyncUpButtonTapped() { + state.destination.add = SyncUpForm(syncUp: SyncUp(id: di.uuid())) + } + + func confirmAddSyncUpButtonTapped() { + var syncUp = state.destination.add.syncUp + syncUp.attendees.removeAll { attendee in + attendee.name.allSatisfy(\.isWhitespace) + } + if syncUp.attendees.isEmpty { + syncUp.attendees.append( + state.destination.add.syncUp.attendees.first ?? Attendee(id: di.uuid()) + ) + } + state.syncUps.append(syncUp) + state.destination.selected = nil + } + + func destinationPresented() { + state.destination.confirmLoadMockData.select() + state.syncUps = [ + .mock, + .designMock, + .engineeringMock, + ] + } + + func dismissAddSyncUpButtonTapped() { + state.destination.selected = nil + } + + func onDelete(indexSet: IndexSet) { + state.syncUps.remove(atOffsets: indexSet) + } } struct SyncUpsListView: View { - @ViewStore var state: SyncUpsList - @StateStep var feature = AppFeature.Path() - - init(state: SyncUpsList) { - self._state = ViewStore(wrappedValue: state) - } - - init(store: Store) { - self._state = ViewStore(store: store) - } - - var body: some View { - List { - ForEach(state.syncUps) { syncUp in - Button { - feature.detail = SyncUpDetail(syncUp: syncUp) - } label: { - CardView(syncUp: syncUp) - } - .listRowBackground(syncUp.theme.mainColor) - } - .onDelete { - $state.onDelete(indexSet: $0) - } - } - .toolbar { - Button { - $state.addSyncUpButtonTapped() - } label: { - Image(systemName: "plus") - } - } - .navigationTitle("Daily Sync-ups") - .syncUpsListAlert($state) - .sheet( - isPresented: $state.binding.destination.isSelected(.add) - ) { - NavigationStack { - SyncUpFormView(store: $state.destination.add) - .navigationTitle("New sync-up") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Dismiss") { - $state.dismissAddSyncUpButtonTapped() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Add") { - $state.confirmAddSyncUpButtonTapped() - } - } - } - } - } - } + @ViewStore var state: SyncUpsList + @StateStep var feature = AppFeature.Path() + + init(state: SyncUpsList) { + _state = ViewStore(wrappedValue: state) + } + + init(store: Store) { + _state = ViewStore(store: store) + } + + var body: some View { + List { + ForEach(state.syncUps) { syncUp in + Button { + feature.detail = SyncUpDetail(syncUp: syncUp) + } label: { + CardView(syncUp: syncUp) + } + .listRowBackground(syncUp.theme.mainColor) + } + .onDelete { + $state.onDelete(indexSet: $0) + } + } + .toolbar { + Button { + $state.addSyncUpButtonTapped() + } label: { + Image(systemName: "plus") + } + } + .navigationTitle("Daily Sync-ups") + .syncUpsListAlert($state) + .sheet( + isPresented: $state.binding.destination.isSelected(.add) + ) { + NavigationStack { + SyncUpFormView(store: $state.destination.add) + .navigationTitle("New sync-up") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Dismiss") { + $state.dismissAddSyncUpButtonTapped() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + $state.confirmAddSyncUpButtonTapped() + } + } + } + } + } + } } extension View { - - @MainActor - func syncUpsListAlert( - _ store: Store - ) -> some View { - self.alert( - "Data failed to load", - isPresented: Binding { - store.state.destination.selected == .confirmLoadMockData - } set: { - if $0 { - store.destinationPresented() - } - } - ) { - Button("Yes") { - store.withAnimation { - store.destinationPresented() - } - } - Button("No", role: .cancel) {} - } message: { - Text( - """ - Unfortunately your past data failed to load. Would you like to load some mock data to play \ - around with? - """ - ) - } - } + + @MainActor + func syncUpsListAlert( + _ store: Store + ) -> some View { + alert( + "Data failed to load", + isPresented: Binding { + store.state.destination.selected == .confirmLoadMockData + } set: { + if $0 { + store.destinationPresented() + } + } + ) { + Button("Yes") { + store.withAnimation { + store.destinationPresented() + } + } + Button("No", role: .cancel) {} + } message: { + Text( + """ + Unfortunately your past data failed to load. Would you like to load some mock data to play \ + around with? + """ + ) + } + } } struct CardView: View { - let syncUp: SyncUp - - var body: some View { - VStack(alignment: .leading) { - Text(self.syncUp.title) - .font(.headline) - Spacer() - HStack { - Label("\(self.syncUp.attendees.count)", systemImage: "person.3") - Spacer() - Label(self.syncUp.duration.formatted(.units()), systemImage: "clock") - .labelStyle(.trailingIcon) - } - .font(.caption) - } - .padding() - .foregroundColor(self.syncUp.theme.accentColor) - } + let syncUp: SyncUp + + var body: some View { + VStack(alignment: .leading) { + Text(syncUp.title) + .font(.headline) + Spacer() + HStack { + Label("\(syncUp.attendees.count)", systemImage: "person.3") + Spacer() + Label(syncUp.duration.formatted(.units()), systemImage: "clock") + .labelStyle(.trailingIcon) + } + .font(.caption) + } + .padding() + .foregroundColor(syncUp.theme.accentColor) + } } struct TrailingIconLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - configuration.icon - } - } + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + configuration.icon + } + } } extension LabelStyle where Self == TrailingIconLabelStyle { - static var trailingIcon: Self { Self() } + static var trailingIcon: Self { Self() } } #Preview { - SyncUpsListView( - store: Store( - SyncUpsList {[ - SyncUp.mock, - .designMock, - .engineeringMock, - ]} - ) - ) + SyncUpsListView( + store: Store( + SyncUpsList { [ + SyncUp.mock, + .designMock, + .engineeringMock, + ] } + ) + ) } #Preview("Load data failure") { - SyncUpsListView( - store: Store( - SyncUpsList { - try JSONDecoder().decode([SyncUp].self, from: Data("!@#$% bad data ^&*()".utf8)) - } - ) - ) - .previewDisplayName("Load data failure") + SyncUpsListView( + store: Store( + SyncUpsList { + try JSONDecoder().decode([SyncUp].self, from: Data("!@#$% bad data ^&*()".utf8)) + } + ) + ) + .previewDisplayName("Load data failure") } #Preview("Card") { - CardView( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [], - duration: .seconds(60), - meetings: [], - theme: .bubblegum, - title: "Point-Free Morning Sync" - ) - ) + CardView( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [], + duration: .seconds(60), + meetings: [], + theme: .bubblegum, + title: "Point-Free Morning Sync" + ) + ) } diff --git a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift index d2dafc1..e512ec8 100644 --- a/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift +++ b/Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift @@ -4,141 +4,141 @@ import XCTest @testable import SyncUps final class AppFeatureTests: XCTestCase { - @MainActor - func testDelete() async throws { - let syncUp = SyncUp.mock - - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: try! JSONEncoder().encode([syncUp]) - ) - } - - await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { - $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) - } - - await store.send(\.path[id:0].detail.deleteButtonTapped) { - $0.path[id: 0]?.detail?.destination = .alert(.deleteSyncUp) - } - - await store.send(\.path[id:0].detail.destination.alert.confirmDeletion) { - $0.path[id: 0]?.detail?.destination = nil - } - - await store.receive(\.path[id:0].detail.delegate.deleteSyncUp) { - $0.syncUpsList.syncUps = [] - } - await store.receive(\.path.popFrom) { - $0.path = StackState() - } - } - - @MainActor - func testDetailEdit() async throws { - var syncUp = SyncUp.mock - let savedData = LockIsolated(Data?.none) - - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { dependencies in - dependencies.continuousClock = ImmediateClock() - dependencies.dataManager = .mock( - initialData: try! JSONEncoder().encode([syncUp]) - ) - dependencies.dataManager.save = { @Sendable [dependencies] data, url in - savedData.setValue(data) - try await dependencies.dataManager.save(data, to: url) - } - } - - await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { - $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) - } - - await store.send(\.path[id:0].detail.editButtonTapped) { - $0.path[id: 0]?.detail?.destination = .edit( - SyncUpForm.State(syncUp: syncUp) - ) - } - - syncUp.title = "Blob" - await store.send(\.path[id:0].detail.destination.edit.binding.syncUp, syncUp) { - $0.path[id: 0]?.detail?.destination?.edit?.syncUp.title = "Blob" - } - - await store.send(\.path[id:0].detail.doneEditingButtonTapped) { - $0.path[id: 0]?.detail?.destination = nil - $0.path[id: 0]?.detail?.syncUp.title = "Blob" - } - - await store.receive(\.path[id:0].detail.delegate.syncUpUpdated) { - $0.syncUpsList.syncUps[0].title = "Blob" - } - - var savedSyncUp = syncUp - savedSyncUp.title = "Blob" - XCTAssertNoDifference( - try JSONDecoder().decode([SyncUp].self, from: savedData.value!), - [savedSyncUp] - ) - } - - @MainActor - func testRecording() async { - let speechResult = SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "I completed the project"), - isFinal: true - ) - let syncUp = SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) - ) - - let store = TestStore( - initialState: AppFeature.State( - path: StackState([ - .detail(SyncUpDetail.State(syncUp: syncUp)), - .record(RecordMeeting.State(syncUp: syncUp)), - ]) - ) - ) { - AppFeature() - } withDependencies: { - $0.dataManager = .mock(initialData: try! JSONEncoder().encode([syncUp])) - $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) - $0.continuousClock = ImmediateClock() - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { continuation in - continuation.yield(speechResult) - continuation.finish() - } - } - $0.uuid = .incrementing - } - store.exhaustivity = .off - - await store.send(\.path[id:1].record.onTask) - await store.receive(\.path[id:1].record.delegate.save) { - $0.path[id: 0]?.detail?.syncUp.meetings = [ - Meeting( - id: Meeting.ID(UUID(0)), - date: Date(timeIntervalSince1970: 1_234_567_890), - transcript: "I completed the project" - ) - ] - } - await store.receive(\.path.popFrom) { - XCTAssertEqual($0.path.count, 1) - } - } + @MainActor + func testDelete() async throws { + let syncUp = SyncUp.mock + + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.continuousClock = ImmediateClock() + $0.dataManager = .mock( + initialData: try! JSONEncoder().encode([syncUp]) + ) + } + + await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { + $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) + } + + await store.send(\.path[id: 0].detail.deleteButtonTapped) { + $0.path[id: 0]?.detail?.destination = .alert(.deleteSyncUp) + } + + await store.send(\.path[id: 0].detail.destination.alert.confirmDeletion) { + $0.path[id: 0]?.detail?.destination = nil + } + + await store.receive(\.path[id: 0].detail.delegate.deleteSyncUp) { + $0.syncUpsList.syncUps = [] + } + await store.receive(\.path.popFrom) { + $0.path = StackState() + } + } + + @MainActor + func testDetailEdit() async throws { + var syncUp = SyncUp.mock + let savedData = LockIsolated(Data?.none) + + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { dependencies in + dependencies.continuousClock = ImmediateClock() + dependencies.dataManager = .mock( + initialData: try! JSONEncoder().encode([syncUp]) + ) + dependencies.dataManager.save = { @Sendable [dependencies] data, url in + savedData.setValue(data) + try await dependencies.dataManager.save(data, to: url) + } + } + + await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))) { + $0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: syncUp)) + } + + await store.send(\.path[id: 0].detail.editButtonTapped) { + $0.path[id: 0]?.detail?.destination = .edit( + SyncUpForm.State(syncUp: syncUp) + ) + } + + syncUp.title = "Blob" + await store.send(\.path[id: 0].detail.destination.edit.binding.syncUp, syncUp) { + $0.path[id: 0]?.detail?.destination?.edit?.syncUp.title = "Blob" + } + + await store.send(\.path[id: 0].detail.doneEditingButtonTapped) { + $0.path[id: 0]?.detail?.destination = nil + $0.path[id: 0]?.detail?.syncUp.title = "Blob" + } + + await store.receive(\.path[id: 0].detail.delegate.syncUpUpdated) { + $0.syncUpsList.syncUps[0].title = "Blob" + } + + var savedSyncUp = syncUp + savedSyncUp.title = "Blob" + XCTAssertNoDifference( + try JSONDecoder().decode([SyncUp].self, from: savedData.value!), + [savedSyncUp] + ) + } + + @MainActor + func testRecording() async { + let speechResult = SpeechRecognitionResult( + bestTranscription: Transcription(formattedString: "I completed the project"), + isFinal: true + ) + let syncUp = SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) + + let store = TestStore( + initialState: AppFeature.State( + path: StackState([ + .detail(SyncUpDetail.State(syncUp: syncUp)), + .record(RecordMeeting.State(syncUp: syncUp)), + ]) + ) + ) { + AppFeature() + } withDependencies: { + $0.dataManager = .mock(initialData: try! JSONEncoder().encode([syncUp])) + $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) + $0.continuousClock = ImmediateClock() + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { continuation in + continuation.yield(speechResult) + continuation.finish() + } + } + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(\.path[id: 1].record.onTask) + await store.receive(\.path[id: 1].record.delegate.save) { + $0.path[id: 0]?.detail?.syncUp.meetings = [ + Meeting( + id: Meeting.ID(UUID(0)), + date: Date(timeIntervalSince1970: 1_234_567_890), + transcript: "I completed the project" + ), + ] + } + await store.receive(\.path.popFrom) { + XCTAssertEqual($0.path.count, 1) + } + } } diff --git a/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift b/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift index 274172f..126e975 100644 --- a/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift +++ b/Examples/SyncUps/SyncUpsTests/RecordMeetingTests.swift @@ -4,370 +4,370 @@ import XCTest @testable import SyncUps final class RecordMeetingTests: XCTestCase { - @MainActor - func testTimer() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore( - initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) - ) - ) - ) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .denied } - } - - let onTask = await store.send(.onTask) - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 0 - $0.secondsElapsed = 1 - XCTAssertEqual($0.durationRemaining, .seconds(5)) - } - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 1 - $0.secondsElapsed = 2 - XCTAssertEqual($0.durationRemaining, .seconds(4)) - } - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 1 - $0.secondsElapsed = 3 - XCTAssertEqual($0.durationRemaining, .seconds(3)) - } - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 2 - $0.secondsElapsed = 4 - XCTAssertEqual($0.durationRemaining, .seconds(2)) - } - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 2 - $0.secondsElapsed = 5 - XCTAssertEqual($0.durationRemaining, .seconds(1)) - } - - await clock.advance(by: .seconds(1)) - await store.receive(\.timerTick) { - $0.speakerIndex = 2 - $0.secondsElapsed = 6 - XCTAssertEqual($0.durationRemaining, .seconds(0)) - } - - // NB: this improves on the onMeetingFinished pattern from vanilla SwiftUI - await store.receive(\.delegate.save) - - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } - - @MainActor - func testRecordTranscript() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore( - initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) - ) - ) - ) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { continuation in - continuation.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "I completed the project"), - isFinal: true - ) - ) - continuation.finish() - } - } - } - - let onTask = await store.send(.onTask) - - await store.receive(\.speechResult) { - $0.transcript = "I completed the project" - } - - await store.withExhaustivity(.off(showSkippedAssertions: true)) { - await clock.advance(by: .seconds(6)) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - } - - await store.receive(\.delegate.save) - - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } - - @MainActor - func testEndMeetingSave() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .denied } - } - - let onTask = await store.send(.onTask) - - await store.send(.endMeetingButtonTapped) { - $0.alert = .endMeeting(isDiscardable: true) - } - - await clock.advance(by: .seconds(3)) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - - await store.send(\.alert.confirmSave) { - $0.alert = nil - } - - await store.receive(\.delegate.save) - - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } - - @MainActor - func testEndMeetingDiscard() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .denied } - } - - let task = await store.send(.onTask) - - await store.send(.endMeetingButtonTapped) { - $0.alert = .endMeeting(isDiscardable: true) - } - - await store.send(\.alert.confirmDiscard) { - $0.alert = nil - } - - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await task.cancel() - } - - @MainActor - func testNextSpeaker() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore( - initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) - ) - ) - ) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .denied } - } - - let onTask = await store.send(.onTask) - - await store.send(.nextButtonTapped) { - $0.speakerIndex = 1 - $0.secondsElapsed = 2 - } - - await store.send(.nextButtonTapped) { - $0.speakerIndex = 2 - $0.secondsElapsed = 4 - } - - await store.send(.nextButtonTapped) { - $0.alert = .endMeeting(isDiscardable: false) - } - - await store.send(\.alert.confirmSave) { - $0.alert = nil - } - - await store.receive(\.delegate.save) - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } - - @MainActor - func testSpeechRecognitionFailure_Continue() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore( - initialState: RecordMeeting.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - duration: .seconds(6) - ) - ) - ) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { - $0.yield( - SpeechRecognitionResult( - bestTranscription: Transcription(formattedString: "I completed the project"), - isFinal: true - ) - ) - struct SpeechRecognitionFailure: Error {} - $0.finish(throwing: SpeechRecognitionFailure()) - } - } - } - - let onTask = await store.send(.onTask) - - await store.receive(\.speechResult) { - $0.transcript = "I completed the project" - } - - await store.receive(\.speechFailure) { - $0.alert = .speechRecognizerFailed - $0.transcript = "I completed the project ❌" - } - - await store.send(\.alert.dismiss) { - $0.alert = nil - } - - await clock.advance(by: .seconds(6)) - - store.exhaustivity = .off(showSkippedAssertions: true) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - await store.receive(\.timerTick) - store.exhaustivity = .on - - await store.receive(\.delegate.save) - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } - - @MainActor - func testSpeechRecognitionFailure_Discard() async { - let clock = TestClock() - let dismissed = self.expectation(description: "dismissed") - - let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { - RecordMeeting() - } withDependencies: { - $0.continuousClock = clock - $0.dismiss = DismissEffect { dismissed.fulfill() } - $0.speechClient.authorizationStatus = { .authorized } - $0.speechClient.startTask = { @Sendable _ in - AsyncThrowingStream { - struct SpeechRecognitionFailure: Error {} - $0.finish(throwing: SpeechRecognitionFailure()) - } - } - } - - let onTask = await store.send(.onTask) - - await store.receive(\.speechFailure) { - $0.alert = .speechRecognizerFailed - } - - await store.send(\.alert.confirmDiscard) { - $0.alert = nil - } - - #if swift(>=5.10) - nonisolated(unsafe) let `self` = self - #endif - await self.fulfillment(of: [dismissed]) - await onTask.cancel() - } + @MainActor + func testTimer() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore( + initialState: RecordMeeting.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) + ) + ) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .denied } + } + + let onTask = await store.send(.onTask) + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 0 + $0.secondsElapsed = 1 + XCTAssertEqual($0.durationRemaining, .seconds(5)) + } + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 1 + $0.secondsElapsed = 2 + XCTAssertEqual($0.durationRemaining, .seconds(4)) + } + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 1 + $0.secondsElapsed = 3 + XCTAssertEqual($0.durationRemaining, .seconds(3)) + } + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 2 + $0.secondsElapsed = 4 + XCTAssertEqual($0.durationRemaining, .seconds(2)) + } + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 2 + $0.secondsElapsed = 5 + XCTAssertEqual($0.durationRemaining, .seconds(1)) + } + + await clock.advance(by: .seconds(1)) + await store.receive(\.timerTick) { + $0.speakerIndex = 2 + $0.secondsElapsed = 6 + XCTAssertEqual($0.durationRemaining, .seconds(0)) + } + + // NB: this improves on the onMeetingFinished pattern from vanilla SwiftUI + await store.receive(\.delegate.save) + + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } + + @MainActor + func testRecordTranscript() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore( + initialState: RecordMeeting.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) + ) + ) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { continuation in + continuation.yield( + SpeechRecognitionResult( + bestTranscription: Transcription(formattedString: "I completed the project"), + isFinal: true + ) + ) + continuation.finish() + } + } + } + + let onTask = await store.send(.onTask) + + await store.receive(\.speechResult) { + $0.transcript = "I completed the project" + } + + await store.withExhaustivity(.off(showSkippedAssertions: true)) { + await clock.advance(by: .seconds(6)) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + } + + await store.receive(\.delegate.save) + + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } + + @MainActor + func testEndMeetingSave() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .denied } + } + + let onTask = await store.send(.onTask) + + await store.send(.endMeetingButtonTapped) { + $0.alert = .endMeeting(isDiscardable: true) + } + + await clock.advance(by: .seconds(3)) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + + await store.send(\.alert.confirmSave) { + $0.alert = nil + } + + await store.receive(\.delegate.save) + + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } + + @MainActor + func testEndMeetingDiscard() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .denied } + } + + let task = await store.send(.onTask) + + await store.send(.endMeetingButtonTapped) { + $0.alert = .endMeeting(isDiscardable: true) + } + + await store.send(\.alert.confirmDiscard) { + $0.alert = nil + } + + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await task.cancel() + } + + @MainActor + func testNextSpeaker() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore( + initialState: RecordMeeting.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) + ) + ) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .denied } + } + + let onTask = await store.send(.onTask) + + await store.send(.nextButtonTapped) { + $0.speakerIndex = 1 + $0.secondsElapsed = 2 + } + + await store.send(.nextButtonTapped) { + $0.speakerIndex = 2 + $0.secondsElapsed = 4 + } + + await store.send(.nextButtonTapped) { + $0.alert = .endMeeting(isDiscardable: false) + } + + await store.send(\.alert.confirmSave) { + $0.alert = nil + } + + await store.receive(\.delegate.save) + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } + + @MainActor + func testSpeechRecognitionFailure_Continue() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore( + initialState: RecordMeeting.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + duration: .seconds(6) + ) + ) + ) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { + $0.yield( + SpeechRecognitionResult( + bestTranscription: Transcription(formattedString: "I completed the project"), + isFinal: true + ) + ) + struct SpeechRecognitionFailure: Error {} + $0.finish(throwing: SpeechRecognitionFailure()) + } + } + } + + let onTask = await store.send(.onTask) + + await store.receive(\.speechResult) { + $0.transcript = "I completed the project" + } + + await store.receive(\.speechFailure) { + $0.alert = .speechRecognizerFailed + $0.transcript = "I completed the project ❌" + } + + await store.send(\.alert.dismiss) { + $0.alert = nil + } + + await clock.advance(by: .seconds(6)) + + store.exhaustivity = .off(showSkippedAssertions: true) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + await store.receive(\.timerTick) + store.exhaustivity = .on + + await store.receive(\.delegate.save) + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } + + @MainActor + func testSpeechRecognitionFailure_Discard() async { + let clock = TestClock() + let dismissed = expectation(description: "dismissed") + + let store = TestStore(initialState: RecordMeeting.State(syncUp: .mock)) { + RecordMeeting() + } withDependencies: { + $0.continuousClock = clock + $0.dismiss = DismissEffect { dismissed.fulfill() } + $0.speechClient.authorizationStatus = { .authorized } + $0.speechClient.startTask = { @Sendable _ in + AsyncThrowingStream { + struct SpeechRecognitionFailure: Error {} + $0.finish(throwing: SpeechRecognitionFailure()) + } + } + } + + let onTask = await store.send(.onTask) + + await store.receive(\.speechFailure) { + $0.alert = .speechRecognizerFailed + } + + await store.send(\.alert.confirmDiscard) { + $0.alert = nil + } + + #if swift(>=5.10) + nonisolated(unsafe) let self = self + #endif + await self.fulfillment(of: [dismissed]) + await onTask.cancel() + } } diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift index 6390fb8..c5e1a28 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift @@ -4,135 +4,135 @@ import XCTest @testable import SyncUps final class SyncUpDetailTests: XCTestCase { - @MainActor - func testSpeechRestricted() async { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { - SyncUpDetail() - } withDependencies: { - $0.speechClient.authorizationStatus = { .restricted } - } - - await store.send(.startMeetingButtonTapped) { - $0.destination = .alert(.speechRecognitionRestricted) - } - } - - @MainActor - func testSpeechDenied() async throws { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { - SyncUpDetail() - } withDependencies: { - $0.speechClient.authorizationStatus = { - .denied - } - } - - await store.send(.startMeetingButtonTapped) { - $0.destination = .alert(.speechRecognitionDenied) - } - } - - @MainActor - func testOpenSettings() async { - let settingsOpened = LockIsolated(false) - - let store = TestStore( - initialState: SyncUpDetail.State( - destination: .alert(.speechRecognitionDenied), - syncUp: .mock - ) - ) { - SyncUpDetail() - } withDependencies: { - $0.openSettings = { settingsOpened.setValue(true) } - $0.speechClient.authorizationStatus = { .denied } - } - - await store.send(\.destination.alert.openSettings) { - $0.destination = nil - } - XCTAssertEqual(settingsOpened.value, true) - } - - @MainActor - func testContinueWithoutRecording() async throws { - let store = TestStore( - initialState: SyncUpDetail.State( - destination: .alert(.speechRecognitionDenied), - syncUp: .mock - ) - ) { - SyncUpDetail() - } withDependencies: { - $0.speechClient.authorizationStatus = { .denied } - } - - await store.send(\.destination.alert.continueWithoutRecording) { - $0.destination = nil - } - - await store.receive(\.delegate.startMeeting) - } - - @MainActor - func testSpeechAuthorized() async throws { - let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { - SyncUpDetail() - } withDependencies: { - $0.speechClient.authorizationStatus = { .authorized } - } - - await store.send(.startMeetingButtonTapped) - - await store.receive(\.delegate.startMeeting) - } - - @MainActor - func testEdit() async { - var syncUp = SyncUp.mock - let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { - SyncUpDetail() - } withDependencies: { - $0.uuid = .incrementing - } - - await store.send(.editButtonTapped) { - $0.destination = .edit(SyncUpForm.State(syncUp: syncUp)) - } - - syncUp.title = "Blob's Meeting" - await store.send(\.destination.edit.binding.syncUp, syncUp) { - $0.destination?.edit?.syncUp.title = "Blob's Meeting" - } - - await store.send(.doneEditingButtonTapped) { - $0.destination = nil - $0.syncUp.title = "Blob's Meeting" - } - - await store.receive(\.delegate.syncUpUpdated) - } - - @MainActor - func testDelete() async { - let didDismiss = LockIsolated(false) - defer { XCTAssertEqual(didDismiss.value, true) } - - let syncUp = SyncUp.mock - let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { - SyncUpDetail() - } withDependencies: { - $0.dismiss = DismissEffect { - didDismiss.setValue(true) - } - } - - await store.send(.deleteButtonTapped) { - $0.destination = .alert(.deleteSyncUp) - } - await store.send(\.destination.alert.confirmDeletion) { - $0.destination = nil - } - await store.receive(\.delegate.deleteSyncUp) - } + @MainActor + func testSpeechRestricted() async { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + SyncUpDetail() + } withDependencies: { + $0.speechClient.authorizationStatus = { .restricted } + } + + await store.send(.startMeetingButtonTapped) { + $0.destination = .alert(.speechRecognitionRestricted) + } + } + + @MainActor + func testSpeechDenied() async throws { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + SyncUpDetail() + } withDependencies: { + $0.speechClient.authorizationStatus = { + .denied + } + } + + await store.send(.startMeetingButtonTapped) { + $0.destination = .alert(.speechRecognitionDenied) + } + } + + @MainActor + func testOpenSettings() async { + let settingsOpened = LockIsolated(false) + + let store = TestStore( + initialState: SyncUpDetail.State( + destination: .alert(.speechRecognitionDenied), + syncUp: .mock + ) + ) { + SyncUpDetail() + } withDependencies: { + $0.openSettings = { settingsOpened.setValue(true) } + $0.speechClient.authorizationStatus = { .denied } + } + + await store.send(\.destination.alert.openSettings) { + $0.destination = nil + } + XCTAssertEqual(settingsOpened.value, true) + } + + @MainActor + func testContinueWithoutRecording() async throws { + let store = TestStore( + initialState: SyncUpDetail.State( + destination: .alert(.speechRecognitionDenied), + syncUp: .mock + ) + ) { + SyncUpDetail() + } withDependencies: { + $0.speechClient.authorizationStatus = { .denied } + } + + await store.send(\.destination.alert.continueWithoutRecording) { + $0.destination = nil + } + + await store.receive(\.delegate.startMeeting) + } + + @MainActor + func testSpeechAuthorized() async throws { + let store = TestStore(initialState: SyncUpDetail.State(syncUp: .mock)) { + SyncUpDetail() + } withDependencies: { + $0.speechClient.authorizationStatus = { .authorized } + } + + await store.send(.startMeetingButtonTapped) + + await store.receive(\.delegate.startMeeting) + } + + @MainActor + func testEdit() async { + var syncUp = SyncUp.mock + let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { + SyncUpDetail() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.editButtonTapped) { + $0.destination = .edit(SyncUpForm.State(syncUp: syncUp)) + } + + syncUp.title = "Blob's Meeting" + await store.send(\.destination.edit.binding.syncUp, syncUp) { + $0.destination?.edit?.syncUp.title = "Blob's Meeting" + } + + await store.send(.doneEditingButtonTapped) { + $0.destination = nil + $0.syncUp.title = "Blob's Meeting" + } + + await store.receive(\.delegate.syncUpUpdated) + } + + @MainActor + func testDelete() async { + let didDismiss = LockIsolated(false) + defer { XCTAssertEqual(didDismiss.value, true) } + + let syncUp = SyncUp.mock + let store = TestStore(initialState: SyncUpDetail.State(syncUp: syncUp)) { + SyncUpDetail() + } withDependencies: { + $0.dismiss = DismissEffect { + didDismiss.setValue(true) + } + } + + await store.send(.deleteButtonTapped) { + $0.destination = .alert(.deleteSyncUp) + } + await store.send(\.destination.alert.confirmDeletion) { + $0.destination = nil + } + await store.receive(\.delegate.deleteSyncUp) + } } diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpFormTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpFormTests.swift index 70cff3d..a89ba90 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpFormTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpFormTests.swift @@ -4,88 +4,88 @@ import XCTest @testable import SyncUps final class SyncUpFormTests: XCTestCase { - @MainActor - func testAddAttendee() async { - let store = TestStore( - initialState: SyncUpForm.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [], - title: "Engineering" - ) - ) - ) { - SyncUpForm() - } withDependencies: { - $0.uuid = .incrementing - } + @MainActor + func testAddAttendee() async { + let store = TestStore( + initialState: SyncUpForm.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [], + title: "Engineering" + ) + ) + ) { + SyncUpForm() + } withDependencies: { + $0.uuid = .incrementing + } - XCTAssertNoDifference( - store.state.syncUp.attendees, - [ - Attendee(id: Attendee.ID(UUID(0))) - ] - ) + XCTAssertNoDifference( + store.state.syncUp.attendees, + [ + Attendee(id: Attendee.ID(UUID(0))), + ] + ) - await store.send(.addAttendeeButtonTapped) { - $0.focus = .attendee(Attendee.ID(UUID(1))) - $0.syncUp.attendees = [ - Attendee(id: Attendee.ID(UUID(0))), - Attendee(id: Attendee.ID(UUID(1))), - ] - } - } + await store.send(.addAttendeeButtonTapped) { + $0.focus = .attendee(Attendee.ID(UUID(1))) + $0.syncUp.attendees = [ + Attendee(id: Attendee.ID(UUID(0))), + Attendee(id: Attendee.ID(UUID(1))), + ] + } + } - @MainActor - func testFocus_RemoveAttendee() async { - let store = TestStore( - initialState: SyncUpForm.State( - syncUp: SyncUp( - id: SyncUp.ID(), - attendees: [ - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - Attendee(id: Attendee.ID()), - ], - title: "Engineering" - ) - ) - ) { - SyncUpForm() - } withDependencies: { - $0.uuid = .incrementing - } + @MainActor + func testFocus_RemoveAttendee() async { + let store = TestStore( + initialState: SyncUpForm.State( + syncUp: SyncUp( + id: SyncUp.ID(), + attendees: [ + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + Attendee(id: Attendee.ID()), + ], + title: "Engineering" + ) + ) + ) { + SyncUpForm() + } withDependencies: { + $0.uuid = .incrementing + } - await store.send(.deleteAttendees(atOffsets: [0])) { - $0.focus = .attendee($0.syncUp.attendees[1].id) - $0.syncUp.attendees = [ - $0.syncUp.attendees[1], - $0.syncUp.attendees[2], - $0.syncUp.attendees[3], - ] - } + await store.send(.deleteAttendees(atOffsets: [0])) { + $0.focus = .attendee($0.syncUp.attendees[1].id) + $0.syncUp.attendees = [ + $0.syncUp.attendees[1], + $0.syncUp.attendees[2], + $0.syncUp.attendees[3], + ] + } - await store.send(.deleteAttendees(atOffsets: [1])) { - $0.focus = .attendee($0.syncUp.attendees[2].id) - $0.syncUp.attendees = [ - $0.syncUp.attendees[0], - $0.syncUp.attendees[2], - ] - } + await store.send(.deleteAttendees(atOffsets: [1])) { + $0.focus = .attendee($0.syncUp.attendees[2].id) + $0.syncUp.attendees = [ + $0.syncUp.attendees[0], + $0.syncUp.attendees[2], + ] + } - await store.send(.deleteAttendees(atOffsets: [1])) { - $0.focus = .attendee($0.syncUp.attendees[0].id) - $0.syncUp.attendees = [ - $0.syncUp.attendees[0] - ] - } + await store.send(.deleteAttendees(atOffsets: [1])) { + $0.focus = .attendee($0.syncUp.attendees[0].id) + $0.syncUp.attendees = [ + $0.syncUp.attendees[0], + ] + } - await store.send(.deleteAttendees(atOffsets: [0])) { - $0.focus = .attendee(Attendee.ID(UUID(0))) - $0.syncUp.attendees = [ - Attendee(id: Attendee.ID(UUID(0))) - ] - } - } + await store.send(.deleteAttendees(atOffsets: [0])) { + $0.focus = .attendee(Attendee.ID(UUID(0))) + $0.syncUp.attendees = [ + Attendee(id: Attendee.ID(UUID(0))), + ] + } + } } diff --git a/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift b/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift index 0cb087a..2d2b83d 100644 --- a/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift +++ b/Examples/SyncUps/SyncUpsTests/SyncUpsListTests.swift @@ -4,113 +4,113 @@ import XCTest @testable import SyncUps final class SyncUpsListTests: XCTestCase { - @MainActor - func testAdd() async throws { - let store = TestStore(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() - $0.uuid = .incrementing - } + @MainActor + func testAdd() async throws { + let store = TestStore(initialState: SyncUpsList.State()) { + SyncUpsList() + } withDependencies: { + $0.continuousClock = ImmediateClock() + $0.dataManager = .mock() + $0.uuid = .incrementing + } - var syncUp = SyncUp( - id: SyncUp.ID(UUID(0)), - attendees: [ - Attendee(id: Attendee.ID(UUID(1))) - ] - ) - await store.send(.addSyncUpButtonTapped) { - $0.destination = .add(SyncUpForm.State(syncUp: syncUp)) - } + var syncUp = SyncUp( + id: SyncUp.ID(UUID(0)), + attendees: [ + Attendee(id: Attendee.ID(UUID(1))), + ] + ) + await store.send(.addSyncUpButtonTapped) { + $0.destination = .add(SyncUpForm.State(syncUp: syncUp)) + } - syncUp.title = "Engineering" - await store.send(\.destination.add.binding.syncUp, syncUp) { - $0.destination?.add?.syncUp.title = "Engineering" - } + syncUp.title = "Engineering" + await store.send(\.destination.add.binding.syncUp, syncUp) { + $0.destination?.add?.syncUp.title = "Engineering" + } - await store.send(.confirmAddSyncUpButtonTapped) { - $0.destination = nil - $0.syncUps = [syncUp] - } - } + await store.send(.confirmAddSyncUpButtonTapped) { + $0.destination = nil + $0.syncUps = [syncUp] + } + } - @MainActor - func testAdd_ValidatedAttendees() async throws { - @Dependency(\.uuid) var uuid + @MainActor + func testAdd_ValidatedAttendees() async throws { + @Dependency(\.uuid) var uuid - let store = TestStore( - initialState: SyncUpsList.State( - destination: .add( - SyncUpForm.State( - syncUp: SyncUp( - id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, - attendees: [ - Attendee(id: Attendee.ID(uuid()), name: ""), - Attendee(id: Attendee.ID(uuid()), name: " "), - ], - title: "Design" - ) - ) - ) - ) - ) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock() - $0.uuid = .incrementing - } + let store = TestStore( + initialState: SyncUpsList.State( + destination: .add( + SyncUpForm.State( + syncUp: SyncUp( + id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, + attendees: [ + Attendee(id: Attendee.ID(uuid()), name: ""), + Attendee(id: Attendee.ID(uuid()), name: " "), + ], + title: "Design" + ) + ) + ) + ) + ) { + SyncUpsList() + } withDependencies: { + $0.continuousClock = ImmediateClock() + $0.dataManager = .mock() + $0.uuid = .incrementing + } - await store.send(.confirmAddSyncUpButtonTapped) { - $0.destination = nil - $0.syncUps = [ - SyncUp( - id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, - attendees: [ - Attendee(id: Attendee.ID(UUID(0))) - ], - title: "Design" - ) - ] - } - } + await store.send(.confirmAddSyncUpButtonTapped) { + $0.destination = nil + $0.syncUps = [ + SyncUp( + id: SyncUp.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, + attendees: [ + Attendee(id: Attendee.ID(UUID(0))), + ], + title: "Design" + ), + ] + } + } - @MainActor - func testLoadingDataDecodingFailed() async throws { - let store = TestStore(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager = .mock( - initialData: Data("!@#$ BAD DATA %^&*()".utf8) - ) - } + @MainActor + func testLoadingDataDecodingFailed() async throws { + let store = TestStore(initialState: SyncUpsList.State()) { + SyncUpsList() + } withDependencies: { + $0.continuousClock = ImmediateClock() + $0.dataManager = .mock( + initialData: Data("!@#$ BAD DATA %^&*()".utf8) + ) + } - XCTAssertEqual(store.state.destination, .alert(.dataFailedToLoad)) + XCTAssertEqual(store.state.destination, .alert(.dataFailedToLoad)) - await store.send(\.destination.alert.confirmLoadMockData) { - $0.destination = nil - $0.syncUps = [ - .mock, - .designMock, - .engineeringMock, - ] - } - } + await store.send(\.destination.alert.confirmLoadMockData) { + $0.destination = nil + $0.syncUps = [ + .mock, + .designMock, + .engineeringMock, + ] + } + } - @MainActor - func testLoadingDataFileNotFound() async throws { - let store = TestStore(initialState: SyncUpsList.State()) { - SyncUpsList() - } withDependencies: { - $0.continuousClock = ImmediateClock() - $0.dataManager.load = { @Sendable _ in - struct FileNotFound: Error {} - throw FileNotFound() - } - } + @MainActor + func testLoadingDataFileNotFound() async throws { + let store = TestStore(initialState: SyncUpsList.State()) { + SyncUpsList() + } withDependencies: { + $0.continuousClock = ImmediateClock() + $0.dataManager.load = { @Sendable _ in + struct FileNotFound: Error {} + throw FileNotFound() + } + } - XCTAssertEqual(store.state.destination, nil) - } + XCTAssertEqual(store.state.destination, nil) + } } diff --git a/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift b/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift index 072b5dd..7f73727 100644 --- a/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift +++ b/Examples/SyncUps/SyncUpsUITests/SyncUpsUITests.swift @@ -1,46 +1,46 @@ import XCTest final class SyncUpsUITests: XCTestCase { - @MainActor - var app: XCUIApplication! - - @MainActor - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launchEnvironment = [ - "UITesting": "true" - ] - } - - // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in - // the form, and then adding the sync-up to the list. It's a very simple test, but it takes - // approximately 10 seconds to run, and it depends on a lot of internal implementation details to - // get right, such as tapping a button with the literal label "Add". - // - // This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where - // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when - // the sync-up is added to the list its data will be persisted to disk so that it will be - // available on next launch. - @MainActor - func testAdd() throws { - app.launch() - app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() - - let collectionViews = app.collectionViews - let titleTextField = collectionViews.textFields["Title"] - let nameTextField = collectionViews.textFields["Name"] - - titleTextField.typeText("Engineering") - - nameTextField.tap() - nameTextField.typeText("Blob") - - collectionViews.buttons["New attendee"].tap() - app.typeText("Blob Jr.") - - app.navigationBars["New sync-up"].buttons["Add"].tap() - - XCTAssertEqual(collectionViews.staticTexts["Engineering"].exists, true) - } + @MainActor + var app: XCUIApplication! + + @MainActor + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = [ + "UITesting": "true", + ] + } + + /// This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in + /// the form, and then adding the sync-up to the list. It's a very simple test, but it takes + /// approximately 10 seconds to run, and it depends on a lot of internal implementation details to + /// get right, such as tapping a button with the literal label "Add". + /// + /// This test is also written in the simpler, "unit test" style in SyncUpsListTests.swift, where + /// it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when + /// the sync-up is added to the list its data will be persisted to disk so that it will be + /// available on next launch. + @MainActor + func testAdd() throws { + app.launch() + app.navigationBars["Daily Sync-ups"].buttons["Add"].tap() + + let collectionViews = app.collectionViews + let titleTextField = collectionViews.textFields["Title"] + let nameTextField = collectionViews.textFields["Name"] + + titleTextField.typeText("Engineering") + + nameTextField.tap() + nameTextField.typeText("Blob") + + collectionViews.buttons["New attendee"].tap() + app.typeText("Blob Jr.") + + app.navigationBars["New sync-up"].buttons["Add"].tap() + + XCTAssertEqual(collectionViews.staticTexts["Engineering"].exists, true) + } } diff --git a/Examples/TicTacToe/App/RootView.swift b/Examples/TicTacToe/App/RootView.swift index f3604d8..e4242b3 100644 --- a/Examples/TicTacToe/App/RootView.swift +++ b/Examples/TicTacToe/App/RootView.swift @@ -6,56 +6,56 @@ import ComposableArchitecture import SwiftUI private let readMe = """ - This application demonstrates how to build a moderately complex application in the Composable \ - Architecture. +This application demonstrates how to build a moderately complex application in the Composable \ +Architecture. - It includes a login with two-factor authentication, navigation flows, side effects, game logic, \ - and a full test suite. +It includes a login with two-factor authentication, navigation flows, side effects, game logic, \ +and a full test suite. - This application is super-modularized to demonstrate that it's possible. The core business logic \ - for each screen is put into its own module, and each view is put into its own module. +This application is super-modularized to demonstrate that it's possible. The core business logic \ +for each screen is put into its own module, and each view is put into its own module. - Further, the app has been built in both SwiftUI and UIKit to demonstrate how the patterns \ - translate for each platform. The core business logic is only written a single time, and both \ - SwiftUI and UIKit are run from those modules by adapting their domain to the domain that makes \ - most sense for each platform. - """ +Further, the app has been built in both SwiftUI and UIKit to demonstrate how the patterns \ +translate for each platform. The core business logic is only written a single time, and both \ +SwiftUI and UIKit are run from those modules by adapting their domain to the domain that makes \ +most sense for each platform. +""" enum GameType: Identifiable { - case swiftui - case uikit - var id: Self { self } + case swiftui + case uikit + var id: Self { self } } struct RootView: View { - let store = Store(initialState: TicTacToe.State.login(.init())) { - TicTacToe.body._printChanges() - } - - @State var showGame: GameType? - - var body: some View { - NavigationStack { - Form { - Text(readMe) - - Section { - Button("SwiftUI version") { showGame = .swiftui } - Button("UIKit version") { showGame = .uikit } - } - } - .sheet(item: $showGame) { gameType in - if gameType == .swiftui { - AppView(store: store) - } else { - UIKitAppView(store: store) - } - } - .navigationTitle("Tic-Tac-Toe") - } - } + let store = Store(initialState: TicTacToe.State.login(.init())) { + TicTacToe.body._printChanges() + } + + @State var showGame: GameType? + + var body: some View { + NavigationStack { + Form { + Text(readMe) + + Section { + Button("SwiftUI version") { showGame = .swiftui } + Button("UIKit version") { showGame = .uikit } + } + } + .sheet(item: $showGame) { gameType in + if gameType == .swiftui { + AppView(store: store) + } else { + UIKitAppView(store: store) + } + } + .navigationTitle("Tic-Tac-Toe") + } + } } #Preview { - RootView() + RootView() } diff --git a/Examples/TicTacToe/App/TicTacToeApp.swift b/Examples/TicTacToe/App/TicTacToeApp.swift index f2de900..aee2320 100644 --- a/Examples/TicTacToe/App/TicTacToeApp.swift +++ b/Examples/TicTacToe/App/TicTacToeApp.swift @@ -2,9 +2,9 @@ import SwiftUI @main struct TicTacToeApp: App { - var body: some Scene { - WindowGroup { - RootView() - } - } + var body: some Scene { + WindowGroup { + RootView() + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Package.swift b/Examples/TicTacToe/tic-tac-toe/Package.swift index 174a9a0..b683c15 100644 --- a/Examples/TicTacToe/tic-tac-toe/Package.swift +++ b/Examples/TicTacToe/tic-tac-toe/Package.swift @@ -3,175 +3,175 @@ import PackageDescription let package = Package( - name: "tic-tac-toe", - platforms: [ - .iOS(.v17) - ], - products: [ - .library(name: "AppCore", targets: ["AppCore"]), - .library(name: "AppSwiftUI", targets: ["AppSwiftUI"]), - .library(name: "AppUIKit", targets: ["AppUIKit"]), - .library(name: "AuthenticationClient", targets: ["AuthenticationClient"]), - .library(name: "AuthenticationClientLive", targets: ["AuthenticationClientLive"]), - .library(name: "GameCore", targets: ["GameCore"]), - .library(name: "GameSwiftUI", targets: ["GameSwiftUI"]), - .library(name: "GameUIKit", targets: ["GameUIKit"]), - .library(name: "LoginCore", targets: ["LoginCore"]), - .library(name: "LoginSwiftUI", targets: ["LoginSwiftUI"]), - .library(name: "LoginUIKit", targets: ["LoginUIKit"]), - .library(name: "NewGameCore", targets: ["NewGameCore"]), - .library(name: "NewGameSwiftUI", targets: ["NewGameSwiftUI"]), - .library(name: "NewGameUIKit", targets: ["NewGameUIKit"]), - .library(name: "TwoFactorCore", targets: ["TwoFactorCore"]), - .library(name: "TwoFactorSwiftUI", targets: ["TwoFactorSwiftUI"]), - .library(name: "TwoFactorUIKit", targets: ["TwoFactorUIKit"]), - ], - dependencies: [ - .package(name: "swift-composable-architecture", path: "../../.."), - .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), - ], - targets: [ - .target( - name: "AppCore", - dependencies: [ - "AuthenticationClient", - "LoginCore", - "NewGameCore", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), - .testTarget( - name: "AppCoreTests", - dependencies: ["AppCore"] - ), - .target( - name: "AppSwiftUI", - dependencies: [ - "AppCore", - "LoginSwiftUI", - "NewGameSwiftUI", - ] - ), - .target( - name: "AppUIKit", - dependencies: [ - "AppCore", - "LoginUIKit", - "NewGameUIKit", - ] - ), + name: "tic-tac-toe", + platforms: [ + .iOS(.v17), + ], + products: [ + .library(name: "AppCore", targets: ["AppCore"]), + .library(name: "AppSwiftUI", targets: ["AppSwiftUI"]), + .library(name: "AppUIKit", targets: ["AppUIKit"]), + .library(name: "AuthenticationClient", targets: ["AuthenticationClient"]), + .library(name: "AuthenticationClientLive", targets: ["AuthenticationClientLive"]), + .library(name: "GameCore", targets: ["GameCore"]), + .library(name: "GameSwiftUI", targets: ["GameSwiftUI"]), + .library(name: "GameUIKit", targets: ["GameUIKit"]), + .library(name: "LoginCore", targets: ["LoginCore"]), + .library(name: "LoginSwiftUI", targets: ["LoginSwiftUI"]), + .library(name: "LoginUIKit", targets: ["LoginUIKit"]), + .library(name: "NewGameCore", targets: ["NewGameCore"]), + .library(name: "NewGameSwiftUI", targets: ["NewGameSwiftUI"]), + .library(name: "NewGameUIKit", targets: ["NewGameUIKit"]), + .library(name: "TwoFactorCore", targets: ["TwoFactorCore"]), + .library(name: "TwoFactorSwiftUI", targets: ["TwoFactorSwiftUI"]), + .library(name: "TwoFactorUIKit", targets: ["TwoFactorUIKit"]), + ], + dependencies: [ + .package(name: "swift-composable-architecture", path: "../../.."), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), + ], + targets: [ + .target( + name: "AppCore", + dependencies: [ + "AuthenticationClient", + "LoginCore", + "NewGameCore", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget( + name: "AppCoreTests", + dependencies: ["AppCore"] + ), + .target( + name: "AppSwiftUI", + dependencies: [ + "AppCore", + "LoginSwiftUI", + "NewGameSwiftUI", + ] + ), + .target( + name: "AppUIKit", + dependencies: [ + "AppCore", + "LoginUIKit", + "NewGameUIKit", + ] + ), - .target( - name: "AuthenticationClient", - dependencies: [ - .product(name: "Dependencies", package: "swift-dependencies"), - .product(name: "DependenciesMacros", package: "swift-dependencies"), - ] - ), - .target( - name: "AuthenticationClientLive", - dependencies: ["AuthenticationClient"] - ), + .target( + name: "AuthenticationClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + ] + ), + .target( + name: "AuthenticationClientLive", + dependencies: ["AuthenticationClient"] + ), - .target( - name: "GameCore", - dependencies: [ - .product(name: "ComposableArchitecture", package: "swift-composable-architecture") - ] - ), - .testTarget( - name: "GameCoreTests", - dependencies: ["GameCore"] - ), - .target( - name: "GameSwiftUI", - dependencies: ["GameCore"] - ), - .target( - name: "GameUIKit", - dependencies: ["GameCore"] - ), + .target( + name: "GameCore", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget( + name: "GameCoreTests", + dependencies: ["GameCore"] + ), + .target( + name: "GameSwiftUI", + dependencies: ["GameCore"] + ), + .target( + name: "GameUIKit", + dependencies: ["GameCore"] + ), - .target( - name: "LoginCore", - dependencies: [ - "AuthenticationClient", - "TwoFactorCore", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), - .testTarget( - name: "LoginCoreTests", - dependencies: ["LoginCore"] - ), - .target( - name: "LoginSwiftUI", - dependencies: [ - "LoginCore", - "TwoFactorSwiftUI", - ] - ), - .target( - name: "LoginUIKit", - dependencies: [ - "LoginCore", - "TwoFactorUIKit", - ] - ), + .target( + name: "LoginCore", + dependencies: [ + "AuthenticationClient", + "TwoFactorCore", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget( + name: "LoginCoreTests", + dependencies: ["LoginCore"] + ), + .target( + name: "LoginSwiftUI", + dependencies: [ + "LoginCore", + "TwoFactorSwiftUI", + ] + ), + .target( + name: "LoginUIKit", + dependencies: [ + "LoginCore", + "TwoFactorUIKit", + ] + ), - .target( - name: "NewGameCore", - dependencies: [ - "GameCore", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), - .testTarget( - name: "NewGameCoreTests", - dependencies: ["NewGameCore"] - ), - .target( - name: "NewGameSwiftUI", - dependencies: [ - "GameSwiftUI", - "NewGameCore", - ] - ), - .target( - name: "NewGameUIKit", - dependencies: [ - "GameUIKit", - "NewGameCore", - ] - ), + .target( + name: "NewGameCore", + dependencies: [ + "GameCore", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget( + name: "NewGameCoreTests", + dependencies: ["NewGameCore"] + ), + .target( + name: "NewGameSwiftUI", + dependencies: [ + "GameSwiftUI", + "NewGameCore", + ] + ), + .target( + name: "NewGameUIKit", + dependencies: [ + "GameUIKit", + "NewGameCore", + ] + ), - .target( - name: "TwoFactorCore", - dependencies: [ - "AuthenticationClient", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), - .testTarget( - name: "TwoFactorCoreTests", - dependencies: ["TwoFactorCore"] - ), - .target( - name: "TwoFactorSwiftUI", - dependencies: ["TwoFactorCore"] - ), - .target( - name: "TwoFactorUIKit", - dependencies: ["TwoFactorCore"] - ), - ] + .target( + name: "TwoFactorCore", + dependencies: [ + "AuthenticationClient", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget( + name: "TwoFactorCoreTests", + dependencies: ["TwoFactorCore"] + ), + .target( + name: "TwoFactorSwiftUI", + dependencies: ["TwoFactorCore"] + ), + .target( + name: "TwoFactorUIKit", + dependencies: ["TwoFactorCore"] + ), + ] ) for target in package.targets { - target.swiftSettings = [ - .unsafeFlags([ - "-Xfrontend", "-enable-actor-data-race-checks", - "-Xfrontend", "-warn-concurrency", - ]) - ] + target.swiftSettings = [ + .unsafeFlags([ + "-Xfrontend", "-enable-actor-data-race-checks", + "-Xfrontend", "-warn-concurrency", + ]), + ] } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift index 552c2a8..6b155b4 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swift @@ -4,36 +4,36 @@ import NewGameCore @Reducer(state: .equatable) public enum TicTacToe { - case login(Login) - case newGame(NewGame) + case login(Login) + case newGame(NewGame) - public static var body: some ReducerOf { - Reduce { state, action in - switch action { - case .login(.twoFactor(.presented(.twoFactorResponse(.success)))): - state = .newGame(NewGame.State()) - return .none + public static var body: some ReducerOf { + Reduce { state, action in + switch action { + case .login(.twoFactor(.presented(.twoFactorResponse(.success)))): + state = .newGame(NewGame.State()) + return .none - case let .login(.loginResponse(.success(response))) where !response.twoFactorRequired: - state = .newGame(NewGame.State()) - return .none + case let .login(.loginResponse(.success(response))) where !response.twoFactorRequired: + state = .newGame(NewGame.State()) + return .none - case .login: - return .none + case .login: + return .none - case .newGame(.logoutButtonTapped): - state = .login(Login.State()) - return .none + case .newGame(.logoutButtonTapped): + state = .login(Login.State()) + return .none - case .newGame: - return .none - } - } - .ifCaseLet(\.login, action: \.login) { - Login() - } - .ifCaseLet(\.newGame, action: \.newGame) { - NewGame() - } - } + case .newGame: + return .none + } + } + .ifCaseLet(\.login, action: \.login) { + Login() + } + .ifCaseLet(\.newGame, action: \.newGame) { + NewGame() + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift index ba60609..b25b7df 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppSwiftUI/AppView.swift @@ -5,22 +5,22 @@ import NewGameSwiftUI import SwiftUI public struct AppView: View { - let store: StoreOf + let store: StoreOf - public init(store: StoreOf) { - self.store = store - } + public init(store: StoreOf) { + self.store = store + } - public var body: some View { - switch store.case { - case let .login(store): - NavigationStack { - LoginView(store: store) - } - case let .newGame(store): - NavigationStack { - NewGameView(store: store) - } - } - } + public var body: some View { + switch store.case { + case let .login(store): + NavigationStack { + LoginView(store: store) + } + case let .newGame(store): + NavigationStack { + NewGameView(store: store) + } + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift index 5fa1e05..d363097 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AppUIKit/AppViewController.swift @@ -6,47 +6,48 @@ import SwiftUI import UIKit public struct UIKitAppView: UIViewControllerRepresentable { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public func makeUIViewController(context: Context) -> UIViewController { - AppViewController(store: store) - } - - public func updateUIViewController( - _ uiViewController: UIViewController, - context: Context - ) { - // Nothing to do - } + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + AppViewController(store: store) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } } class AppViewController: UINavigationController { - let store: StoreOf - - init(store: StoreOf) { - self.store = store - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - observe { [weak self] in - guard let self else { return } - switch store.case { - case let .login(store): - setViewControllers([LoginViewController(store: store)], animated: false) - case let .newGame(store): - setViewControllers([NewGameViewController(store: store)], animated: false) - } - } - } + let store: StoreOf + + init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + observe { [weak self] in + guard let self else { return } + switch store.case { + case let .login(store): + setViewControllers([LoginViewController(store: store)], animated: false) + case let .newGame(store): + setViewControllers([NewGameViewController(store: store)], animated: false) + } + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift index 54e4aae..aa2db3e 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClient/AuthenticationClient.swift @@ -3,50 +3,50 @@ import DependenciesMacros import Foundation public struct AuthenticationResponse: Equatable, Sendable { - public var token: String - public var twoFactorRequired: Bool + public var token: String + public var twoFactorRequired: Bool - public init( - token: String, - twoFactorRequired: Bool - ) { - self.token = token - self.twoFactorRequired = twoFactorRequired - } + public init( + token: String, + twoFactorRequired: Bool + ) { + self.token = token + self.twoFactorRequired = twoFactorRequired + } } public enum AuthenticationError: Equatable, LocalizedError, Sendable { - case invalidUserPassword - case invalidTwoFactor - case invalidIntermediateToken + case invalidUserPassword + case invalidTwoFactor + case invalidIntermediateToken - public var errorDescription: String? { - switch self { - case .invalidUserPassword: - return "Unknown user or invalid password." - case .invalidTwoFactor: - return "Invalid second factor (try 1234)" - case .invalidIntermediateToken: - return "404!! What happened to your token there bud?!?!" - } - } + public var errorDescription: String? { + switch self { + case .invalidUserPassword: + return "Unknown user or invalid password." + case .invalidTwoFactor: + return "Invalid second factor (try 1234)" + case .invalidIntermediateToken: + return "404!! What happened to your token there bud?!?!" + } + } } @DependencyClient public struct AuthenticationClient: Sendable { - public var login: - @Sendable (_ email: String, _ password: String) async throws -> AuthenticationResponse - public var twoFactor: - @Sendable (_ code: String, _ token: String) async throws -> AuthenticationResponse + public var login: + @Sendable (_ email: String, _ password: String) async throws -> AuthenticationResponse + public var twoFactor: + @Sendable (_ code: String, _ token: String) async throws -> AuthenticationResponse } extension AuthenticationClient: TestDependencyKey { - public static let testValue = Self() + public static let testValue = Self() } -extension DependencyValues { - public var authenticationClient: AuthenticationClient { - get { self[AuthenticationClient.self] } - set { self[AuthenticationClient.self] = newValue } - } +public extension DependencyValues { + var authenticationClient: AuthenticationClient { + get { self[AuthenticationClient.self] } + set { self[AuthenticationClient.self] = newValue } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift index 0de50a0..44e4e7d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/AuthenticationClientLive/LiveAuthenticationClient.swift @@ -3,25 +3,25 @@ import Dependencies import Foundation extension AuthenticationClient: DependencyKey { - public static let liveValue = Self( - login: { email, password in - guard email.contains("@") && password == "password" - else { throw AuthenticationError.invalidUserPassword } + public static let liveValue = Self( + login: { email, password in + guard email.contains("@"), password == "password" + else { throw AuthenticationError.invalidUserPassword } - try await Task.sleep(for: .seconds(1)) - return AuthenticationResponse( - token: "deadbeef", twoFactorRequired: email.contains("2fa") - ) - }, - twoFactor: { code, token in - guard token == "deadbeef" - else { throw AuthenticationError.invalidIntermediateToken } + try await Task.sleep(for: .seconds(1)) + return AuthenticationResponse( + token: "deadbeef", twoFactorRequired: email.contains("2fa") + ) + }, + twoFactor: { code, token in + guard token == "deadbeef" + else { throw AuthenticationError.invalidIntermediateToken } - guard code == "1234" - else { throw AuthenticationError.invalidTwoFactor } + guard code == "1234" + else { throw AuthenticationError.invalidTwoFactor } - try await Task.sleep(for: .seconds(1)) - return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) - } - ) + try await Task.sleep(for: .seconds(1)) + return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } + ) } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift index a412b6a..3bad79c 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/GameCore.swift @@ -3,120 +3,120 @@ import SwiftUI @Reducer public struct Game: Sendable { - @ObservableState - public struct State: Equatable { - public var board: Three> = .empty - public var currentPlayer: Player = .x - public let oPlayerName: String - public let xPlayerName: String - - public init(oPlayerName: String, xPlayerName: String) { - self.oPlayerName = oPlayerName - self.xPlayerName = xPlayerName - } - - public var currentPlayerName: String { - switch self.currentPlayer { - case .o: return self.oPlayerName - case .x: return self.xPlayerName - } - } - } - - public enum Action: Sendable { - case cellTapped(row: Int, column: Int) - case playAgainButtonTapped - case quitButtonTapped - } - - @Dependency(\.dismiss) var dismiss - - public init() {} - - public var body: some Reducer { - Reduce { state, action in - switch action { - case let .cellTapped(row, column): - guard - state.board[row][column] == nil, - !state.board.hasWinner - else { return .none } - - state.board[row][column] = state.currentPlayer - - if !state.board.hasWinner { - state.currentPlayer.toggle() - } - - return .none - - case .playAgainButtonTapped: - state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName) - return .none - - case .quitButtonTapped: - return .run { _ in - await self.dismiss() - } - } - } - } + @ObservableState + public struct State: Equatable { + public var board: Three> = .empty + public var currentPlayer: Player = .x + public let oPlayerName: String + public let xPlayerName: String + + public init(oPlayerName: String, xPlayerName: String) { + self.oPlayerName = oPlayerName + self.xPlayerName = xPlayerName + } + + public var currentPlayerName: String { + switch currentPlayer { + case .o: return oPlayerName + case .x: return xPlayerName + } + } + } + + public enum Action: Sendable { + case cellTapped(row: Int, column: Int) + case playAgainButtonTapped + case quitButtonTapped + } + + @Dependency(\.dismiss) var dismiss + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .cellTapped(row, column): + guard + state.board[row][column] == nil, + !state.board.hasWinner + else { return .none } + + state.board[row][column] = state.currentPlayer + + if !state.board.hasWinner { + state.currentPlayer.toggle() + } + + return .none + + case .playAgainButtonTapped: + state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName) + return .none + + case .quitButtonTapped: + return .run { _ in + await dismiss() + } + } + } + } } public enum Player: Equatable, Sendable { - case o - case x - - public mutating func toggle() { - switch self { - case .o: self = .x - case .x: self = .o - } - } - - public var label: String { - switch self { - case .o: return "⭕️" - case .x: return "❌" - } - } + case o + case x + + public mutating func toggle() { + switch self { + case .o: self = .x + case .x: self = .o + } + } + + public var label: String { + switch self { + case .o: return "⭕️" + case .x: return "❌" + } + } } -extension Three where Element == Three { - public static let empty = Self( - .init(nil, nil, nil), - .init(nil, nil, nil), - .init(nil, nil, nil) - ) - - public var isFilled: Bool { - self.allSatisfy { $0.allSatisfy { $0 != nil } } - } - - func hasWin(_ player: Player) -> Bool { - let winConditions = [ - [0, 1, 2], [3, 4, 5], [6, 7, 8], - [0, 3, 6], [1, 4, 7], [2, 5, 8], - [0, 4, 8], [6, 4, 2], - ] - - for condition in winConditions { - let matches = - condition - .map { self[$0 % 3][$0 / 3] } - let matchCount = - matches - .filter { $0 == player } - .count - - if matchCount == 3 { - return true - } - } - return false - } - - public var hasWinner: Bool { - hasWin(.x) || hasWin(.o) - } +public extension Three where Element == Three { + static let empty = Self( + .init(nil, nil, nil), + .init(nil, nil, nil), + .init(nil, nil, nil) + ) + + var isFilled: Bool { + allSatisfy { $0.allSatisfy { $0 != nil } } + } + + func hasWin(_ player: Player) -> Bool { + let winConditions = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [6, 4, 2], + ] + + for condition in winConditions { + let matches = + condition + .map { self[$0 % 3][$0 / 3] } + let matchCount = + matches + .filter { $0 == player } + .count + + if matchCount == 3 { + return true + } + } + return false + } + + var hasWinner: Bool { + hasWin(.x) || hasWin(.o) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/Three.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/Three.swift index b3982f2..a777ead 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/Three.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameCore/Three.swift @@ -1,43 +1,43 @@ /// A collection of three elements. public struct Three { - public var first: Element - public var second: Element - public var third: Element + public var first: Element + public var second: Element + public var third: Element - public init(_ first: Element, _ second: Element, _ third: Element) { - self.first = first - self.second = second - self.third = third - } + public init(_ first: Element, _ second: Element, _ third: Element) { + self.first = first + self.second = second + self.third = third + } - public func map(_ transform: (Element) -> T) -> Three { - .init(transform(self.first), transform(self.second), transform(self.third)) - } + public func map(_ transform: (Element) -> T) -> Three { + .init(transform(first), transform(second), transform(third)) + } } extension Three: MutableCollection { - public subscript(offset: Int) -> Element { - _read { - switch offset { - case 0: yield self.first - case 1: yield self.second - case 2: yield self.third - default: fatalError() - } - } - _modify { - switch offset { - case 0: yield &self.first - case 1: yield &self.second - case 2: yield &self.third - default: fatalError() - } - } - } + public subscript(offset: Int) -> Element { + _read { + switch offset { + case 0: yield self.first + case 1: yield self.second + case 2: yield self.third + default: fatalError() + } + } + _modify { + switch offset { + case 0: yield &self.first + case 1: yield &self.second + case 2: yield &self.third + default: fatalError() + } + } + } - public var startIndex: Int { 0 } - public var endIndex: Int { 3 } - public func index(after i: Int) -> Int { i + 1 } + public var startIndex: Int { 0 } + public var endIndex: Int { 3 } + public func index(after i: Int) -> Int { i + 1 } } extension Three: RandomAccessCollection {} diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift index 13e28b0..df5e42f 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift @@ -3,91 +3,91 @@ import GameCore import SwiftUI public struct GameView: View { - let store: StoreOf + let store: StoreOf - public init(store: StoreOf) { - self.store = store - } + public init(store: StoreOf) { + self.store = store + } - public var body: some View { - GeometryReader { proxy in - VStack(spacing: 0.0) { - VStack { - Text(store.title) - .font(.title) + public var body: some View { + GeometryReader { proxy in + VStack(spacing: 0.0) { + VStack { + Text(store.title) + .font(.title) - if store.isPlayAgainButtonVisible { - Button("Play again?") { - store.send(.playAgainButtonTapped) - } - .padding(.top, 12) - .font(.title) - } - } - .padding(.bottom, 48) + if store.isPlayAgainButtonVisible { + Button("Play again?") { + store.send(.playAgainButtonTapped) + } + .padding(.top, 12) + .font(.title) + } + } + .padding(.bottom, 48) - VStack { - rowView(row: 0, proxy: proxy) - rowView(row: 1, proxy: proxy) - rowView(row: 2, proxy: proxy) - } - .disabled(store.isGameDisabled) - } - .navigationTitle("Tic-tac-toe") - .navigationBarItems(leading: Button("Quit") { store.send(.quitButtonTapped) }) - .navigationBarBackButtonHidden(true) - } - } + VStack { + rowView(row: 0, proxy: proxy) + rowView(row: 1, proxy: proxy) + rowView(row: 2, proxy: proxy) + } + .disabled(store.isGameDisabled) + } + .navigationTitle("Tic-tac-toe") + .navigationBarItems(leading: Button("Quit") { store.send(.quitButtonTapped) }) + .navigationBarBackButtonHidden(true) + } + } - func rowView( - row: Int, - proxy: GeometryProxy - ) -> some View { - HStack(spacing: 0.0) { - cellView(row: row, column: 0, proxy: proxy) - cellView(row: row, column: 1, proxy: proxy) - cellView(row: row, column: 2, proxy: proxy) - } - } + func rowView( + row: Int, + proxy: GeometryProxy + ) -> some View { + HStack(spacing: 0.0) { + cellView(row: row, column: 0, proxy: proxy) + cellView(row: row, column: 1, proxy: proxy) + cellView(row: row, column: 2, proxy: proxy) + } + } - func cellView( - row: Int, - column: Int, - proxy: GeometryProxy - ) -> some View { - Button { - store.send(.cellTapped(row: row, column: column)) - } label: { - Text(store.rows[row][column]) - .frame(width: proxy.size.width / 3, height: proxy.size.width / 3) - .background( - (row + column).isMultiple(of: 2) - ? Color(red: 0.8, green: 0.8, blue: 0.8) - : Color(red: 0.6, green: 0.6, blue: 0.6) - ) - } - } + func cellView( + row: Int, + column: Int, + proxy: GeometryProxy + ) -> some View { + Button { + store.send(.cellTapped(row: row, column: column)) + } label: { + Text(store.rows[row][column]) + .frame(width: proxy.size.width / 3, height: proxy.size.width / 3) + .background( + (row + column).isMultiple(of: 2) + ? Color(red: 0.8, green: 0.8, blue: 0.8) + : Color(red: 0.6, green: 0.6, blue: 0.6) + ) + } + } } -extension Game.State { - fileprivate var rows: [[String]] { self.board.map { $0.map { $0?.label ?? "" } } } - fileprivate var isGameDisabled: Bool { self.board.hasWinner || self.board.isFilled } - fileprivate var isPlayAgainButtonVisible: Bool { self.board.hasWinner || self.board.isFilled } - fileprivate var title: String { - self.board.hasWinner - ? "Winner! Congrats \(self.currentPlayerName)!" - : self.board.isFilled - ? "Tied game!" - : "\(self.currentPlayerName), place your \(self.currentPlayer.label)" - } +private extension Game.State { + var rows: [[String]] { board.map { $0.map { $0?.label ?? "" } } } + var isGameDisabled: Bool { board.hasWinner || board.isFilled } + var isPlayAgainButtonVisible: Bool { board.hasWinner || board.isFilled } + var title: String { + board.hasWinner + ? "Winner! Congrats \(currentPlayerName)!" + : board.isFilled + ? "Tied game!" + : "\(currentPlayerName), place your \(currentPlayer.label)" + } } #Preview { - NavigationStack { - GameView( - store: Store(initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.")) { - Game() - } - ) - } + NavigationStack { + GameView( + store: Store(initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.")) { + Game() + } + ) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift index b457fc9..4a5f72c 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/GameUIKit/GameViewController.swift @@ -3,152 +3,153 @@ import GameCore import UIKit public final class GameViewController: UIViewController { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - - self.navigationItem.title = "Tic-Tac-Toe" - self.view.backgroundColor = .systemBackground - - self.navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "Quit", - style: .done, - target: self, - action: #selector(quitButtonTapped) - ) - - let titleLabel = UILabel() - titleLabel.textAlignment = .center - - let playAgainButton = UIButton(type: .system) - playAgainButton.setTitle("Play again?", for: .normal) - playAgainButton.addTarget(self, action: #selector(playAgainButtonTapped), for: .touchUpInside) - - let titleStackView = UIStackView(arrangedSubviews: [titleLabel, playAgainButton]) - titleStackView.axis = .vertical - titleStackView.spacing = 12 - - let gridCell11 = UIButton() - gridCell11.addTarget(self, action: #selector(gridCell11Tapped), for: .touchUpInside) - let gridCell21 = UIButton() - gridCell21.addTarget(self, action: #selector(gridCell21Tapped), for: .touchUpInside) - let gridCell31 = UIButton() - gridCell31.addTarget(self, action: #selector(gridCell31Tapped), for: .touchUpInside) - let gridCell12 = UIButton() - gridCell12.addTarget(self, action: #selector(gridCell12Tapped), for: .touchUpInside) - let gridCell22 = UIButton() - gridCell22.addTarget(self, action: #selector(gridCell22Tapped), for: .touchUpInside) - let gridCell32 = UIButton() - gridCell32.addTarget(self, action: #selector(gridCell32Tapped), for: .touchUpInside) - let gridCell13 = UIButton() - gridCell13.addTarget(self, action: #selector(gridCell13Tapped), for: .touchUpInside) - let gridCell23 = UIButton() - gridCell23.addTarget(self, action: #selector(gridCell23Tapped), for: .touchUpInside) - let gridCell33 = UIButton() - gridCell33.addTarget(self, action: #selector(gridCell33Tapped), for: .touchUpInside) - - let cells = [ - [gridCell11, gridCell12, gridCell13], - [gridCell21, gridCell22, gridCell23], - [gridCell31, gridCell32, gridCell33], - ] - - let gameRow1StackView = UIStackView(arrangedSubviews: cells[0]) - gameRow1StackView.spacing = 6 - let gameRow2StackView = UIStackView(arrangedSubviews: cells[1]) - gameRow2StackView.spacing = 6 - let gameRow3StackView = UIStackView(arrangedSubviews: cells[2]) - gameRow3StackView.spacing = 6 - - let gameStackView = UIStackView(arrangedSubviews: [ - gameRow1StackView, - gameRow2StackView, - gameRow3StackView, - ]) - gameStackView.axis = .vertical - gameStackView.spacing = 6 - - let rootStackView = UIStackView(arrangedSubviews: [ - titleStackView, - gameStackView, - ]) - rootStackView.isLayoutMarginsRelativeArrangement = true - rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) - rootStackView.translatesAutoresizingMaskIntoConstraints = false - rootStackView.axis = .vertical - rootStackView.spacing = 100 - - self.view.addSubview(rootStackView) - - NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), - ]) - - gameStackView.arrangedSubviews - .flatMap { view in (view as? UIStackView)?.arrangedSubviews ?? [] } - .enumerated() - .forEach { idx, cellView in - cellView.backgroundColor = idx % 2 == 0 ? .darkGray : .lightGray - NSLayoutConstraint.activate([ - cellView.widthAnchor.constraint(equalTo: cellView.heightAnchor) - ]) - } - - observe { [weak self] in - guard let self else { return } - titleLabel.text = self.store.title - playAgainButton.isHidden = self.store.isPlayAgainButtonHidden - - for (rowIdx, row) in self.store.rows.enumerated() { - for (colIdx, label) in row.enumerated() { - let button = cells[rowIdx][colIdx] - button.setTitle(label, for: .normal) - button.isEnabled = self.store.isGameEnabled - } - } - } - } - - @objc private func gridCell11Tapped() { self.store.send(.cellTapped(row: 0, column: 0)) } - @objc private func gridCell12Tapped() { self.store.send(.cellTapped(row: 0, column: 1)) } - @objc private func gridCell13Tapped() { self.store.send(.cellTapped(row: 0, column: 2)) } - @objc private func gridCell21Tapped() { self.store.send(.cellTapped(row: 1, column: 0)) } - @objc private func gridCell22Tapped() { self.store.send(.cellTapped(row: 1, column: 1)) } - @objc private func gridCell23Tapped() { self.store.send(.cellTapped(row: 1, column: 2)) } - @objc private func gridCell31Tapped() { self.store.send(.cellTapped(row: 2, column: 0)) } - @objc private func gridCell32Tapped() { self.store.send(.cellTapped(row: 2, column: 1)) } - @objc private func gridCell33Tapped() { self.store.send(.cellTapped(row: 2, column: 2)) } - - @objc private func quitButtonTapped() { - self.store.send(.quitButtonTapped) - } - - @objc private func playAgainButtonTapped() { - self.store.send(.playAgainButtonTapped) - } + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = "Tic-Tac-Toe" + view.backgroundColor = .systemBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Quit", + style: .done, + target: self, + action: #selector(quitButtonTapped) + ) + + let titleLabel = UILabel() + titleLabel.textAlignment = .center + + let playAgainButton = UIButton(type: .system) + playAgainButton.setTitle("Play again?", for: .normal) + playAgainButton.addTarget(self, action: #selector(playAgainButtonTapped), for: .touchUpInside) + + let titleStackView = UIStackView(arrangedSubviews: [titleLabel, playAgainButton]) + titleStackView.axis = .vertical + titleStackView.spacing = 12 + + let gridCell11 = UIButton() + gridCell11.addTarget(self, action: #selector(gridCell11Tapped), for: .touchUpInside) + let gridCell21 = UIButton() + gridCell21.addTarget(self, action: #selector(gridCell21Tapped), for: .touchUpInside) + let gridCell31 = UIButton() + gridCell31.addTarget(self, action: #selector(gridCell31Tapped), for: .touchUpInside) + let gridCell12 = UIButton() + gridCell12.addTarget(self, action: #selector(gridCell12Tapped), for: .touchUpInside) + let gridCell22 = UIButton() + gridCell22.addTarget(self, action: #selector(gridCell22Tapped), for: .touchUpInside) + let gridCell32 = UIButton() + gridCell32.addTarget(self, action: #selector(gridCell32Tapped), for: .touchUpInside) + let gridCell13 = UIButton() + gridCell13.addTarget(self, action: #selector(gridCell13Tapped), for: .touchUpInside) + let gridCell23 = UIButton() + gridCell23.addTarget(self, action: #selector(gridCell23Tapped), for: .touchUpInside) + let gridCell33 = UIButton() + gridCell33.addTarget(self, action: #selector(gridCell33Tapped), for: .touchUpInside) + + let cells = [ + [gridCell11, gridCell12, gridCell13], + [gridCell21, gridCell22, gridCell23], + [gridCell31, gridCell32, gridCell33], + ] + + let gameRow1StackView = UIStackView(arrangedSubviews: cells[0]) + gameRow1StackView.spacing = 6 + let gameRow2StackView = UIStackView(arrangedSubviews: cells[1]) + gameRow2StackView.spacing = 6 + let gameRow3StackView = UIStackView(arrangedSubviews: cells[2]) + gameRow3StackView.spacing = 6 + + let gameStackView = UIStackView(arrangedSubviews: [ + gameRow1StackView, + gameRow2StackView, + gameRow3StackView, + ]) + gameStackView.axis = .vertical + gameStackView.spacing = 6 + + let rootStackView = UIStackView(arrangedSubviews: [ + titleStackView, + gameStackView, + ]) + rootStackView.isLayoutMarginsRelativeArrangement = true + rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) + rootStackView.translatesAutoresizingMaskIntoConstraints = false + rootStackView.axis = .vertical + rootStackView.spacing = 100 + + view.addSubview(rootStackView) + + NSLayoutConstraint.activate([ + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + gameStackView.arrangedSubviews + .flatMap { view in (view as? UIStackView)?.arrangedSubviews ?? [] } + .enumerated() + .forEach { idx, cellView in + cellView.backgroundColor = idx % 2 == 0 ? .darkGray : .lightGray + NSLayoutConstraint.activate([ + cellView.widthAnchor.constraint(equalTo: cellView.heightAnchor), + ]) + } + + observe { [weak self] in + guard let self else { return } + titleLabel.text = self.store.title + playAgainButton.isHidden = self.store.isPlayAgainButtonHidden + + for (rowIdx, row) in self.store.rows.enumerated() { + for (colIdx, label) in row.enumerated() { + let button = cells[rowIdx][colIdx] + button.setTitle(label, for: .normal) + button.isEnabled = self.store.isGameEnabled + } + } + } + } + + @objc private func gridCell11Tapped() { store.send(.cellTapped(row: 0, column: 0)) } + @objc private func gridCell12Tapped() { store.send(.cellTapped(row: 0, column: 1)) } + @objc private func gridCell13Tapped() { store.send(.cellTapped(row: 0, column: 2)) } + @objc private func gridCell21Tapped() { store.send(.cellTapped(row: 1, column: 0)) } + @objc private func gridCell22Tapped() { store.send(.cellTapped(row: 1, column: 1)) } + @objc private func gridCell23Tapped() { store.send(.cellTapped(row: 1, column: 2)) } + @objc private func gridCell31Tapped() { store.send(.cellTapped(row: 2, column: 0)) } + @objc private func gridCell32Tapped() { store.send(.cellTapped(row: 2, column: 1)) } + @objc private func gridCell33Tapped() { store.send(.cellTapped(row: 2, column: 2)) } + + @objc private func quitButtonTapped() { + store.send(.quitButtonTapped) + } + + @objc private func playAgainButtonTapped() { + store.send(.playAgainButtonTapped) + } } -extension Game.State { - fileprivate var rows: Three> { self.board.map { $0.map { $0?.label ?? "" } } } - fileprivate var isGameEnabled: Bool { !self.board.hasWinner && !self.board.isFilled } - fileprivate var isPlayAgainButtonHidden: Bool { !self.board.hasWinner && !self.board.isFilled } - fileprivate var title: String { - self.board.hasWinner - ? "Winner! Congrats \(self.currentPlayerName)!" - : self.board.isFilled - ? "Tied game!" - : "\(self.currentPlayerName), place your \(self.currentPlayer.label)" - } +private extension Game.State { + var rows: Three> { board.map { $0.map { $0?.label ?? "" } } } + var isGameEnabled: Bool { !board.hasWinner && !board.isFilled } + var isPlayAgainButtonHidden: Bool { !board.hasWinner && !board.isFilled } + var title: String { + board.hasWinner + ? "Winner! Congrats \(currentPlayerName)!" + : board.isFilled + ? "Tied game!" + : "\(currentPlayerName), place your \(currentPlayer.label)" + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift index 87223b7..07ade3d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginCore/LoginCore.swift @@ -5,79 +5,79 @@ import TwoFactorCore @Reducer public struct Login: Sendable { - @ObservableState - public struct State: Equatable { - @Presents public var alert: AlertState? - public var email = "" - public var isFormValid = false - public var isLoginRequestInFlight = false - public var password = "" - @Presents public var twoFactor: TwoFactor.State? + @ObservableState + public struct State: Equatable { + @Presents public var alert: AlertState? + public var email = "" + public var isFormValid = false + public var isLoginRequestInFlight = false + public var password = "" + @Presents public var twoFactor: TwoFactor.State? - public init() {} - } + public init() {} + } - public enum Action: Sendable, ViewAction { - case alert(PresentationAction) - case loginResponse(Result) - case twoFactor(PresentationAction) - case view(View) + public enum Action: Sendable, ViewAction { + case alert(PresentationAction) + case loginResponse(Result) + case twoFactor(PresentationAction) + case view(View) - public enum Alert: Equatable, Sendable {} + public enum Alert: Equatable, Sendable {} - @CasePathable - public enum View: BindableAction, Sendable { - case binding(BindingAction) - case loginButtonTapped - } - } + @CasePathable + public enum View: BindableAction, Sendable { + case binding(BindingAction) + case loginButtonTapped + } + } - @Dependency(\.authenticationClient) var authenticationClient + @Dependency(\.authenticationClient) var authenticationClient - public init() {} + public init() {} - public var body: some Reducer { - BindingReducer(action: \.view) - Reduce { state, action in - switch action { - case .alert: - return .none + public var body: some Reducer { + BindingReducer(action: \.view) + Reduce { state, action in + switch action { + case .alert: + return .none - case let .loginResponse(.success(response)): - state.isLoginRequestInFlight = false - if response.twoFactorRequired { - state.twoFactor = TwoFactor.State(token: response.token) - } - return .none + case let .loginResponse(.success(response)): + state.isLoginRequestInFlight = false + if response.twoFactorRequired { + state.twoFactor = TwoFactor.State(token: response.token) + } + return .none - case let .loginResponse(.failure(error)): - state.alert = AlertState { TextState(error.localizedDescription) } - state.isLoginRequestInFlight = false - return .none + case let .loginResponse(.failure(error)): + state.alert = AlertState { TextState(error.localizedDescription) } + state.isLoginRequestInFlight = false + return .none - case .twoFactor: - return .none + case .twoFactor: + return .none - case .view(.binding): - state.isFormValid = !state.email.isEmpty && !state.password.isEmpty - return .none + case .view(.binding): + state.isFormValid = !state.email.isEmpty && !state.password.isEmpty + return .none - case .view(.loginButtonTapped): - state.isLoginRequestInFlight = true - return .run { [email = state.email, password = state.password] send in - await send( - .loginResponse( - Result { - try await self.authenticationClient.login(email: email, password: password) - } - ) - ) - } - } - } - .ifLet(\.$alert, action: \.alert) - .ifLet(\.$twoFactor, action: \.twoFactor) { - TwoFactor() - } - } + case .view(.loginButtonTapped): + state.isLoginRequestInFlight = true + return .run { [email = state.email, password = state.password] send in + await send( + .loginResponse( + Result { + try await authenticationClient.login(email: email, password: password) + } + ) + ) + } + } + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$twoFactor, action: \.twoFactor) { + TwoFactor() + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift index eb40c5d..56a8c41 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginSwiftUI/LoginView.swift @@ -7,79 +7,79 @@ import TwoFactorSwiftUI @ViewAction(for: Login.self) public struct LoginView: View { - @Bindable public var store: StoreOf + @Bindable public var store: StoreOf - public init(store: StoreOf) { - self.store = store - } + public init(store: StoreOf) { + self.store = store + } - public var body: some View { - Form { - Text( - """ - To login use any email and "password" for the password. If your email contains the \ - characters "2fa" you will be taken to a two-factor flow, and on that screen you can \ - use "1234" for the code. - """ - ) + public var body: some View { + Form { + Text( + """ + To login use any email and "password" for the password. If your email contains the \ + characters "2fa" you will be taken to a two-factor flow, and on that screen you can \ + use "1234" for the code. + """ + ) - Section { - TextField("blob@pointfree.co", text: $store.email) - .autocapitalization(.none) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) + Section { + TextField("blob@pointfree.co", text: $store.email) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) - SecureField("••••••••", text: $store.password) - } + SecureField("••••••••", text: $store.password) + } - Button { - // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" if - // you disable a text field while it is focused. This hack will force all fields to - // unfocus before we send the action to the store. - // CF: https://stackoverflow.com/a/69653555 - _ = UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil - ) - send(.loginButtonTapped) - } label: { - HStack { - Text("Log in") - if store.isActivityIndicatorVisible { - Spacer() - ProgressView() - } - } - } - .disabled(store.isLoginButtonDisabled) - } - .disabled(store.isFormDisabled) - .alert($store.scope(state: \.alert, action: \.alert)) - .navigationDestination(item: $store.scope(state: \.twoFactor, action: \.twoFactor)) { store in - TwoFactorView(store: store) - } - .navigationTitle("Login") - } + Button { + // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" if + // you disable a text field while it is focused. This hack will force all fields to + // unfocus before we send the action to the store. + // CF: https://stackoverflow.com/a/69653555 + _ = UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + send(.loginButtonTapped) + } label: { + HStack { + Text("Log in") + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + .disabled(store.isLoginButtonDisabled) + } + .disabled(store.isFormDisabled) + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationDestination(item: $store.scope(state: \.twoFactor, action: \.twoFactor)) { store in + TwoFactorView(store: store) + } + .navigationTitle("Login") + } } -extension Login.State { - fileprivate var isActivityIndicatorVisible: Bool { self.isLoginRequestInFlight } - fileprivate var isFormDisabled: Bool { self.isLoginRequestInFlight } - fileprivate var isLoginButtonDisabled: Bool { !self.isFormValid } +private extension Login.State { + var isActivityIndicatorVisible: Bool { isLoginRequestInFlight } + var isFormDisabled: Bool { isLoginRequestInFlight } + var isLoginButtonDisabled: Bool { !isFormValid } } #Preview { - NavigationStack { - LoginView( - store: Store(initialState: Login.State()) { - Login() - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - $0.authenticationClient.twoFactor = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - } - ) - } + NavigationStack { + LoginView( + store: Store(initialState: Login.State()) { + Login() + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + $0.authenticationClient.twoFactor = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } + ) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift index 8b9df2d..28af8e7 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/LoginUIKit/LoginViewController.swift @@ -5,154 +5,155 @@ import UIKit @ViewAction(for: Login.self) public class LoginViewController: UIViewController { - public let store: StoreOf - - public init(store: StoreOf) { - self.store = store - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - - navigationItem.title = "Login" - view.backgroundColor = .systemBackground - - let disclaimerLabel = UILabel() - disclaimerLabel.text = """ - To login use any email and "password" for the password. If your email contains the \ - characters "2fa" you will be taken to a two-factor flow, and on that screen you can use \ - "1234" for the code. - """ - disclaimerLabel.textAlignment = .left - disclaimerLabel.numberOfLines = 0 - - let divider = UIView() - divider.backgroundColor = .gray - - let titleLabel = UILabel() - titleLabel.text = "Please log in to play TicTacToe!" - titleLabel.font = UIFont.preferredFont(forTextStyle: .title2) - titleLabel.numberOfLines = 0 - - let emailTextField = UITextField() - emailTextField.placeholder = "email@address.com" - emailTextField.borderStyle = .roundedRect - emailTextField.autocapitalizationType = .none - emailTextField.addTarget( - self, action: #selector(emailTextFieldChanged(sender:)), for: .editingChanged - ) - - let passwordTextField = UITextField() - passwordTextField.placeholder = "**********" - passwordTextField.borderStyle = .roundedRect - passwordTextField.addTarget( - self, action: #selector(passwordTextFieldChanged(sender:)), for: .editingChanged - ) - passwordTextField.isSecureTextEntry = true - - let loginButton = UIButton(type: .system) - loginButton.setTitle("Login", for: .normal) - loginButton.addTarget(self, action: #selector(loginButtonTapped(sender:)), for: .touchUpInside) - - let activityIndicator = UIActivityIndicatorView(style: .large) - activityIndicator.startAnimating() - - let rootStackView = UIStackView(arrangedSubviews: [ - disclaimerLabel, - divider, - titleLabel, - emailTextField, - passwordTextField, - loginButton, - activityIndicator, - ]) - rootStackView.isLayoutMarginsRelativeArrangement = true - rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) - rootStackView.translatesAutoresizingMaskIntoConstraints = false - rootStackView.axis = .vertical - rootStackView.spacing = 24 - - view.addSubview(rootStackView) - - NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - divider.heightAnchor.constraint(equalToConstant: 1), - ]) - - var alertController: UIAlertController? - var twoFactorController: TwoFactorViewController? - - observe { [weak self] in - guard let self else { return } - emailTextField.text = store.email - emailTextField.isEnabled = store.isEmailTextFieldEnabled - passwordTextField.text = store.password - passwordTextField.isEnabled = store.isPasswordTextFieldEnabled - loginButton.isEnabled = store.isLoginButtonEnabled - activityIndicator.isHidden = store.isActivityIndicatorHidden - - if let store = store.scope(state: \.alert, action: \.alert), - alertController == nil - { - alertController = UIAlertController(store: store) - present(alertController!, animated: true, completion: nil) - } else if store.alert == nil, alertController != nil { - alertController?.dismiss(animated: true) - alertController = nil - } - - if let store = store.scope(state: \.twoFactor, action: \.twoFactor.presented), - twoFactorController == nil - { - twoFactorController = TwoFactorViewController(store: store) - navigationController?.pushViewController( - twoFactorController!, - animated: true - ) - } else if store.twoFactor == nil, twoFactorController != nil { - navigationController?.popToViewController(self, animated: true) - twoFactorController = nil - } - } - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - if !isMovingToParent { - store.twoFactorDismissed() - } - } - - @objc private func loginButtonTapped(sender: UIButton) { - send(.loginButtonTapped) - } - - @objc private func emailTextFieldChanged(sender: UITextField) { - store.email = sender.text ?? "" - } - - @objc private func passwordTextFieldChanged(sender: UITextField) { - store.password = sender.text ?? "" - } + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = "Login" + view.backgroundColor = .systemBackground + + let disclaimerLabel = UILabel() + disclaimerLabel.text = """ + To login use any email and "password" for the password. If your email contains the \ + characters "2fa" you will be taken to a two-factor flow, and on that screen you can use \ + "1234" for the code. + """ + disclaimerLabel.textAlignment = .left + disclaimerLabel.numberOfLines = 0 + + let divider = UIView() + divider.backgroundColor = .gray + + let titleLabel = UILabel() + titleLabel.text = "Please log in to play TicTacToe!" + titleLabel.font = UIFont.preferredFont(forTextStyle: .title2) + titleLabel.numberOfLines = 0 + + let emailTextField = UITextField() + emailTextField.placeholder = "email@address.com" + emailTextField.borderStyle = .roundedRect + emailTextField.autocapitalizationType = .none + emailTextField.addTarget( + self, action: #selector(emailTextFieldChanged(sender:)), for: .editingChanged + ) + + let passwordTextField = UITextField() + passwordTextField.placeholder = "**********" + passwordTextField.borderStyle = .roundedRect + passwordTextField.addTarget( + self, action: #selector(passwordTextFieldChanged(sender:)), for: .editingChanged + ) + passwordTextField.isSecureTextEntry = true + + let loginButton = UIButton(type: .system) + loginButton.setTitle("Login", for: .normal) + loginButton.addTarget(self, action: #selector(loginButtonTapped(sender:)), for: .touchUpInside) + + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.startAnimating() + + let rootStackView = UIStackView(arrangedSubviews: [ + disclaimerLabel, + divider, + titleLabel, + emailTextField, + passwordTextField, + loginButton, + activityIndicator, + ]) + rootStackView.isLayoutMarginsRelativeArrangement = true + rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) + rootStackView.translatesAutoresizingMaskIntoConstraints = false + rootStackView.axis = .vertical + rootStackView.spacing = 24 + + view.addSubview(rootStackView) + + NSLayoutConstraint.activate([ + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + divider.heightAnchor.constraint(equalToConstant: 1), + ]) + + var alertController: UIAlertController? + var twoFactorController: TwoFactorViewController? + + observe { [weak self] in + guard let self else { return } + emailTextField.text = store.email + emailTextField.isEnabled = store.isEmailTextFieldEnabled + passwordTextField.text = store.password + passwordTextField.isEnabled = store.isPasswordTextFieldEnabled + loginButton.isEnabled = store.isLoginButtonEnabled + activityIndicator.isHidden = store.isActivityIndicatorHidden + + if let store = store.scope(state: \.alert, action: \.alert), + alertController == nil + { + alertController = UIAlertController(store: store) + present(alertController!, animated: true, completion: nil) + } else if store.alert == nil, alertController != nil { + alertController?.dismiss(animated: true) + alertController = nil + } + + if let store = store.scope(state: \.twoFactor, action: \.twoFactor.presented), + twoFactorController == nil + { + twoFactorController = TwoFactorViewController(store: store) + navigationController?.pushViewController( + twoFactorController!, + animated: true + ) + } else if store.twoFactor == nil, twoFactorController != nil { + navigationController?.popToViewController(self, animated: true) + twoFactorController = nil + } + } + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !isMovingToParent { + store.twoFactorDismissed() + } + } + + @objc private func loginButtonTapped(sender: UIButton) { + send(.loginButtonTapped) + } + + @objc private func emailTextFieldChanged(sender: UITextField) { + store.email = sender.text ?? "" + } + + @objc private func passwordTextFieldChanged(sender: UITextField) { + store.password = sender.text ?? "" + } } -extension Login.State { - fileprivate var isActivityIndicatorHidden: Bool { !isLoginRequestInFlight } - fileprivate var isEmailTextFieldEnabled: Bool { !isLoginRequestInFlight } - fileprivate var isLoginButtonEnabled: Bool { isFormValid && !isLoginRequestInFlight } - fileprivate var isPasswordTextFieldEnabled: Bool { !isLoginRequestInFlight } +private extension Login.State { + var isActivityIndicatorHidden: Bool { !isLoginRequestInFlight } + var isEmailTextFieldEnabled: Bool { !isLoginRequestInFlight } + var isLoginButtonEnabled: Bool { isFormValid && !isLoginRequestInFlight } + var isPasswordTextFieldEnabled: Bool { !isLoginRequestInFlight } } -extension StoreOf { - fileprivate func twoFactorDismissed() { - send(.twoFactor(.dismiss)) - } +private extension StoreOf { + func twoFactorDismissed() { + send(.twoFactor(.dismiss)) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift index a82b5a3..1c49c1d 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameCore/NewGameCore.swift @@ -3,47 +3,47 @@ import GameCore @Reducer public struct NewGame { - @ObservableState - public struct State: Equatable { - @Presents public var game: Game.State? - public var oPlayerName = "" - public var xPlayerName = "" - - public init() {} - } - - public enum Action: BindableAction { - case binding(BindingAction) - case game(PresentationAction) - case letsPlayButtonTapped - case logoutButtonTapped - } - - public init() {} - - public var body: some Reducer { - BindingReducer() - Reduce { state, action in - switch action { - case .binding: - return .none - - case .game: - return .none - - case .letsPlayButtonTapped: - state.game = Game.State( - oPlayerName: state.oPlayerName, - xPlayerName: state.xPlayerName - ) - return .none - - case .logoutButtonTapped: - return .none - } - } - .ifLet(\.$game, action: \.game) { - Game() - } - } + @ObservableState + public struct State: Equatable { + @Presents public var game: Game.State? + public var oPlayerName = "" + public var xPlayerName = "" + + public init() {} + } + + public enum Action: BindableAction { + case binding(BindingAction) + case game(PresentationAction) + case letsPlayButtonTapped + case logoutButtonTapped + } + + public init() {} + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .game: + return .none + + case .letsPlayButtonTapped: + state.game = Game.State( + oPlayerName: state.oPlayerName, + xPlayerName: state.xPlayerName + ) + return .none + + case .logoutButtonTapped: + return .none + } + } + .ifLet(\.$game, action: \.game) { + Game() + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift index c679a95..d970d1a 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameSwiftUI/NewGameView.swift @@ -5,57 +5,57 @@ import NewGameCore import SwiftUI public struct NewGameView: View { - @Bindable var store: StoreOf + @Bindable var store: StoreOf - public init(store: StoreOf) { - self.store = store - } + public init(store: StoreOf) { + self.store = store + } - public var body: some View { - Form { - Section { - TextField("Blob Sr.", text: $store.xPlayerName) - .autocapitalization(.words) - .disableAutocorrection(true) - .textContentType(.name) - } header: { - Text("X Player Name") - } + public var body: some View { + Form { + Section { + TextField("Blob Sr.", text: $store.xPlayerName) + .autocapitalization(.words) + .disableAutocorrection(true) + .textContentType(.name) + } header: { + Text("X Player Name") + } - Section { - TextField("Blob Jr.", text: $store.oPlayerName) - .autocapitalization(.words) - .disableAutocorrection(true) - .textContentType(.name) - } header: { - Text("O Player Name") - } + Section { + TextField("Blob Jr.", text: $store.oPlayerName) + .autocapitalization(.words) + .disableAutocorrection(true) + .textContentType(.name) + } header: { + Text("O Player Name") + } - Button("Let's play!") { - store.send(.letsPlayButtonTapped) - } - .disabled(store.isLetsPlayButtonDisabled) - } - .navigationTitle("New Game") - .navigationBarItems(trailing: Button("Logout") { store.send(.logoutButtonTapped) }) - .navigationDestination(item: $store.scope(state: \.game, action: \.game)) { store in - GameView(store: store) - } - } + Button("Let's play!") { + store.send(.letsPlayButtonTapped) + } + .disabled(store.isLetsPlayButtonDisabled) + } + .navigationTitle("New Game") + .navigationBarItems(trailing: Button("Logout") { store.send(.logoutButtonTapped) }) + .navigationDestination(item: $store.scope(state: \.game, action: \.game)) { store in + GameView(store: store) + } + } } -extension NewGame.State { - fileprivate var isLetsPlayButtonDisabled: Bool { - self.oPlayerName.isEmpty || self.xPlayerName.isEmpty - } +private extension NewGame.State { + var isLetsPlayButtonDisabled: Bool { + oPlayerName.isEmpty || xPlayerName.isEmpty + } } #Preview { - NavigationStack { - NewGameView( - store: Store(initialState: NewGame.State()) { - NewGame() - } - ) - } + NavigationStack { + NewGameView( + store: Store(initialState: NewGame.State()) { + NewGame() + } + ) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift index e5aeda1..183b40c 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/NewGameUIKit/NewGameViewController.swift @@ -4,125 +4,128 @@ import NewGameCore import UIKit public class NewGameViewController: UIViewController { - let store: StoreOf - - public init(store: StoreOf) { - self.store = store - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - - navigationItem.title = "New Game" - - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "Logout", - style: .done, - target: self, - action: #selector(logoutButtonTapped) - ) - - let playerXLabel = UILabel() - playerXLabel.text = "Player X" - playerXLabel.setContentHuggingPriority(.required, for: .horizontal) - - let playerXTextField = UITextField() - playerXTextField.borderStyle = .roundedRect - playerXTextField.placeholder = "Blob Sr." - playerXTextField.setContentCompressionResistancePriority(.required, for: .horizontal) - playerXTextField.addTarget( - self, action: #selector(playerXTextChanged(sender:)), for: .editingChanged) - - let playerXRow = UIStackView(arrangedSubviews: [ - playerXLabel, - playerXTextField, - ]) - playerXRow.spacing = 24 - - let playerOLabel = UILabel() - playerOLabel.text = "Player O" - playerOLabel.setContentHuggingPriority(.required, for: .horizontal) - - let playerOTextField = UITextField() - playerOTextField.borderStyle = .roundedRect - playerOTextField.placeholder = "Blob Jr." - playerOTextField.setContentCompressionResistancePriority(.required, for: .horizontal) - playerOTextField.addTarget( - self, action: #selector(playerOTextChanged(sender:)), for: .editingChanged) - - let playerORow = UIStackView(arrangedSubviews: [ - playerOLabel, - playerOTextField, - ]) - playerORow.spacing = 24 - - let letsPlayButton = UIButton(type: .system) - letsPlayButton.setTitle("Let’s Play!", for: .normal) - letsPlayButton.addTarget(self, action: #selector(letsPlayTapped), for: .touchUpInside) - - let rootStackView = UIStackView(arrangedSubviews: [ - playerXRow, - playerORow, - letsPlayButton, - ]) - rootStackView.isLayoutMarginsRelativeArrangement = true - rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) - rootStackView.translatesAutoresizingMaskIntoConstraints = false - rootStackView.axis = .vertical - rootStackView.spacing = 24 - - view.addSubview(rootStackView) - - NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - var gameController: GameViewController? - - observe { [weak self] in - guard let self else { return } - playerOTextField.text = store.oPlayerName - playerXTextField.text = store.xPlayerName - letsPlayButton.isEnabled = store.isLetsPlayButtonEnabled - - if let store = store.scope(state: \.game, action: \.game.presented), - gameController == nil - { - gameController = GameViewController(store: store) - navigationController?.pushViewController(gameController!, animated: true) - } else if store.game == nil, gameController != nil { - navigationController?.popToViewController(self, animated: true) - gameController = nil - } - } - } - - @objc private func logoutButtonTapped() { - store.send(.logoutButtonTapped) - } - - @objc private func playerXTextChanged(sender: UITextField) { - store.xPlayerName = sender.text ?? "" - } - - @objc private func playerOTextChanged(sender: UITextField) { - store.oPlayerName = sender.text ?? "" - } - - @objc private func letsPlayTapped() { - store.send(.letsPlayButtonTapped) - } + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = "New Game" + + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Logout", + style: .done, + target: self, + action: #selector(logoutButtonTapped) + ) + + let playerXLabel = UILabel() + playerXLabel.text = "Player X" + playerXLabel.setContentHuggingPriority(.required, for: .horizontal) + + let playerXTextField = UITextField() + playerXTextField.borderStyle = .roundedRect + playerXTextField.placeholder = "Blob Sr." + playerXTextField.setContentCompressionResistancePriority(.required, for: .horizontal) + playerXTextField.addTarget( + self, action: #selector(playerXTextChanged(sender:)), for: .editingChanged + ) + + let playerXRow = UIStackView(arrangedSubviews: [ + playerXLabel, + playerXTextField, + ]) + playerXRow.spacing = 24 + + let playerOLabel = UILabel() + playerOLabel.text = "Player O" + playerOLabel.setContentHuggingPriority(.required, for: .horizontal) + + let playerOTextField = UITextField() + playerOTextField.borderStyle = .roundedRect + playerOTextField.placeholder = "Blob Jr." + playerOTextField.setContentCompressionResistancePriority(.required, for: .horizontal) + playerOTextField.addTarget( + self, action: #selector(playerOTextChanged(sender:)), for: .editingChanged + ) + + let playerORow = UIStackView(arrangedSubviews: [ + playerOLabel, + playerOTextField, + ]) + playerORow.spacing = 24 + + let letsPlayButton = UIButton(type: .system) + letsPlayButton.setTitle("Let’s Play!", for: .normal) + letsPlayButton.addTarget(self, action: #selector(letsPlayTapped), for: .touchUpInside) + + let rootStackView = UIStackView(arrangedSubviews: [ + playerXRow, + playerORow, + letsPlayButton, + ]) + rootStackView.isLayoutMarginsRelativeArrangement = true + rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) + rootStackView.translatesAutoresizingMaskIntoConstraints = false + rootStackView.axis = .vertical + rootStackView.spacing = 24 + + view.addSubview(rootStackView) + + NSLayoutConstraint.activate([ + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + var gameController: GameViewController? + + observe { [weak self] in + guard let self else { return } + playerOTextField.text = store.oPlayerName + playerXTextField.text = store.xPlayerName + letsPlayButton.isEnabled = store.isLetsPlayButtonEnabled + + if let store = store.scope(state: \.game, action: \.game.presented), + gameController == nil + { + gameController = GameViewController(store: store) + navigationController?.pushViewController(gameController!, animated: true) + } else if store.game == nil, gameController != nil { + navigationController?.popToViewController(self, animated: true) + gameController = nil + } + } + } + + @objc private func logoutButtonTapped() { + store.send(.logoutButtonTapped) + } + + @objc private func playerXTextChanged(sender: UITextField) { + store.xPlayerName = sender.text ?? "" + } + + @objc private func playerOTextChanged(sender: UITextField) { + store.oPlayerName = sender.text ?? "" + } + + @objc private func letsPlayTapped() { + store.send(.letsPlayButtonTapped) + } } -extension NewGame.State { - fileprivate var isLetsPlayButtonEnabled: Bool { - !oPlayerName.isEmpty && !xPlayerName.isEmpty - } +private extension NewGame.State { + var isLetsPlayButtonEnabled: Bool { + !oPlayerName.isEmpty && !xPlayerName.isEmpty + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift index bc2586d..72001ea 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorCore/TwoFactorCore.swift @@ -5,70 +5,70 @@ import Dispatch @Reducer public struct TwoFactor: Sendable { - @ObservableState - public struct State: Equatable { - @Presents public var alert: AlertState? - public var code = "" - public var isFormValid = false - public var isTwoFactorRequestInFlight = false - public let token: String + @ObservableState + public struct State: Equatable { + @Presents public var alert: AlertState? + public var code = "" + public var isFormValid = false + public var isTwoFactorRequestInFlight = false + public let token: String - public init(token: String) { - self.token = token - } - } + public init(token: String) { + self.token = token + } + } - public enum Action: Sendable, ViewAction { - case alert(PresentationAction) - case twoFactorResponse(Result) - case view(View) + public enum Action: Sendable, ViewAction { + case alert(PresentationAction) + case twoFactorResponse(Result) + case view(View) - public enum Alert: Equatable, Sendable {} + public enum Alert: Equatable, Sendable {} - @CasePathable - public enum View: BindableAction, Sendable { - case binding(BindingAction) - case submitButtonTapped - } - } + @CasePathable + public enum View: BindableAction, Sendable { + case binding(BindingAction) + case submitButtonTapped + } + } - @Dependency(\.authenticationClient) var authenticationClient + @Dependency(\.authenticationClient) var authenticationClient - public init() {} + public init() {} - public var body: some ReducerOf { - BindingReducer(action: \.view) - Reduce { state, action in - switch action { - case .alert: - return .none + public var body: some ReducerOf { + BindingReducer(action: \.view) + Reduce { state, action in + switch action { + case .alert: + return .none - case let .twoFactorResponse(.failure(error)): - state.alert = AlertState { TextState(error.localizedDescription) } - state.isTwoFactorRequestInFlight = false - return .none + case let .twoFactorResponse(.failure(error)): + state.alert = AlertState { TextState(error.localizedDescription) } + state.isTwoFactorRequestInFlight = false + return .none - case .twoFactorResponse(.success): - state.isTwoFactorRequestInFlight = false - return .none + case .twoFactorResponse(.success): + state.isTwoFactorRequestInFlight = false + return .none - case .view(.binding): - state.isFormValid = state.code.count >= 4 - return .none + case .view(.binding): + state.isFormValid = state.code.count >= 4 + return .none - case .view(.submitButtonTapped): - state.isTwoFactorRequestInFlight = true - return .run { [code = state.code, token = state.token] send in - await send( - .twoFactorResponse( - await Result { - try await self.authenticationClient.twoFactor(code: code, token: token) - } - ) - ) - } - } - } - .ifLet(\.$alert, action: \.alert) - } + case .view(.submitButtonTapped): + state.isTwoFactorRequestInFlight = true + return .run { [code = state.code, token = state.token] send in + await send( + .twoFactorResponse( + Result { + try await authenticationClient.twoFactor(code: code, token: token) + } + ) + ) + } + } + } + .ifLet(\.$alert, action: \.alert) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift index fff03b3..6f837ac 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorSwiftUI/TwoFactorView.swift @@ -5,65 +5,65 @@ import TwoFactorCore @ViewAction(for: TwoFactor.self) public struct TwoFactorView: View { - @Bindable public var store: StoreOf + @Bindable public var store: StoreOf - public init(store: StoreOf) { - self.store = store - } + public init(store: StoreOf) { + self.store = store + } - public var body: some View { - Form { - Text(#"To confirm the second factor enter "1234" into the form."#) + public var body: some View { + Form { + Text(#"To confirm the second factor enter "1234" into the form."#) - Section { - TextField("1234", text: $store.code) - .keyboardType(.numberPad) - } + Section { + TextField("1234", text: $store.code) + .keyboardType(.numberPad) + } - HStack { - Button("Submit") { - // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" - // if you disable a text field while it is focused. This hack will force all - // fields to unfocus before we send the action to the store. - // CF: https://stackoverflow.com/a/69653555 - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil - ) - send(.submitButtonTapped) - } - .disabled(store.isSubmitButtonDisabled) + HStack { + Button("Submit") { + // NB: SwiftUI will print errors to the console about "AttributeGraph: cycle detected" + // if you disable a text field while it is focused. This hack will force all + // fields to unfocus before we send the action to the store. + // CF: https://stackoverflow.com/a/69653555 + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + send(.submitButtonTapped) + } + .disabled(store.isSubmitButtonDisabled) - if store.isActivityIndicatorVisible { - Spacer() - ProgressView() - } - } - } - .alert($store.scope(state: \.alert, action: \.alert)) - .disabled(store.isFormDisabled) - .navigationTitle("Confirmation Code") - } + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + .alert($store.scope(state: \.alert, action: \.alert)) + .disabled(store.isFormDisabled) + .navigationTitle("Confirmation Code") + } } -extension TwoFactor.State { - fileprivate var isActivityIndicatorVisible: Bool { self.isTwoFactorRequestInFlight } - fileprivate var isFormDisabled: Bool { self.isTwoFactorRequestInFlight } - fileprivate var isSubmitButtonDisabled: Bool { !self.isFormValid } +private extension TwoFactor.State { + var isActivityIndicatorVisible: Bool { isTwoFactorRequestInFlight } + var isFormDisabled: Bool { isTwoFactorRequestInFlight } + var isSubmitButtonDisabled: Bool { !isFormValid } } #Preview { - NavigationStack { - TwoFactorView( - store: Store(initialState: TwoFactor.State(token: "deadbeef")) { - TwoFactor() - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - $0.authenticationClient.twoFactor = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - } - ) - } + NavigationStack { + TwoFactorView( + store: Store(initialState: TwoFactor.State(token: "deadbeef")) { + TwoFactor() + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + $0.authenticationClient.twoFactor = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } + ) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift index a84137b..be84f9f 100644 --- a/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift +++ b/Examples/TicTacToe/tic-tac-toe/Sources/TwoFactorUIKit/TwoFactorViewController.swift @@ -4,89 +4,91 @@ import UIKit @ViewAction(for: TwoFactor.self) public final class TwoFactorViewController: UIViewController { - public let store: StoreOf - - public init(store: StoreOf) { - self.store = store - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - - let titleLabel = UILabel() - titleLabel.text = "Enter the one time code to continue" - titleLabel.textAlignment = .center - - let codeTextField = UITextField() - codeTextField.placeholder = "1234" - codeTextField.borderStyle = .roundedRect - codeTextField.addTarget( - self, action: #selector(codeTextFieldChanged(sender:)), for: .editingChanged) - - let loginButton = UIButton(type: .system) - loginButton.setTitle("Login", for: .normal) - loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) - - let activityIndicator = UIActivityIndicatorView(style: .large) - activityIndicator.startAnimating() - - let rootStackView = UIStackView(arrangedSubviews: [ - titleLabel, - codeTextField, - loginButton, - activityIndicator, - ]) - rootStackView.isLayoutMarginsRelativeArrangement = true - rootStackView.layoutMargins = .init(top: 0, left: 32, bottom: 0, right: 32) - rootStackView.translatesAutoresizingMaskIntoConstraints = false - rootStackView.axis = .vertical - rootStackView.spacing = 24 - - view.addSubview(rootStackView) - - NSLayoutConstraint.activate([ - rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - var alertController: UIAlertController? - - observe { [weak self] in - guard let self else { return } - activityIndicator.isHidden = store.isActivityIndicatorHidden - codeTextField.text = store.code - loginButton.isEnabled = store.isLoginButtonEnabled - - if let store = store.scope(state: \.alert, action: \.alert), - alertController == nil - { - alertController = UIAlertController(store: store) - present(alertController!, animated: true, completion: nil) - } else if store.alert == nil, alertController != nil { - alertController?.dismiss(animated: true) - alertController = nil - } - } - } - - @objc private func codeTextFieldChanged(sender: UITextField) { - store.code = sender.text ?? "" - } - - @objc private func loginButtonTapped() { - send(.submitButtonTapped) - } + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + let titleLabel = UILabel() + titleLabel.text = "Enter the one time code to continue" + titleLabel.textAlignment = .center + + let codeTextField = UITextField() + codeTextField.placeholder = "1234" + codeTextField.borderStyle = .roundedRect + codeTextField.addTarget( + self, action: #selector(codeTextFieldChanged(sender:)), for: .editingChanged + ) + + let loginButton = UIButton(type: .system) + loginButton.setTitle("Login", for: .normal) + loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) + + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.startAnimating() + + let rootStackView = UIStackView(arrangedSubviews: [ + titleLabel, + codeTextField, + loginButton, + activityIndicator, + ]) + rootStackView.isLayoutMarginsRelativeArrangement = true + rootStackView.layoutMargins = .init(top: 0, left: 32, bottom: 0, right: 32) + rootStackView.translatesAutoresizingMaskIntoConstraints = false + rootStackView.axis = .vertical + rootStackView.spacing = 24 + + view.addSubview(rootStackView) + + NSLayoutConstraint.activate([ + rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + var alertController: UIAlertController? + + observe { [weak self] in + guard let self else { return } + activityIndicator.isHidden = store.isActivityIndicatorHidden + codeTextField.text = store.code + loginButton.isEnabled = store.isLoginButtonEnabled + + if let store = store.scope(state: \.alert, action: \.alert), + alertController == nil + { + alertController = UIAlertController(store: store) + present(alertController!, animated: true, completion: nil) + } else if store.alert == nil, alertController != nil { + alertController?.dismiss(animated: true) + alertController = nil + } + } + } + + @objc private func codeTextFieldChanged(sender: UITextField) { + store.code = sender.text ?? "" + } + + @objc private func loginButtonTapped() { + send(.submitButtonTapped) + } } -extension TwoFactor.State { - fileprivate var isActivityIndicatorHidden: Bool { !isTwoFactorRequestInFlight } - fileprivate var isLoginButtonEnabled: Bool { isFormValid && !isTwoFactorRequestInFlight } +private extension TwoFactor.State { + var isActivityIndicatorHidden: Bool { !isTwoFactorRequestInFlight } + var isLoginButtonEnabled: Bool { isFormValid && !isTwoFactorRequestInFlight } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift index 3b23327..5b53c22 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/AppCoreTests/AppCoreTests.swift @@ -7,77 +7,77 @@ import TwoFactorCore import XCTest final class AppCoreTests: XCTestCase { - @MainActor - func testIntegration() async { - let store = TestStore(initialState: TicTacToe.State.login(Login.State())) { - TicTacToe.body - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - } + @MainActor + func testIntegration() async { + let store = TestStore(initialState: TicTacToe.State.login(Login.State())) { + TicTacToe.body + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } - await store.send(\.login.view.binding.email, "blob@pointfree.co") { - $0.login?.email = "blob@pointfree.co" - } - await store.send(\.login.view.binding.password, "bl0bbl0b") { - $0.login?.password = "bl0bbl0b" - $0.login?.isFormValid = true - } - await store.send(\.login.view.loginButtonTapped) { - $0.login?.isLoginRequestInFlight = true - } - await store.receive(\.login.loginResponse.success) { - $0 = .newGame(NewGame.State()) - } - await store.send(\.newGame.binding.oPlayerName, "Blob Sr.") { - $0.newGame?.oPlayerName = "Blob Sr." - } - await store.send(\.newGame.logoutButtonTapped) { - $0 = .login(Login.State()) - } - } + await store.send(\.login.view.binding.email, "blob@pointfree.co") { + $0.login?.email = "blob@pointfree.co" + } + await store.send(\.login.view.binding.password, "bl0bbl0b") { + $0.login?.password = "bl0bbl0b" + $0.login?.isFormValid = true + } + await store.send(\.login.view.loginButtonTapped) { + $0.login?.isLoginRequestInFlight = true + } + await store.receive(\.login.loginResponse.success) { + $0 = .newGame(NewGame.State()) + } + await store.send(\.newGame.binding.oPlayerName, "Blob Sr.") { + $0.newGame?.oPlayerName = "Blob Sr." + } + await store.send(\.newGame.logoutButtonTapped) { + $0 = .login(Login.State()) + } + } - @MainActor - func testIntegration_TwoFactor() async { - let store = TestStore(initialState: TicTacToe.State.login(Login.State())) { - TicTacToe.body - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: true) - } - $0.authenticationClient.twoFactor = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) - } - } + @MainActor + func testIntegration_TwoFactor() async { + let store = TestStore(initialState: TicTacToe.State.login(Login.State())) { + TicTacToe.body + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeef", twoFactorRequired: false) + } + } - await store.send(\.login.view.binding.email, "blob@pointfree.co") { - $0.login?.email = "blob@pointfree.co" - } + await store.send(\.login.view.binding.email, "blob@pointfree.co") { + $0.login?.email = "blob@pointfree.co" + } - await store.send(\.login.view.binding.password, "bl0bbl0b") { - $0.login?.password = "bl0bbl0b" - $0.login?.isFormValid = true - } + await store.send(\.login.view.binding.password, "bl0bbl0b") { + $0.login?.password = "bl0bbl0b" + $0.login?.isFormValid = true + } - await store.send(\.login.view.loginButtonTapped) { - $0.login?.isLoginRequestInFlight = true - } - await store.receive(\.login.loginResponse.success) { - $0.login?.isLoginRequestInFlight = false - $0.login?.twoFactor = TwoFactor.State(token: "deadbeef") - } + await store.send(\.login.view.loginButtonTapped) { + $0.login?.isLoginRequestInFlight = true + } + await store.receive(\.login.loginResponse.success) { + $0.login?.isLoginRequestInFlight = false + $0.login?.twoFactor = TwoFactor.State(token: "deadbeef") + } - await store.send(\.login.twoFactor.view.binding.code, "1234") { - $0.login?.twoFactor?.code = "1234" - $0.login?.twoFactor?.isFormValid = true - } + await store.send(\.login.twoFactor.view.binding.code, "1234") { + $0.login?.twoFactor?.code = "1234" + $0.login?.twoFactor?.isFormValid = true + } - await store.send(\.login.twoFactor.view.submitButtonTapped) { - $0.login?.twoFactor?.isTwoFactorRequestInFlight = true - } - await store.receive(\.login.twoFactor.twoFactorResponse.success) { - $0 = .newGame(NewGame.State()) - } - } + await store.send(\.login.twoFactor.view.submitButtonTapped) { + $0.login?.twoFactor?.isTwoFactorRequestInFlight = true + } + await store.receive(\.login.twoFactor.twoFactorResponse.success) { + $0 = .newGame(NewGame.State()) + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/GameCoreTests/GameCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/GameCoreTests/GameCoreTests.swift index aebc359..313b3a6 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/GameCoreTests/GameCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/GameCoreTests/GameCoreTests.swift @@ -3,81 +3,81 @@ import GameCore import XCTest final class GameCoreTests: XCTestCase { - @MainActor - func testFlow_Winner_Quit() async { - let store = TestStore( - initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") - ) { - Game() - } + @MainActor + func testFlow_Winner_Quit() async { + let store = TestStore( + initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") + ) { + Game() + } - await store.send(.cellTapped(row: 0, column: 0)) { - $0.board[0][0] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 2, column: 1)) { - $0.board[2][1] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 1, column: 0)) { - $0.board[1][0] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 1, column: 1)) { - $0.board[1][1] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 2, column: 0)) { - $0.board[2][0] = .x - } - } + await store.send(.cellTapped(row: 0, column: 0)) { + $0.board[0][0] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 2, column: 1)) { + $0.board[2][1] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 1, column: 0)) { + $0.board[1][0] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 1, column: 1)) { + $0.board[1][1] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 2, column: 0)) { + $0.board[2][0] = .x + } + } - @MainActor - func testFlow_Tie() async { - let store = TestStore( - initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") - ) { - Game() - } + @MainActor + func testFlow_Tie() async { + let store = TestStore( + initialState: Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") + ) { + Game() + } - await store.send(.cellTapped(row: 0, column: 0)) { - $0.board[0][0] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 2, column: 2)) { - $0.board[2][2] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 1, column: 0)) { - $0.board[1][0] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 2, column: 0)) { - $0.board[2][0] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 2, column: 1)) { - $0.board[2][1] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 1, column: 2)) { - $0.board[1][2] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 0, column: 2)) { - $0.board[0][2] = .x - $0.currentPlayer = .o - } - await store.send(.cellTapped(row: 0, column: 1)) { - $0.board[0][1] = .o - $0.currentPlayer = .x - } - await store.send(.cellTapped(row: 1, column: 1)) { - $0.board[1][1] = .x - $0.currentPlayer = .o - } - await store.send(.playAgainButtonTapped) { - $0 = Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") - } - } + await store.send(.cellTapped(row: 0, column: 0)) { + $0.board[0][0] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 2, column: 2)) { + $0.board[2][2] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 1, column: 0)) { + $0.board[1][0] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 2, column: 0)) { + $0.board[2][0] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 2, column: 1)) { + $0.board[2][1] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 1, column: 2)) { + $0.board[1][2] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 0, column: 2)) { + $0.board[0][2] = .x + $0.currentPlayer = .o + } + await store.send(.cellTapped(row: 0, column: 1)) { + $0.board[0][1] = .o + $0.currentPlayer = .x + } + await store.send(.cellTapped(row: 1, column: 1)) { + $0.board[1][1] = .x + $0.currentPlayer = .o + } + await store.send(.playAgainButtonTapped) { + $0 = Game.State(oPlayerName: "Blob Jr.", xPlayerName: "Blob Sr.") + } + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift index 3487c7d..03f1ec3 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/LoginCoreTests/LoginCoreTests.swift @@ -5,84 +5,84 @@ import TwoFactorCore import XCTest final class LoginCoreTests: XCTestCase { - @MainActor - func testFlow_Success_TwoFactor_Integration() async { - let store = TestStore(initialState: Login.State()) { - Login() - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) - } - $0.authenticationClient.twoFactor = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) - } - } + @MainActor + func testFlow_Success_TwoFactor_Integration() async { + let store = TestStore(initialState: Login.State()) { + Login() + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } + } - await store.send(\.view.binding.email, "2fa@pointfree.co") { - $0.email = "2fa@pointfree.co" - } - await store.send(\.view.binding.password, "password") { - $0.password = "password" - $0.isFormValid = true - } - let twoFactorPresentationTask = await store.send(\.view.loginButtonTapped) { - $0.isLoginRequestInFlight = true - } - await store.receive(\.loginResponse.success) { - $0.isLoginRequestInFlight = false - $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") - } - await store.send(\.twoFactor.view.binding.code, "1234") { - $0.twoFactor?.code = "1234" - $0.twoFactor?.isFormValid = true - } - await store.send(\.twoFactor.view.submitButtonTapped) { - $0.twoFactor?.isTwoFactorRequestInFlight = true - } - await store.receive(\.twoFactor.twoFactorResponse.success) { - $0.twoFactor?.isTwoFactorRequestInFlight = false - } - await twoFactorPresentationTask.cancel() - } + await store.send(\.view.binding.email, "2fa@pointfree.co") { + $0.email = "2fa@pointfree.co" + } + await store.send(\.view.binding.password, "password") { + $0.password = "password" + $0.isFormValid = true + } + let twoFactorPresentationTask = await store.send(\.view.loginButtonTapped) { + $0.isLoginRequestInFlight = true + } + await store.receive(\.loginResponse.success) { + $0.isLoginRequestInFlight = false + $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") + } + await store.send(\.twoFactor.view.binding.code, "1234") { + $0.twoFactor?.code = "1234" + $0.twoFactor?.isFormValid = true + } + await store.send(\.twoFactor.view.submitButtonTapped) { + $0.twoFactor?.isTwoFactorRequestInFlight = true + } + await store.receive(\.twoFactor.twoFactorResponse.success) { + $0.twoFactor?.isTwoFactorRequestInFlight = false + } + await twoFactorPresentationTask.cancel() + } - @MainActor - func testFlow_DismissEarly_TwoFactor_Integration() async { - let store = TestStore(initialState: Login.State()) { - Login() - } withDependencies: { - $0.authenticationClient.login = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) - } - $0.authenticationClient.twoFactor = { @Sendable _, _ in - try await Task.sleep(for: .seconds(1)) - return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) - } - } + @MainActor + func testFlow_DismissEarly_TwoFactor_Integration() async { + let store = TestStore(initialState: Login.State()) { + Login() + } withDependencies: { + $0.authenticationClient.login = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true) + } + $0.authenticationClient.twoFactor = { @Sendable _, _ in + try await Task.sleep(for: .seconds(1)) + return AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } + } - await store.send(\.view.binding.email, "2fa@pointfree.co") { - $0.email = "2fa@pointfree.co" - } - await store.send(\.view.binding.password, "password") { - $0.password = "password" - $0.isFormValid = true - } - await store.send(\.view.loginButtonTapped) { - $0.isLoginRequestInFlight = true - } - await store.receive(\.loginResponse.success) { - $0.isLoginRequestInFlight = false - $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") - } - await store.send(\.twoFactor.view.binding.code, "1234") { - $0.twoFactor?.code = "1234" - $0.twoFactor?.isFormValid = true - } - await store.send(\.twoFactor.view.submitButtonTapped) { - $0.twoFactor?.isTwoFactorRequestInFlight = true - } - await store.send(\.twoFactor.dismiss) { - $0.twoFactor = nil - } - await store.finish() - } + await store.send(\.view.binding.email, "2fa@pointfree.co") { + $0.email = "2fa@pointfree.co" + } + await store.send(\.view.binding.password, "password") { + $0.password = "password" + $0.isFormValid = true + } + await store.send(\.view.loginButtonTapped) { + $0.isLoginRequestInFlight = true + } + await store.receive(\.loginResponse.success) { + $0.isLoginRequestInFlight = false + $0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef") + } + await store.send(\.twoFactor.view.binding.code, "1234") { + $0.twoFactor?.code = "1234" + $0.twoFactor?.isFormValid = true + } + await store.send(\.twoFactor.view.submitButtonTapped) { + $0.twoFactor?.isTwoFactorRequestInFlight = true + } + await store.send(\.twoFactor.dismiss) { + $0.twoFactor = nil + } + await store.finish() + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift index f8eb4bf..624c059 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/NewGameCoreTests/NewGameCoreTests.swift @@ -4,34 +4,34 @@ import NewGameCore import XCTest final class NewGameCoreTests: XCTestCase { - @MainActor - func testFlow_NewGame_Integration() async { - let store = TestStore(initialState: NewGame.State()) { - NewGame() - } - await store.send(\.binding.oPlayerName, "Blob Sr.") { - $0.oPlayerName = "Blob Sr." - } - await store.send(\.binding.xPlayerName, "Blob Jr.") { - $0.xPlayerName = "Blob Jr." - } - await store.send(.letsPlayButtonTapped) { - $0.game = Game.State(oPlayerName: "Blob Sr.", xPlayerName: "Blob Jr.") - } - await store.send(\.game.cellTapped, (row: 0, column: 0)) { - $0.game!.board[0][0] = .x - $0.game!.currentPlayer = .o - } - await store.send(\.game.quitButtonTapped) - await store.receive(\.game.dismiss) { - $0.game = nil - } - await store.send(.letsPlayButtonTapped) { - $0.game = Game.State(oPlayerName: "Blob Sr.", xPlayerName: "Blob Jr.") - } - await store.send(\.game.dismiss) { - $0.game = nil - } - await store.send(.logoutButtonTapped) - } + @MainActor + func testFlow_NewGame_Integration() async { + let store = TestStore(initialState: NewGame.State()) { + NewGame() + } + await store.send(\.binding.oPlayerName, "Blob Sr.") { + $0.oPlayerName = "Blob Sr." + } + await store.send(\.binding.xPlayerName, "Blob Jr.") { + $0.xPlayerName = "Blob Jr." + } + await store.send(.letsPlayButtonTapped) { + $0.game = Game.State(oPlayerName: "Blob Sr.", xPlayerName: "Blob Jr.") + } + await store.send(\.game.cellTapped, (row: 0, column: 0)) { + $0.game!.board[0][0] = .x + $0.game!.currentPlayer = .o + } + await store.send(\.game.quitButtonTapped) + await store.receive(\.game.dismiss) { + $0.game = nil + } + await store.send(.letsPlayButtonTapped) { + $0.game = Game.State(oPlayerName: "Blob Sr.", xPlayerName: "Blob Jr.") + } + await store.send(\.game.dismiss) { + $0.game = nil + } + await store.send(.logoutButtonTapped) + } } diff --git a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift index cf83b51..5b03580 100644 --- a/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift +++ b/Examples/TicTacToe/tic-tac-toe/Tests/TwoFactorCoreTests/TwoFactorCoreTests.swift @@ -4,63 +4,63 @@ import TwoFactorCore import XCTest final class TwoFactorCoreTests: XCTestCase { - @MainActor - func testFlow_Success() async { - let store = TestStore(initialState: TwoFactor.State(token: "deadbeefdeadbeef")) { - TwoFactor() - } withDependencies: { - $0.authenticationClient.twoFactor = { @Sendable _, _ in - AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) - } - } + @MainActor + func testFlow_Success() async { + let store = TestStore(initialState: TwoFactor.State(token: "deadbeefdeadbeef")) { + TwoFactor() + } withDependencies: { + $0.authenticationClient.twoFactor = { @Sendable _, _ in + AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false) + } + } - await store.send(\.view.binding.code, "1") { - $0.code = "1" - } - await store.send(\.view.binding.code, "12") { - $0.code = "12" - } - await store.send(\.view.binding.code, "123") { - $0.code = "123" - } - await store.send(\.view.binding.code, "1234") { - $0.code = "1234" - $0.isFormValid = true - } - await store.send(\.view.submitButtonTapped) { - $0.isTwoFactorRequestInFlight = true - } - await store.receive(\.twoFactorResponse.success) { - $0.isTwoFactorRequestInFlight = false - } - } + await store.send(\.view.binding.code, "1") { + $0.code = "1" + } + await store.send(\.view.binding.code, "12") { + $0.code = "12" + } + await store.send(\.view.binding.code, "123") { + $0.code = "123" + } + await store.send(\.view.binding.code, "1234") { + $0.code = "1234" + $0.isFormValid = true + } + await store.send(\.view.submitButtonTapped) { + $0.isTwoFactorRequestInFlight = true + } + await store.receive(\.twoFactorResponse.success) { + $0.isTwoFactorRequestInFlight = false + } + } - @MainActor - func testFlow_Failure() async { - let store = TestStore(initialState: TwoFactor.State(token: "deadbeefdeadbeef")) { - TwoFactor() - } withDependencies: { - $0.authenticationClient.twoFactor = { @Sendable _, _ in - throw AuthenticationError.invalidTwoFactor - } - } + @MainActor + func testFlow_Failure() async { + let store = TestStore(initialState: TwoFactor.State(token: "deadbeefdeadbeef")) { + TwoFactor() + } withDependencies: { + $0.authenticationClient.twoFactor = { @Sendable _, _ in + throw AuthenticationError.invalidTwoFactor + } + } - await store.send(\.view.binding.code, "1234") { - $0.code = "1234" - $0.isFormValid = true - } - await store.send(\.view.submitButtonTapped) { - $0.isTwoFactorRequestInFlight = true - } - await store.receive(\.twoFactorResponse.failure) { - $0.alert = AlertState { - TextState(AuthenticationError.invalidTwoFactor.localizedDescription) - } - $0.isTwoFactorRequestInFlight = false - } - await store.send(\.alert.dismiss) { - $0.alert = nil - } - await store.finish() - } + await store.send(\.view.binding.code, "1234") { + $0.code = "1234" + $0.isFormValid = true + } + await store.send(\.view.submitButtonTapped) { + $0.isTwoFactorRequestInFlight = true + } + await store.receive(\.twoFactorResponse.failure) { + $0.alert = AlertState { + TextState(AuthenticationError.invalidTwoFactor.localizedDescription) + } + $0.isTwoFactorRequestInFlight = false + } + await store.send(\.alert.dismiss) { + $0.alert = nil + } + await store.finish() + } } diff --git a/Examples/Todos/Todos/Todo.swift b/Examples/Todos/Todos/Todo.swift index fdebac6..2227fd0 100644 --- a/Examples/Todos/Todos/Todo.swift +++ b/Examples/Todos/Todos/Todo.swift @@ -3,36 +3,36 @@ import SwiftUI @Reducer struct Todo { - @ObservableState - struct State: Equatable, Identifiable { - var description = "" - let id: UUID - var isComplete = false - } + @ObservableState + struct State: Equatable, Identifiable { + var description = "" + let id: UUID + var isComplete = false + } - enum Action: BindableAction, Sendable { - case binding(BindingAction) - } + enum Action: BindableAction, Sendable { + case binding(BindingAction) + } - var body: some Reducer { - BindingReducer() - } + var body: some Reducer { + BindingReducer() + } } struct TodoView: View { - @Bindable var store: StoreOf + @Bindable var store: StoreOf - var body: some View { - HStack { - Button { - store.isComplete.toggle() - } label: { - Image(systemName: store.isComplete ? "checkmark.square" : "square") - } - .buttonStyle(.plain) + var body: some View { + HStack { + Button { + store.isComplete.toggle() + } label: { + Image(systemName: store.isComplete ? "checkmark.square" : "square") + } + .buttonStyle(.plain) - TextField("Untitled Todo", text: $store.description) - } - .foregroundColor(store.isComplete ? .gray : nil) - } + TextField("Untitled Todo", text: $store.description) + } + .foregroundColor(store.isComplete ? .gray : nil) + } } diff --git a/Examples/Todos/Todos/Todos.swift b/Examples/Todos/Todos/Todos.swift index bfb26cf..556b820 100644 --- a/Examples/Todos/Todos/Todos.swift +++ b/Examples/Todos/Todos/Todos.swift @@ -2,168 +2,168 @@ import ComposableArchitecture import SwiftUI enum Filter: LocalizedStringKey, CaseIterable, Hashable { - case all = "All" - case active = "Active" - case completed = "Completed" + case all = "All" + case active = "Active" + case completed = "Completed" } @Reducer struct Todos { - @ObservableState - struct State: Equatable { - var editMode: EditMode = .inactive - var filter: Filter = .all - var todos: IdentifiedArrayOf = [] - - var filteredTodos: IdentifiedArrayOf { - switch filter { - case .active: return self.todos.filter { !$0.isComplete } - case .all: return self.todos - case .completed: return self.todos.filter(\.isComplete) - } - } - } - - enum Action: BindableAction, Sendable { - case addTodoButtonTapped - case binding(BindingAction) - case clearCompletedButtonTapped - case delete(IndexSet) - case move(IndexSet, Int) - case sortCompletedTodos - case todos(IdentifiedActionOf) - } - - @Dependency(\.continuousClock) var clock - @Dependency(\.uuid) var uuid - private enum CancelID { case todoCompletion } - - var body: some Reducer { - BindingReducer() - Reduce { state, action in - switch action { - case .addTodoButtonTapped: - state.todos.insert(Todo.State(id: self.uuid()), at: 0) - return .none - - case .binding: - return .none - - case .clearCompletedButtonTapped: - state.todos.removeAll(where: \.isComplete) - return .none - - case let .delete(indexSet): - let filteredTodos = state.filteredTodos - for index in indexSet { - state.todos.remove(id: filteredTodos[index].id) - } - return .none - - case var .move(source, destination): - if state.filter == .completed { - source = IndexSet( - source - .map { state.filteredTodos[$0] } - .compactMap { state.todos.index(id: $0.id) } - ) - destination = - (destination < state.filteredTodos.endIndex - ? state.todos.index(id: state.filteredTodos[destination].id) - : state.todos.endIndex) - ?? destination - } - - state.todos.move(fromOffsets: source, toOffset: destination) - - return .run { send in - try await self.clock.sleep(for: .milliseconds(100)) - await send(.sortCompletedTodos) - } - - case .sortCompletedTodos: - state.todos.sort { $1.isComplete && !$0.isComplete } - return .none - - case .todos(.element(id: _, action: .binding(\.isComplete))): - return .run { send in - try await self.clock.sleep(for: .seconds(1)) - await send(.sortCompletedTodos, animation: .default) - } - .cancellable(id: CancelID.todoCompletion, cancelInFlight: true) - - case .todos: - return .none - } - } - .forEach(\.todos, action: \.todos) { - Todo() - } - } + @ObservableState + struct State: Equatable { + var editMode: EditMode = .inactive + var filter: Filter = .all + var todos: IdentifiedArrayOf = [] + + var filteredTodos: IdentifiedArrayOf { + switch filter { + case .active: return todos.filter { !$0.isComplete } + case .all: return todos + case .completed: return todos.filter(\.isComplete) + } + } + } + + enum Action: BindableAction, Sendable { + case addTodoButtonTapped + case binding(BindingAction) + case clearCompletedButtonTapped + case delete(IndexSet) + case move(IndexSet, Int) + case sortCompletedTodos + case todos(IdentifiedActionOf) + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.uuid) var uuid + private enum CancelID { case todoCompletion } + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .addTodoButtonTapped: + state.todos.insert(Todo.State(id: uuid()), at: 0) + return .none + + case .binding: + return .none + + case .clearCompletedButtonTapped: + state.todos.removeAll(where: \.isComplete) + return .none + + case let .delete(indexSet): + let filteredTodos = state.filteredTodos + for index in indexSet { + state.todos.remove(id: filteredTodos[index].id) + } + return .none + + case var .move(source, destination): + if state.filter == .completed { + source = IndexSet( + source + .map { state.filteredTodos[$0] } + .compactMap { state.todos.index(id: $0.id) } + ) + destination = + (destination < state.filteredTodos.endIndex + ? state.todos.index(id: state.filteredTodos[destination].id) + : state.todos.endIndex) + ?? destination + } + + state.todos.move(fromOffsets: source, toOffset: destination) + + return .run { send in + try await clock.sleep(for: .milliseconds(100)) + await send(.sortCompletedTodos) + } + + case .sortCompletedTodos: + state.todos.sort { $1.isComplete && !$0.isComplete } + return .none + + case .todos(.element(id: _, action: .binding(\.isComplete))): + return .run { send in + try await clock.sleep(for: .seconds(1)) + await send(.sortCompletedTodos, animation: .default) + } + .cancellable(id: CancelID.todoCompletion, cancelInFlight: true) + + case .todos: + return .none + } + } + .forEach(\.todos, action: \.todos) { + Todo() + } + } } struct AppView: View { - @Bindable var store: StoreOf - - var body: some View { - NavigationStack { - VStack(alignment: .leading) { - Picker("Filter", selection: $store.filter.animation()) { - ForEach(Filter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - - List { - ForEach(store.scope(state: \.filteredTodos, action: \.todos)) { store in - TodoView(store: store) - } - .onDelete { store.send(.delete($0)) } - .onMove { store.send(.move($0, $1)) } - } - } - .navigationTitle("Todos") - .navigationBarItems( - trailing: HStack(spacing: 20) { - EditButton() - Button("Clear Completed") { - store.send(.clearCompletedButtonTapped, animation: .default) - } - .disabled(!store.todos.contains(where: \.isComplete)) - Button("Add Todo") { store.send(.addTodoButtonTapped, animation: .default) } - } - ) - .environment(\.editMode, $store.editMode) - } - } + @Bindable var store: StoreOf + + var body: some View { + NavigationStack { + VStack(alignment: .leading) { + Picker("Filter", selection: $store.filter.animation()) { + ForEach(Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + List { + ForEach(store.scope(state: \.filteredTodos, action: \.todos)) { store in + TodoView(store: store) + } + .onDelete { store.send(.delete($0)) } + .onMove { store.send(.move($0, $1)) } + } + } + .navigationTitle("Todos") + .navigationBarItems( + trailing: HStack(spacing: 20) { + EditButton() + Button("Clear Completed") { + store.send(.clearCompletedButtonTapped, animation: .default) + } + .disabled(!store.todos.contains(where: \.isComplete)) + Button("Add Todo") { store.send(.addTodoButtonTapped, animation: .default) } + } + ) + .environment(\.editMode, $store.editMode) + } + } } extension IdentifiedArray where ID == Todo.State.ID, Element == Todo.State { - static let mock: Self = [ - Todo.State( - description: "Check Mail", - id: UUID(), - isComplete: false - ), - Todo.State( - description: "Buy Milk", - id: UUID(), - isComplete: false - ), - Todo.State( - description: "Call Mom", - id: UUID(), - isComplete: true - ), - ] + static let mock: Self = [ + Todo.State( + description: "Check Mail", + id: UUID(), + isComplete: false + ), + Todo.State( + description: "Buy Milk", + id: UUID(), + isComplete: false + ), + Todo.State( + description: "Call Mom", + id: UUID(), + isComplete: true + ), + ] } #Preview { - AppView( - store: Store(initialState: Todos.State(todos: .mock)) { - Todos() - } - ) + AppView( + store: Store(initialState: Todos.State(todos: .mock)) { + Todos() + } + ) } diff --git a/Examples/Todos/Todos/TodosApp.swift b/Examples/Todos/Todos/TodosApp.swift index f4c394a..dab6ba9 100644 --- a/Examples/Todos/Todos/TodosApp.swift +++ b/Examples/Todos/Todos/TodosApp.swift @@ -3,13 +3,13 @@ import SwiftUI @main struct TodosApp: App { - var body: some Scene { - WindowGroup { - AppView( - store: Store(initialState: Todos.State()) { - Todos() - } - ) - } - } + var body: some Scene { + WindowGroup { + AppView( + store: Store(initialState: Todos.State()) { + Todos() + } + ) + } + } } diff --git a/Examples/Todos/TodosTests/TodosTests.swift b/Examples/Todos/TodosTests/TodosTests.swift index 3265cc9..750dbfa 100644 --- a/Examples/Todos/TodosTests/TodosTests.swift +++ b/Examples/Todos/TodosTests/TodosTests.swift @@ -4,350 +4,350 @@ import XCTest @testable import Todos final class TodosTests: XCTestCase { - let clock = TestClock() - - @MainActor - func testAddTodo() async { - let store = TestStore(initialState: Todos.State()) { - Todos() - } withDependencies: { - $0.uuid = .incrementing - } - - await store.send(.addTodoButtonTapped) { - $0.todos.insert( - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - at: 0 - ) - } - - await store.send(.addTodoButtonTapped) { - $0.todos = [ - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - ] - } - } - - @MainActor - func testEditTodo() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ) - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(\.todos[id:UUID(0)].binding.description, "Learn Composable Architecture") { - $0.todos[id: UUID(0)]?.description = "Learn Composable Architecture" - } - } - - @MainActor - func testCompleteTodo() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } - - await store.send(\.todos[id:UUID(0)].binding.isComplete, true) { - $0.todos[id: UUID(0)]?.isComplete = true - } - await self.clock.advance(by: .seconds(1)) - await store.receive(\.sortCompletedTodos) { - $0.todos = [ - $0.todos[1], - $0.todos[0], - ] - } - } - - @MainActor - func testCompleteTodoDebounces() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } - - await store.send(\.todos[id:UUID(0)].binding.isComplete, true) { - $0.todos[id: UUID(0)]?.isComplete = true - } - await self.clock.advance(by: .milliseconds(500)) - await store.send(\.todos[id:UUID(0)].binding.isComplete, false) { - $0.todos[id: UUID(0)]?.isComplete = false - } - await self.clock.advance(by: .seconds(1)) - await store.receive(\.sortCompletedTodos) - } - - @MainActor - func testClearCompleted() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: true - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.clearCompletedButtonTapped) { - $0.todos = [ - $0.todos[0] - ] - } - } - - @MainActor - func testDelete() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(2), - isComplete: false - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.delete([1])) { - $0.todos = [ - $0.todos[0], - $0.todos[2], - ] - } - } - - @MainActor - func testDeleteWhileFiltered() async { - let state = Todos.State( - filter: .completed, - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(2), - isComplete: true - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(.delete([0])) { - $0.todos = [ - $0.todos[0], - $0.todos[1], - ] - } - } - - @MainActor - func testEditModeMoving() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(2), - isComplete: false - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - } - - await store.send(\.binding.editMode, .active) { - $0.editMode = .active - } - await store.send(.move([0], 2)) { - $0.todos = [ - $0.todos[1], - $0.todos[0], - $0.todos[2], - ] - } - await self.clock.advance(by: .milliseconds(100)) - await store.receive(\.sortCompletedTodos) - } - - @MainActor - func testEditModeMovingWithFilter() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(2), - isComplete: true - ), - Todo.State( - description: "", - id: UUID(3), - isComplete: true - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } withDependencies: { - $0.continuousClock = self.clock - $0.uuid = .incrementing - } - - await store.send(\.binding.editMode, .active) { - $0.editMode = .active - } - await store.send(\.binding.filter, .completed) { - $0.filter = .completed - } - await store.send(.move([0], 2)) { - $0.todos = [ - $0.todos[0], - $0.todos[1], - $0.todos[3], - $0.todos[2], - ] - } - await self.clock.advance(by: .milliseconds(100)) - await store.receive(\.sortCompletedTodos) - } - - @MainActor - func testFilteredEdit() async { - let state = Todos.State( - todos: [ - Todo.State( - description: "", - id: UUID(0), - isComplete: false - ), - Todo.State( - description: "", - id: UUID(1), - isComplete: true - ), - ] - ) - - let store = TestStore(initialState: state) { - Todos() - } - - await store.send(\.binding.filter, .completed) { - $0.filter = .completed - } - await store.send(\.todos[id:UUID(1)].binding.description, "Did this already") { - $0.todos[id: UUID(1)]?.description = "Did this already" - } - } + let clock = TestClock() + + @MainActor + func testAddTodo() async { + let store = TestStore(initialState: Todos.State()) { + Todos() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.addTodoButtonTapped) { + $0.todos.insert( + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + at: 0 + ) + } + + await store.send(.addTodoButtonTapped) { + $0.todos = [ + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + ] + } + } + + @MainActor + func testEditTodo() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } + + await store.send(\.todos[id: UUID(0)].binding.description, "Learn Composable Architecture") { + $0.todos[id: UUID(0)]?.description = "Learn Composable Architecture" + } + } + + @MainActor + func testCompleteTodo() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } withDependencies: { + $0.continuousClock = self.clock + } + + await store.send(\.todos[id: UUID(0)].binding.isComplete, true) { + $0.todos[id: UUID(0)]?.isComplete = true + } + await clock.advance(by: .seconds(1)) + await store.receive(\.sortCompletedTodos) { + $0.todos = [ + $0.todos[1], + $0.todos[0], + ] + } + } + + @MainActor + func testCompleteTodoDebounces() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } withDependencies: { + $0.continuousClock = self.clock + } + + await store.send(\.todos[id: UUID(0)].binding.isComplete, true) { + $0.todos[id: UUID(0)]?.isComplete = true + } + await clock.advance(by: .milliseconds(500)) + await store.send(\.todos[id: UUID(0)].binding.isComplete, false) { + $0.todos[id: UUID(0)]?.isComplete = false + } + await clock.advance(by: .seconds(1)) + await store.receive(\.sortCompletedTodos) + } + + @MainActor + func testClearCompleted() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: true + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } + + await store.send(.clearCompletedButtonTapped) { + $0.todos = [ + $0.todos[0], + ] + } + } + + @MainActor + func testDelete() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(2), + isComplete: false + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } + + await store.send(.delete([1])) { + $0.todos = [ + $0.todos[0], + $0.todos[2], + ] + } + } + + @MainActor + func testDeleteWhileFiltered() async { + let state = Todos.State( + filter: .completed, + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(2), + isComplete: true + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } + + await store.send(.delete([0])) { + $0.todos = [ + $0.todos[0], + $0.todos[1], + ] + } + } + + @MainActor + func testEditModeMoving() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(2), + isComplete: false + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } withDependencies: { + $0.continuousClock = self.clock + } + + await store.send(\.binding.editMode, .active) { + $0.editMode = .active + } + await store.send(.move([0], 2)) { + $0.todos = [ + $0.todos[1], + $0.todos[0], + $0.todos[2], + ] + } + await clock.advance(by: .milliseconds(100)) + await store.receive(\.sortCompletedTodos) + } + + @MainActor + func testEditModeMovingWithFilter() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(2), + isComplete: true + ), + Todo.State( + description: "", + id: UUID(3), + isComplete: true + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } withDependencies: { + $0.continuousClock = self.clock + $0.uuid = .incrementing + } + + await store.send(\.binding.editMode, .active) { + $0.editMode = .active + } + await store.send(\.binding.filter, .completed) { + $0.filter = .completed + } + await store.send(.move([0], 2)) { + $0.todos = [ + $0.todos[0], + $0.todos[1], + $0.todos[3], + $0.todos[2], + ] + } + await clock.advance(by: .milliseconds(100)) + await store.receive(\.sortCompletedTodos) + } + + @MainActor + func testFilteredEdit() async { + let state = Todos.State( + todos: [ + Todo.State( + description: "", + id: UUID(0), + isComplete: false + ), + Todo.State( + description: "", + id: UUID(1), + isComplete: true + ), + ] + ) + + let store = TestStore(initialState: state) { + Todos() + } + + await store.send(\.binding.filter, .completed) { + $0.filter = .completed + } + await store.send(\.todos[id: UUID(1)].binding.description, "Did this already") { + $0.todos[id: UUID(1)]?.description = "Did this already" + } + } } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift index 00c89b9..9c94b66 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/AudioPlayerClient.swift @@ -3,23 +3,23 @@ import Foundation @DependencyClient struct AudioPlayerClient { - var play: @Sendable (_ url: URL) async throws -> Bool + var play: @Sendable (_ url: URL) async throws -> Bool } extension AudioPlayerClient: TestDependencyKey { - static let previewValue = Self( - play: { _ in - try await Task.sleep(for: .seconds(5)) - return true - } - ) + static let previewValue = Self( + play: { _ in + try await Task.sleep(for: .seconds(5)) + return true + } + ) - static let testValue = Self() + static let testValue = Self() } extension DependencyValues { - var audioPlayer: AudioPlayerClient { - get { self[AudioPlayerClient.self] } - set { self[AudioPlayerClient.self] = newValue } - } + var audioPlayer: AudioPlayerClient { + get { self[AudioPlayerClient.self] } + set { self[AudioPlayerClient.self] = newValue } + } } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift index ece4954..0768b55 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioPlayerClient/LiveAudioPlayerClient.swift @@ -2,53 +2,53 @@ import Dependencies extension AudioPlayerClient: DependencyKey { - static let liveValue = Self { url in - let stream = AsyncThrowingStream { continuation in - do { - let delegate = try Delegate( - url: url, - didFinishPlaying: { successful in - continuation.yield(successful) - continuation.finish() - }, - decodeErrorDidOccur: { error in - continuation.finish(throwing: error) - } - ) - delegate.player.play() - continuation.onTermination = { _ in - delegate.player.stop() - } - } catch { - continuation.finish(throwing: error) - } - } - return try await stream.first(where: { _ in true }) ?? false - } + static let liveValue = Self { url in + let stream = AsyncThrowingStream { continuation in + do { + let delegate = try Delegate( + url: url, + didFinishPlaying: { successful in + continuation.yield(successful) + continuation.finish() + }, + decodeErrorDidOccur: { error in + continuation.finish(throwing: error) + } + ) + delegate.player.play() + continuation.onTermination = { _ in + delegate.player.stop() + } + } catch { + continuation.finish(throwing: error) + } + } + return try await stream.first(where: { _ in true }) ?? false + } } private final class Delegate: NSObject, AVAudioPlayerDelegate, Sendable { - let didFinishPlaying: @Sendable (Bool) -> Void - let decodeErrorDidOccur: @Sendable (Error?) -> Void - let player: AVAudioPlayer + let didFinishPlaying: @Sendable (Bool) -> Void + let decodeErrorDidOccur: @Sendable (Error?) -> Void + let player: AVAudioPlayer - init( - url: URL, - didFinishPlaying: @escaping @Sendable (Bool) -> Void, - decodeErrorDidOccur: @escaping @Sendable (Error?) -> Void - ) throws { - self.didFinishPlaying = didFinishPlaying - self.decodeErrorDidOccur = decodeErrorDidOccur - self.player = try AVAudioPlayer(contentsOf: url) - super.init() - self.player.delegate = self - } + init( + url: URL, + didFinishPlaying: @escaping @Sendable (Bool) -> Void, + decodeErrorDidOccur: @escaping @Sendable (Error?) -> Void + ) throws { + self.didFinishPlaying = didFinishPlaying + self.decodeErrorDidOccur = decodeErrorDidOccur + player = try AVAudioPlayer(contentsOf: url) + super.init() + player.delegate = self + } - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - self.didFinishPlaying(flag) - } + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + didFinishPlaying(flag) + } - func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { - self.decodeErrorDidOccur(error) - } + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + decodeErrorDidOccur(error) + } } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift index a4cc63a..807c014 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/AudioRecorderClient.swift @@ -3,41 +3,41 @@ import Foundation @DependencyClient struct AudioRecorderClient { - var currentTime: @Sendable () async -> TimeInterval? - var requestRecordPermission: @Sendable () async -> Bool = { false } - var startRecording: @Sendable (_ url: URL) async throws -> Bool - var stopRecording: @Sendable () async -> Void + var currentTime: @Sendable () async -> TimeInterval? + var requestRecordPermission: @Sendable () async -> Bool = { false } + var startRecording: @Sendable (_ url: URL) async throws -> Bool + var stopRecording: @Sendable () async -> Void } extension AudioRecorderClient: TestDependencyKey { - static var previewValue: Self { - let isRecording = ActorIsolated(false) - let currentTime = ActorIsolated(0.0) + static var previewValue: Self { + let isRecording = ActorIsolated(false) + let currentTime = ActorIsolated(0.0) - return Self( - currentTime: { await currentTime.value }, - requestRecordPermission: { true }, - startRecording: { _ in - await isRecording.setValue(true) - while await isRecording.value { - try await Task.sleep(for: .seconds(1)) - await currentTime.withValue { $0 += 1 } - } - return true - }, - stopRecording: { - await isRecording.setValue(false) - await currentTime.setValue(0) - } - ) - } + return Self( + currentTime: { await currentTime.value }, + requestRecordPermission: { true }, + startRecording: { _ in + await isRecording.setValue(true) + while await isRecording.value { + try await Task.sleep(for: .seconds(1)) + await currentTime.withValue { $0 += 1 } + } + return true + }, + stopRecording: { + await isRecording.setValue(false) + await currentTime.setValue(0) + } + ) + } - static let testValue = Self() + static let testValue = Self() } extension DependencyValues { - var audioRecorder: AudioRecorderClient { - get { self[AudioRecorderClient.self] } - set { self[AudioRecorderClient.self] = newValue } - } + var audioRecorder: AudioRecorderClient { + get { self[AudioRecorderClient.self] } + set { self[AudioRecorderClient.self] = newValue } + } } diff --git a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift index 5aedb6b..7a381de 100644 --- a/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift +++ b/Examples/VoiceMemos/VoiceMemos/AudioRecorderClient/LiveAudioRecorderClient.swift @@ -2,102 +2,104 @@ import AVFoundation import Dependencies extension AudioRecorderClient: DependencyKey { - static var liveValue: Self { - let audioRecorder = AudioRecorder() - return Self( - currentTime: { await audioRecorder.currentTime }, - requestRecordPermission: { await AudioRecorder.requestPermission() }, - startRecording: { url in try await audioRecorder.start(url: url) }, - stopRecording: { await audioRecorder.stop() } - ) - } + static var liveValue: Self { + let audioRecorder = AudioRecorder() + return Self( + currentTime: { await audioRecorder.currentTime }, + requestRecordPermission: { await AudioRecorder.requestPermission() }, + startRecording: { url in try await audioRecorder.start(url: url) }, + stopRecording: { await audioRecorder.stop() } + ) + } } private actor AudioRecorder { - var delegate: Delegate? - var recorder: AVAudioRecorder? + var delegate: Delegate? + var recorder: AVAudioRecorder? - var currentTime: TimeInterval? { - guard - let recorder = self.recorder, - recorder.isRecording - else { return nil } - return recorder.currentTime - } + var currentTime: TimeInterval? { + guard + let recorder, + recorder.isRecording + else { return nil } + return recorder.currentTime + } - static func requestPermission() async -> Bool { - await AVAudioApplication.requestRecordPermission() - } + static func requestPermission() async -> Bool { + await AVAudioApplication.requestRecordPermission() + } - func stop() { - self.recorder?.stop() - try? AVAudioSession.sharedInstance().setActive(false) - } + func stop() { + recorder?.stop() + try? AVAudioSession.sharedInstance().setActive(false) + } - func start(url: URL) async throws -> Bool { - self.stop() + func start(url: URL) async throws -> Bool { + stop() - let stream = AsyncThrowingStream { continuation in - do { - self.delegate = Delegate( - didFinishRecording: { flag in - continuation.yield(flag) - continuation.finish() - try? AVAudioSession.sharedInstance().setActive(false) - }, - encodeErrorDidOccur: { error in - continuation.finish(throwing: error) - try? AVAudioSession.sharedInstance().setActive(false) - } - ) - let recorder = try AVAudioRecorder( - url: url, - settings: [ - AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 44100, - AVNumberOfChannelsKey: 1, - AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, - ]) - self.recorder = recorder - recorder.delegate = self.delegate + let stream = AsyncThrowingStream { continuation in + do { + self.delegate = Delegate( + didFinishRecording: { flag in + continuation.yield(flag) + continuation.finish() + try? AVAudioSession.sharedInstance().setActive(false) + }, + encodeErrorDidOccur: { error in + continuation.finish(throwing: error) + try? AVAudioSession.sharedInstance().setActive(false) + } + ) + let recorder = try AVAudioRecorder( + url: url, + settings: [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, + ] + ) + self.recorder = recorder + recorder.delegate = self.delegate - continuation.onTermination = { [recorder = UncheckedSendable(recorder)] _ in - recorder.wrappedValue.stop() - } + continuation.onTermination = { [recorder = UncheckedSendable(recorder)] _ in + recorder.wrappedValue.stop() + } - try AVAudioSession.sharedInstance().setCategory( - .playAndRecord, mode: .default, options: .defaultToSpeaker) - try AVAudioSession.sharedInstance().setActive(true) - self.recorder?.record() - } catch { - continuation.finish(throwing: error) - } - } + try AVAudioSession.sharedInstance().setCategory( + .playAndRecord, mode: .default, options: .defaultToSpeaker + ) + try AVAudioSession.sharedInstance().setActive(true) + self.recorder?.record() + } catch { + continuation.finish(throwing: error) + } + } - for try await didFinish in stream { - return didFinish - } - throw CancellationError() - } + for try await didFinish in stream { + return didFinish + } + throw CancellationError() + } } private final class Delegate: NSObject, AVAudioRecorderDelegate, Sendable { - let didFinishRecording: @Sendable (Bool) -> Void - let encodeErrorDidOccur: @Sendable (Error?) -> Void + let didFinishRecording: @Sendable (Bool) -> Void + let encodeErrorDidOccur: @Sendable (Error?) -> Void - init( - didFinishRecording: @escaping @Sendable (Bool) -> Void, - encodeErrorDidOccur: @escaping @Sendable (Error?) -> Void - ) { - self.didFinishRecording = didFinishRecording - self.encodeErrorDidOccur = encodeErrorDidOccur - } + init( + didFinishRecording: @escaping @Sendable (Bool) -> Void, + encodeErrorDidOccur: @escaping @Sendable (Error?) -> Void + ) { + self.didFinishRecording = didFinishRecording + self.encodeErrorDidOccur = encodeErrorDidOccur + } - func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { - self.didFinishRecording(flag) - } + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + didFinishRecording(flag) + } - func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { - self.encodeErrorDidOccur(error) - } + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + encodeErrorDidOccur(error) + } } diff --git a/Examples/VoiceMemos/VoiceMemos/Dependencies.swift b/Examples/VoiceMemos/VoiceMemos/Dependencies.swift index 18225f5..6e6af45 100644 --- a/Examples/VoiceMemos/VoiceMemos/Dependencies.swift +++ b/Examples/VoiceMemos/VoiceMemos/Dependencies.swift @@ -2,27 +2,27 @@ import Dependencies import SwiftUI extension DependencyValues { - var openSettings: @Sendable () async -> Void { - get { self[OpenSettingsKey.self] } - set { self[OpenSettingsKey.self] = newValue } - } + var openSettings: @Sendable () async -> Void { + get { self[OpenSettingsKey.self] } + set { self[OpenSettingsKey.self] = newValue } + } - private enum OpenSettingsKey: DependencyKey { - typealias Value = @Sendable () async -> Void + private enum OpenSettingsKey: DependencyKey { + typealias Value = @Sendable () async -> Void - static let liveValue: @Sendable () async -> Void = { - await MainActor.run { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } - } - } + static let liveValue: @Sendable () async -> Void = { + await MainActor.run { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } + } - var temporaryDirectory: @Sendable () -> URL { - get { self[TemporaryDirectoryKey.self] } - set { self[TemporaryDirectoryKey.self] = newValue } - } + var temporaryDirectory: @Sendable () -> URL { + get { self[TemporaryDirectoryKey.self] } + set { self[TemporaryDirectoryKey.self] = newValue } + } - private enum TemporaryDirectoryKey: DependencyKey { - static let liveValue: @Sendable () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) } - } + private enum TemporaryDirectoryKey: DependencyKey { + static let liveValue: @Sendable () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) } + } } diff --git a/Examples/VoiceMemos/VoiceMemos/Helpers.swift b/Examples/VoiceMemos/VoiceMemos/Helpers.swift index c100e12..75e3ff0 100644 --- a/Examples/VoiceMemos/VoiceMemos/Helpers.swift +++ b/Examples/VoiceMemos/VoiceMemos/Helpers.swift @@ -1,8 +1,8 @@ import Foundation let dateComponentsFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute, .second] - formatter.zeroFormattingBehavior = .pad - return formatter + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter }() diff --git a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift b/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift index 5de28b8..38ee80b 100644 --- a/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/RecordingMemo.swift @@ -3,120 +3,120 @@ import SwiftUI @Reducer struct RecordingMemo { - @ObservableState - struct State: Equatable, Sendable { - var date: Date - var duration: TimeInterval = 0 - var mode: Mode = .recording - var url: URL - - enum Mode { - case recording - case encoding - } - } - - enum Action: Sendable { - case audioRecorderDidFinish(Result) - case delegate(Delegate) - case finalRecordingTime(TimeInterval) - case onTask - case timerUpdated - case stopButtonTapped - - @CasePathable - enum Delegate: Sendable { - case didFinish(Result) - } - } - - struct Failed: Equatable, Error {} - - @Dependency(\.audioRecorder) var audioRecorder - @Dependency(\.continuousClock) var clock - - var body: some Reducer { - Reduce { state, action in - switch action { - case .audioRecorderDidFinish(.success(true)): - return .send(.delegate(.didFinish(.success(state)))) - - case .audioRecorderDidFinish(.success(false)): - return .send(.delegate(.didFinish(.failure(Failed())))) - - case let .audioRecorderDidFinish(.failure(error)): - return .send(.delegate(.didFinish(.failure(error)))) - - case .delegate: - return .none - - case let .finalRecordingTime(duration): - state.duration = duration - return .none - - case .stopButtonTapped: - state.mode = .encoding - return .run { send in - if let currentTime = await self.audioRecorder.currentTime() { - await send(.finalRecordingTime(currentTime)) - } - await self.audioRecorder.stopRecording() - } - - case .onTask: - return .run { [url = state.url] send in - async let startRecording: Void = send( - .audioRecorderDidFinish( - Result { try await self.audioRecorder.startRecording(url: url) } - ) - ) - for await _ in self.clock.timer(interval: .seconds(1)) { - await send(.timerUpdated) - } - await startRecording - } - - case .timerUpdated: - state.duration += 1 - return .none - } - } - } + @ObservableState + struct State: Equatable, Sendable { + var date: Date + var duration: TimeInterval = 0 + var mode: Mode = .recording + var url: URL + + enum Mode { + case recording + case encoding + } + } + + enum Action: Sendable { + case audioRecorderDidFinish(Result) + case delegate(Delegate) + case finalRecordingTime(TimeInterval) + case onTask + case timerUpdated + case stopButtonTapped + + @CasePathable + enum Delegate: Sendable { + case didFinish(Result) + } + } + + struct Failed: Equatable, Error {} + + @Dependency(\.audioRecorder) var audioRecorder + @Dependency(\.continuousClock) var clock + + var body: some Reducer { + Reduce { state, action in + switch action { + case .audioRecorderDidFinish(.success(true)): + return .send(.delegate(.didFinish(.success(state)))) + + case .audioRecorderDidFinish(.success(false)): + return .send(.delegate(.didFinish(.failure(Failed())))) + + case let .audioRecorderDidFinish(.failure(error)): + return .send(.delegate(.didFinish(.failure(error)))) + + case .delegate: + return .none + + case let .finalRecordingTime(duration): + state.duration = duration + return .none + + case .stopButtonTapped: + state.mode = .encoding + return .run { send in + if let currentTime = await audioRecorder.currentTime() { + await send(.finalRecordingTime(currentTime)) + } + await audioRecorder.stopRecording() + } + + case .onTask: + return .run { [url = state.url] send in + async let startRecording: Void = send( + .audioRecorderDidFinish( + Result { try await audioRecorder.startRecording(url: url) } + ) + ) + for await _ in clock.timer(interval: .seconds(1)) { + await send(.timerUpdated) + } + await startRecording + } + + case .timerUpdated: + state.duration += 1 + return .none + } + } + } } struct RecordingMemoView: View { - let store: StoreOf - - var body: some View { - VStack(spacing: 12) { - Text("Recording") - .font(.title) - .colorMultiply(Color(Int(store.duration).isMultiple(of: 2) ? .systemRed : .label)) - .animation(.easeInOut(duration: 0.5), value: store.duration) - - if let formattedDuration = dateComponentsFormatter.string(from: store.duration) { - Text(formattedDuration) - .font(.body.monospacedDigit().bold()) - .foregroundColor(.black) - } - - ZStack { - Circle() - .foregroundColor(Color(.label)) - .frame(width: 74, height: 74) - - Button { - store.send(.stopButtonTapped, animation: .default) - } label: { - RoundedRectangle(cornerRadius: 4) - .foregroundColor(Color(.systemRed)) - .padding(17) - } - .frame(width: 70, height: 70) - } - } - .task { - await store.send(.onTask).finish() - } - } + let store: StoreOf + + var body: some View { + VStack(spacing: 12) { + Text("Recording") + .font(.title) + .colorMultiply(Color(Int(store.duration).isMultiple(of: 2) ? .systemRed : .label)) + .animation(.easeInOut(duration: 0.5), value: store.duration) + + if let formattedDuration = dateComponentsFormatter.string(from: store.duration) { + Text(formattedDuration) + .font(.body.monospacedDigit().bold()) + .foregroundColor(.black) + } + + ZStack { + Circle() + .foregroundColor(Color(.label)) + .frame(width: 74, height: 74) + + Button { + store.send(.stopButtonTapped, animation: .default) + } label: { + RoundedRectangle(cornerRadius: 4) + .foregroundColor(Color(.systemRed)) + .padding(17) + } + .frame(width: 70, height: 70) + } + } + .task { + await store.send(.onTask).finish() + } + } } diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift index b5c07d6..28fdcb0 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift @@ -3,143 +3,143 @@ import SwiftUI @Reducer struct VoiceMemo { - @ObservableState - struct State: Equatable, Identifiable { - var date: Date - var duration: TimeInterval - var mode = Mode.notPlaying - var title = "" - var url: URL - - var id: URL { self.url } - - @CasePathable - @dynamicMemberLookup - enum Mode: Equatable { - case notPlaying - case playing(progress: Double) - } - } - - enum Action { - case audioPlayerClient(Result) - case delegate(Delegate) - case playButtonTapped - case timerUpdated(TimeInterval) - case titleTextFieldChanged(String) - - @CasePathable - enum Delegate { - case playbackStarted - case playbackFailed - } - } - - @Dependency(\.audioPlayer) var audioPlayer - @Dependency(\.continuousClock) var clock - private enum CancelID { case play } - - var body: some Reducer { - Reduce { state, action in - switch action { - case .audioPlayerClient(.failure): - state.mode = .notPlaying - return .merge( - .cancel(id: CancelID.play), - .send(.delegate(.playbackFailed)) - ) - - case .audioPlayerClient: - state.mode = .notPlaying - return .cancel(id: CancelID.play) - - case .delegate: - return .none - - case .playButtonTapped: - switch state.mode { - case .notPlaying: - state.mode = .playing(progress: 0) - - return .run { [url = state.url] send in - await send(.delegate(.playbackStarted)) - - async let playAudio: Void = send( - .audioPlayerClient(Result { try await self.audioPlayer.play(url: url) }) - ) - - var start: TimeInterval = 0 - for await _ in self.clock.timer(interval: .milliseconds(500)) { - start += 0.5 - await send(.timerUpdated(start)) - } - - await playAudio - } - .cancellable(id: CancelID.play, cancelInFlight: true) - - case .playing: - state.mode = .notPlaying - return .cancel(id: CancelID.play) - } - - case let .timerUpdated(time): - switch state.mode { - case .notPlaying: - break - case .playing: - state.mode = .playing(progress: time / state.duration) - } - return .none - - case let .titleTextFieldChanged(text): - state.title = text - return .none - } - } - } + @ObservableState + struct State: Equatable, Identifiable { + var date: Date + var duration: TimeInterval + var mode = Mode.notPlaying + var title = "" + var url: URL + + var id: URL { url } + + @CasePathable + @dynamicMemberLookup + enum Mode: Equatable { + case notPlaying + case playing(progress: Double) + } + } + + enum Action { + case audioPlayerClient(Result) + case delegate(Delegate) + case playButtonTapped + case timerUpdated(TimeInterval) + case titleTextFieldChanged(String) + + @CasePathable + enum Delegate { + case playbackStarted + case playbackFailed + } + } + + @Dependency(\.audioPlayer) var audioPlayer + @Dependency(\.continuousClock) var clock + private enum CancelID { case play } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .audioPlayerClient(.failure): + state.mode = .notPlaying + return .merge( + .cancel(id: CancelID.play), + .send(.delegate(.playbackFailed)) + ) + + case .audioPlayerClient: + state.mode = .notPlaying + return .cancel(id: CancelID.play) + + case .delegate: + return .none + + case .playButtonTapped: + switch state.mode { + case .notPlaying: + state.mode = .playing(progress: 0) + + return .run { [url = state.url] send in + await send(.delegate(.playbackStarted)) + + async let playAudio: Void = send( + .audioPlayerClient(Result { try await audioPlayer.play(url: url) }) + ) + + var start: TimeInterval = 0 + for await _ in clock.timer(interval: .milliseconds(500)) { + start += 0.5 + await send(.timerUpdated(start)) + } + + await playAudio + } + .cancellable(id: CancelID.play, cancelInFlight: true) + + case .playing: + state.mode = .notPlaying + return .cancel(id: CancelID.play) + } + + case let .timerUpdated(time): + switch state.mode { + case .notPlaying: + break + case .playing: + state.mode = .playing(progress: time / state.duration) + } + return .none + + case let .titleTextFieldChanged(text): + state.title = text + return .none + } + } + } } struct VoiceMemoView: View { - @Bindable var store: StoreOf - - var body: some View { - let currentTime = - store.mode.playing.map { $0 * store.duration } ?? store.duration - HStack { - TextField( - "Untitled, \(store.date.formatted(date: .numeric, time: .shortened))", - text: $store.title.sending(\.titleTextFieldChanged) - ) - - Spacer() - - dateComponentsFormatter.string(from: currentTime).map { - Text($0) - .font(.footnote.monospacedDigit()) - .foregroundColor(Color(.systemGray)) - } - - Button { - store.send(.playButtonTapped) - } label: { - Image(systemName: store.mode.is(\.playing) ? "stop.circle" : "play.circle") - .font(.system(size: 22)) - } - } - .buttonStyle(.borderless) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.horizontal) - .listRowBackground(store.mode.is(\.playing) ? Color(.systemGray6) : .clear) - .listRowInsets(EdgeInsets()) - .background( - Color(.systemGray5) - .frame(maxWidth: store.mode.is(\.playing) ? .infinity : 0) - .animation( - store.mode.is(\.playing) ? .linear(duration: store.duration) : nil, - value: store.mode.is(\.playing) - ), - alignment: .leading - ) - } + @Bindable var store: StoreOf + + var body: some View { + let currentTime = + store.mode.playing.map { $0 * store.duration } ?? store.duration + HStack { + TextField( + "Untitled, \(store.date.formatted(date: .numeric, time: .shortened))", + text: $store.title.sending(\.titleTextFieldChanged) + ) + + Spacer() + + dateComponentsFormatter.string(from: currentTime).map { + Text($0) + .font(.footnote.monospacedDigit()) + .foregroundColor(Color(.systemGray)) + } + + Button { + store.send(.playButtonTapped) + } label: { + Image(systemName: store.mode.is(\.playing) ? "stop.circle" : "play.circle") + .font(.system(size: 22)) + } + } + .buttonStyle(.borderless) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.horizontal) + .listRowBackground(store.mode.is(\.playing) ? Color(.systemGray6) : .clear) + .listRowInsets(EdgeInsets()) + .background( + Color(.systemGray5) + .frame(maxWidth: store.mode.is(\.playing) ? .infinity : 0) + .animation( + store.mode.is(\.playing) ? .linear(duration: store.duration) : nil, + value: store.mode.is(\.playing) + ), + alignment: .leading + ) + } } diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift index 2cb1ffc..8b08169 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemos.swift @@ -4,226 +4,226 @@ import SwiftUI @Reducer struct VoiceMemos { - @ObservableState - struct State: Equatable { - @Presents var alert: AlertState? - var audioRecorderPermission = RecorderPermission.undetermined - @Presents var recordingMemo: RecordingMemo.State? - var voiceMemos: IdentifiedArrayOf = [] - - enum RecorderPermission { - case allowed - case denied - case undetermined - } - } - - enum Action: Sendable { - case alert(PresentationAction) - case onDelete(IndexSet) - case openSettingsButtonTapped - case recordButtonTapped - case recordPermissionResponse(Bool) - case recordingMemo(PresentationAction) - case voiceMemos(IdentifiedActionOf) - - enum Alert: Equatable {} - } - - @Dependency(\.audioRecorder.requestRecordPermission) var requestRecordPermission - @Dependency(\.date) var date - @Dependency(\.openSettings) var openSettings - @Dependency(\.temporaryDirectory) var temporaryDirectory - @Dependency(\.uuid) var uuid - - var body: some Reducer { - Reduce { state, action in - switch action { - case .alert: - return .none - - case let .onDelete(indexSet): - state.voiceMemos.remove(atOffsets: indexSet) - return .none - - case .openSettingsButtonTapped: - return .run { _ in - await self.openSettings() - } - - case .recordButtonTapped: - switch state.audioRecorderPermission { - case .undetermined: - return .run { send in - await send(.recordPermissionResponse(self.requestRecordPermission())) - } - - case .denied: - state.alert = AlertState { TextState("Permission is required to record voice memos.") } - return .none - - case .allowed: - state.recordingMemo = newRecordingMemo - return .none - } - - case let .recordingMemo(.presented(.delegate(.didFinish(.success(recordingMemo))))): - state.recordingMemo = nil - state.voiceMemos.insert( - VoiceMemo.State( - date: recordingMemo.date, - duration: recordingMemo.duration, - url: recordingMemo.url - ), - at: 0 - ) - return .none - - case .recordingMemo(.presented(.delegate(.didFinish(.failure)))): - state.alert = AlertState { TextState("Voice memo recording failed.") } - state.recordingMemo = nil - return .none - - case .recordingMemo: - return .none - - case let .recordPermissionResponse(permission): - state.audioRecorderPermission = permission ? .allowed : .denied - if permission { - state.recordingMemo = newRecordingMemo - return .none - } else { - state.alert = AlertState { TextState("Permission is required to record voice memos.") } - return .none - } - - case let .voiceMemos(.element(id: id, action: .delegate(delegateAction))): - switch delegateAction { - case .playbackFailed: - state.alert = AlertState { TextState("Voice memo playback failed.") } - return .none - case .playbackStarted: - for memoID in state.voiceMemos.ids where memoID != id { - state.voiceMemos[id: memoID]?.mode = .notPlaying - } - return .none - } - - case .voiceMemos: - return .none - } - } - .ifLet(\.$alert, action: \.alert) - .ifLet(\.$recordingMemo, action: \.recordingMemo) { - RecordingMemo() - } - .forEach(\.voiceMemos, action: \.voiceMemos) { - VoiceMemo() - } - } - - private var newRecordingMemo: RecordingMemo.State { - RecordingMemo.State( - date: self.date.now, - url: self.temporaryDirectory() - .appendingPathComponent(self.uuid().uuidString) - .appendingPathExtension("m4a") - ) - } + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var audioRecorderPermission = RecorderPermission.undetermined + @Presents var recordingMemo: RecordingMemo.State? + var voiceMemos: IdentifiedArrayOf = [] + + enum RecorderPermission { + case allowed + case denied + case undetermined + } + } + + enum Action: Sendable { + case alert(PresentationAction) + case onDelete(IndexSet) + case openSettingsButtonTapped + case recordButtonTapped + case recordPermissionResponse(Bool) + case recordingMemo(PresentationAction) + case voiceMemos(IdentifiedActionOf) + + enum Alert: Equatable {} + } + + @Dependency(\.audioRecorder.requestRecordPermission) var requestRecordPermission + @Dependency(\.date) var date + @Dependency(\.openSettings) var openSettings + @Dependency(\.temporaryDirectory) var temporaryDirectory + @Dependency(\.uuid) var uuid + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + return .none + + case let .onDelete(indexSet): + state.voiceMemos.remove(atOffsets: indexSet) + return .none + + case .openSettingsButtonTapped: + return .run { _ in + await openSettings() + } + + case .recordButtonTapped: + switch state.audioRecorderPermission { + case .undetermined: + return .run { send in + await send(.recordPermissionResponse(requestRecordPermission())) + } + + case .denied: + state.alert = AlertState { TextState("Permission is required to record voice memos.") } + return .none + + case .allowed: + state.recordingMemo = newRecordingMemo + return .none + } + + case let .recordingMemo(.presented(.delegate(.didFinish(.success(recordingMemo))))): + state.recordingMemo = nil + state.voiceMemos.insert( + VoiceMemo.State( + date: recordingMemo.date, + duration: recordingMemo.duration, + url: recordingMemo.url + ), + at: 0 + ) + return .none + + case .recordingMemo(.presented(.delegate(.didFinish(.failure)))): + state.alert = AlertState { TextState("Voice memo recording failed.") } + state.recordingMemo = nil + return .none + + case .recordingMemo: + return .none + + case let .recordPermissionResponse(permission): + state.audioRecorderPermission = permission ? .allowed : .denied + if permission { + state.recordingMemo = newRecordingMemo + return .none + } else { + state.alert = AlertState { TextState("Permission is required to record voice memos.") } + return .none + } + + case let .voiceMemos(.element(id: id, action: .delegate(delegateAction))): + switch delegateAction { + case .playbackFailed: + state.alert = AlertState { TextState("Voice memo playback failed.") } + return .none + case .playbackStarted: + for memoID in state.voiceMemos.ids where memoID != id { + state.voiceMemos[id: memoID]?.mode = .notPlaying + } + return .none + } + + case .voiceMemos: + return .none + } + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$recordingMemo, action: \.recordingMemo) { + RecordingMemo() + } + .forEach(\.voiceMemos, action: \.voiceMemos) { + VoiceMemo() + } + } + + private var newRecordingMemo: RecordingMemo.State { + RecordingMemo.State( + date: date.now, + url: temporaryDirectory() + .appendingPathComponent(uuid().uuidString) + .appendingPathExtension("m4a") + ) + } } struct VoiceMemosView: View { - @Bindable var store: StoreOf - - var body: some View { - NavigationStack { - VStack { - List { - ForEach(store.scope(state: \.voiceMemos, action: \.voiceMemos)) { store in - VoiceMemoView(store: store) - } - .onDelete { store.send(.onDelete($0)) } - } - - Group { - if let store = store.scope( - state: \.recordingMemo, action: \.recordingMemo.presented - ) { - RecordingMemoView(store: store) - } else { - RecordButton(permission: store.audioRecorderPermission) { - store.send(.recordButtonTapped, animation: .spring()) - } settingsAction: { - store.send(.openSettingsButtonTapped) - } - } - } - .padding() - .frame(maxWidth: .infinity) - .background(Color.init(white: 0.95)) - } - .alert($store.scope(state: \.alert, action: \.alert)) - .navigationTitle("Voice memos") - } - } + @Bindable var store: StoreOf + + var body: some View { + NavigationStack { + VStack { + List { + ForEach(store.scope(state: \.voiceMemos, action: \.voiceMemos)) { store in + VoiceMemoView(store: store) + } + .onDelete { store.send(.onDelete($0)) } + } + + Group { + if let store = store.scope( + state: \.recordingMemo, action: \.recordingMemo.presented + ) { + RecordingMemoView(store: store) + } else { + RecordButton(permission: store.audioRecorderPermission) { + store.send(.recordButtonTapped, animation: .spring()) + } settingsAction: { + store.send(.openSettingsButtonTapped) + } + } + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(white: 0.95)) + } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationTitle("Voice memos") + } + } } struct RecordButton: View { - let permission: VoiceMemos.State.RecorderPermission - let action: () -> Void - let settingsAction: () -> Void - - var body: some View { - ZStack { - Group { - Circle() - .foregroundColor(Color(.label)) - .frame(width: 74, height: 74) - - Button(action: action) { - RoundedRectangle(cornerRadius: 35) - .foregroundColor(Color(.systemRed)) - .padding(2) - } - .frame(width: 70, height: 70) - } - .opacity(permission == .denied ? 0.1 : 1) - - if permission == .denied { - VStack(spacing: 10) { - Text("Recording requires microphone access.") - .multilineTextAlignment(.center) - Button("Open Settings", action: settingsAction) - } - .frame(maxWidth: .infinity, maxHeight: 74) - } - } - } + let permission: VoiceMemos.State.RecorderPermission + let action: () -> Void + let settingsAction: () -> Void + + var body: some View { + ZStack { + Group { + Circle() + .foregroundColor(Color(.label)) + .frame(width: 74, height: 74) + + Button(action: action) { + RoundedRectangle(cornerRadius: 35) + .foregroundColor(Color(.systemRed)) + .padding(2) + } + .frame(width: 70, height: 70) + } + .opacity(permission == .denied ? 0.1 : 1) + + if permission == .denied { + VStack(spacing: 10) { + Text("Recording requires microphone access.") + .multilineTextAlignment(.center) + Button("Open Settings", action: settingsAction) + } + .frame(maxWidth: .infinity, maxHeight: 74) + } + } + } } #Preview { - VoiceMemosView( - store: Store( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 5, - mode: .notPlaying, - title: "Functions", - url: URL(string: "https://www.pointfree.co/functions")! - ), - VoiceMemo.State( - date: Date(), - duration: 5, - mode: .notPlaying, - title: "", - url: URL(string: "https://www.pointfree.co/untitled")! - ), - ] - ) - ) { - VoiceMemos() - } - ) + VoiceMemosView( + store: Store( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 5, + mode: .notPlaying, + title: "Functions", + url: URL(string: "https://www.pointfree.co/functions")! + ), + VoiceMemo.State( + date: Date(), + duration: 5, + mode: .notPlaying, + title: "", + url: URL(string: "https://www.pointfree.co/untitled")! + ), + ] + ) + ) { + VoiceMemos() + } + ) } diff --git a/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift b/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift index e0a5357..f4a2b92 100644 --- a/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift +++ b/Examples/VoiceMemos/VoiceMemos/VoiceMemosApp.swift @@ -3,13 +3,13 @@ import SwiftUI @main struct VoiceMemosApp: App { - var body: some Scene { - WindowGroup { - VoiceMemosView( - store: Store(initialState: VoiceMemos.State()) { - VoiceMemos()._printChanges() - } - ) - } - } + var body: some Scene { + WindowGroup { + VoiceMemosView( + store: Store(initialState: VoiceMemos.State()) { + VoiceMemos()._printChanges() + } + ) + } + } } diff --git a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift index dc69d86..59bd241 100644 --- a/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift +++ b/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift @@ -7,451 +7,451 @@ let deadbeefID = UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")! let deadbeefURL = URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") final class VoiceMemosTests: XCTestCase { - @MainActor - func testRecordAndPlayback() async throws { - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in - try await clock.sleep(for: .milliseconds(2_500)) - return true - } - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.audioRecorder.stopRecording = { - didFinish.continuation.yield(true) - didFinish.continuation.finish() - } - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.continuousClock = clock - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } + @MainActor + func testRecordAndPlayback() async throws { + let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) + let clock = TestClock() + let store = TestStore(initialState: VoiceMemos.State()) { + VoiceMemos() + } withDependencies: { + $0.audioPlayer.play = { @Sendable _ in + try await clock.sleep(for: .milliseconds(2500)) + return true + } + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { @Sendable _ in + try await didFinish.stream.first { _ in true }! + } + $0.audioRecorder.stopRecording = { + didFinish.continuation.yield(true) + didFinish.continuation.finish() + } + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.continuousClock = clock + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(deadbeefID) + } - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: deadbeefURL - ) - } - await store.send(\.recordingMemo.onTask) - await store.send(\.recordingMemo.stopButtonTapped) { - $0.recordingMemo?.mode = .encoding - } - await store.receive(\.recordingMemo.finalRecordingTime) { - $0.recordingMemo?.duration = 2.5 - } - await store.receive(\.recordingMemo.audioRecorderDidFinish.success) - await store.receive(\.recordingMemo.delegate.didFinish.success) { - $0.recordingMemo = nil - $0.voiceMemos = [ - VoiceMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - duration: 2.5, - mode: .notPlaying, - title: "", - url: deadbeefURL - ) - ] - } - await store.send(\.voiceMemos[id:deadbeefURL].playButtonTapped) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id:deadbeefURL].delegate.playbackStarted) - await clock.run() + await store.send(.recordButtonTapped) + await store.receive(\.recordPermissionResponse) { + $0.audioRecorderPermission = .allowed + $0.recordingMemo = RecordingMemo.State( + date: Date(timeIntervalSinceReferenceDate: 0), + mode: .recording, + url: deadbeefURL + ) + } + await store.send(\.recordingMemo.onTask) + await store.send(\.recordingMemo.stopButtonTapped) { + $0.recordingMemo?.mode = .encoding + } + await store.receive(\.recordingMemo.finalRecordingTime) { + $0.recordingMemo?.duration = 2.5 + } + await store.receive(\.recordingMemo.audioRecorderDidFinish.success) + await store.receive(\.recordingMemo.delegate.didFinish.success) { + $0.recordingMemo = nil + $0.voiceMemos = [ + VoiceMemo.State( + date: Date(timeIntervalSinceReferenceDate: 0), + duration: 2.5, + mode: .notPlaying, + title: "", + url: deadbeefURL + ), + ] + } + await store.send(\.voiceMemos[id: deadbeefURL].playButtonTapped) { + $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0) + } + await store.receive(\.voiceMemos[id: deadbeefURL].delegate.playbackStarted) + await clock.run() - await store.receive(\.voiceMemos[id:deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.2) - } - await store.receive(\.voiceMemos[id:deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.4) - } - await store.receive(\.voiceMemos[id:deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.6) - } - await store.receive(\.voiceMemos[id:deadbeefURL].timerUpdated) { - $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.8) - } - await store.receive(\.voiceMemos[id:deadbeefURL].audioPlayerClient.success) { - $0.voiceMemos[id: deadbeefURL]?.mode = .notPlaying - } - } + await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { + $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.2) + } + await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { + $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.4) + } + await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { + $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.6) + } + await store.receive(\.voiceMemos[id: deadbeefURL].timerUpdated) { + $0.voiceMemos[id: deadbeefURL]?.mode = .playing(progress: 0.8) + } + await store.receive(\.voiceMemos[id: deadbeefURL].audioPlayerClient.success) { + $0.voiceMemos[id: deadbeefURL]?.mode = .notPlaying + } + } - @MainActor - func testRecordMemoHappyPath() async throws { - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.audioRecorder.stopRecording = { - didFinish.continuation.yield(true) - didFinish.continuation.finish() - } - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.continuousClock = clock - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) - } + @MainActor + func testRecordMemoHappyPath() async throws { + let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) + let clock = TestClock() + let store = TestStore(initialState: VoiceMemos.State()) { + VoiceMemos() + } withDependencies: { + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { @Sendable _ in + try await didFinish.stream.first { _ in true }! + } + $0.audioRecorder.stopRecording = { + didFinish.continuation.yield(true) + didFinish.continuation.finish() + } + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.continuousClock = clock + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!) + } - await store.send(.recordButtonTapped) - await clock.advance() - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") - ) - } - let recordingMemoTask = await store.send(\.recordingMemo.onTask) - await clock.advance(by: .seconds(1)) - await store.receive(\.recordingMemo.timerUpdated) { - $0.recordingMemo?.duration = 1 - } - await clock.advance(by: .seconds(1)) - await store.receive(\.recordingMemo.timerUpdated) { - $0.recordingMemo?.duration = 2 - } - await clock.advance(by: .milliseconds(500)) - await store.send(\.recordingMemo.stopButtonTapped) { - $0.recordingMemo?.mode = .encoding - } - await store.receive(\.recordingMemo.finalRecordingTime) { - $0.recordingMemo?.duration = 2.5 - } - await store.receive(\.recordingMemo.audioRecorderDidFinish.success) - await store.receive(\.recordingMemo.delegate.didFinish.success) { - $0.recordingMemo = nil - $0.voiceMemos = [ - VoiceMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - duration: 2.5, - mode: .notPlaying, - title: "", - url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") - ) - ] - } - await recordingMemoTask.cancel() - } + await store.send(.recordButtonTapped) + await clock.advance() + await store.receive(\.recordPermissionResponse) { + $0.audioRecorderPermission = .allowed + $0.recordingMemo = RecordingMemo.State( + date: Date(timeIntervalSinceReferenceDate: 0), + mode: .recording, + url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") + ) + } + let recordingMemoTask = await store.send(\.recordingMemo.onTask) + await clock.advance(by: .seconds(1)) + await store.receive(\.recordingMemo.timerUpdated) { + $0.recordingMemo?.duration = 1 + } + await clock.advance(by: .seconds(1)) + await store.receive(\.recordingMemo.timerUpdated) { + $0.recordingMemo?.duration = 2 + } + await clock.advance(by: .milliseconds(500)) + await store.send(\.recordingMemo.stopButtonTapped) { + $0.recordingMemo?.mode = .encoding + } + await store.receive(\.recordingMemo.finalRecordingTime) { + $0.recordingMemo?.duration = 2.5 + } + await store.receive(\.recordingMemo.audioRecorderDidFinish.success) + await store.receive(\.recordingMemo.delegate.didFinish.success) { + $0.recordingMemo = nil + $0.voiceMemos = [ + VoiceMemo.State( + date: Date(timeIntervalSinceReferenceDate: 0), + duration: 2.5, + mode: .notPlaying, + title: "", + url: URL(fileURLWithPath: "/tmp/DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.m4a") + ), + ] + } + await recordingMemoTask.cancel() + } - @MainActor - func testPermissionDenied() async { - var didOpenSettings = false - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.requestRecordPermission = { false } - $0.openSettings = { @MainActor in didOpenSettings = true } - } + @MainActor + func testPermissionDenied() async { + var didOpenSettings = false + let store = TestStore(initialState: VoiceMemos.State()) { + VoiceMemos() + } withDependencies: { + $0.audioRecorder.requestRecordPermission = { false } + $0.openSettings = { @MainActor in didOpenSettings = true } + } - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.alert = AlertState { TextState("Permission is required to record voice memos.") } - $0.audioRecorderPermission = .denied - } - await store.send(\.alert.dismiss) { - $0.alert = nil - } - await store.send(.openSettingsButtonTapped).finish() - XCTAssert(didOpenSettings) - } + await store.send(.recordButtonTapped) + await store.receive(\.recordPermissionResponse) { + $0.alert = AlertState { TextState("Permission is required to record voice memos.") } + $0.audioRecorderPermission = .denied + } + await store.send(\.alert.dismiss) { + $0.alert = nil + } + await store.send(.openSettingsButtonTapped).finish() + XCTAssert(didOpenSettings) + } - @MainActor - func testRecordMemoFailure() async { - struct SomeError: Error, Equatable {} - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.continuousClock = clock - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } + @MainActor + func testRecordMemoFailure() async { + struct SomeError: Error, Equatable {} + let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) + let clock = TestClock() + let store = TestStore(initialState: VoiceMemos.State()) { + VoiceMemos() + } withDependencies: { + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { @Sendable _ in + try await didFinish.stream.first { _ in true }! + } + $0.continuousClock = clock + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(deadbeefID) + } - await store.send(.recordButtonTapped) - await store.receive(\.recordPermissionResponse) { - $0.audioRecorderPermission = .allowed - $0.recordingMemo = RecordingMemo.State( - date: Date(timeIntervalSinceReferenceDate: 0), - mode: .recording, - url: deadbeefURL - ) - } - await store.send(\.recordingMemo.onTask) + await store.send(.recordButtonTapped) + await store.receive(\.recordPermissionResponse) { + $0.audioRecorderPermission = .allowed + $0.recordingMemo = RecordingMemo.State( + date: Date(timeIntervalSinceReferenceDate: 0), + mode: .recording, + url: deadbeefURL + ) + } + await store.send(\.recordingMemo.onTask) - didFinish.continuation.finish(throwing: SomeError()) - await store.receive(\.recordingMemo.audioRecorderDidFinish.failure) - await store.receive(\.recordingMemo.delegate.didFinish.failure) { - $0.alert = AlertState { TextState("Voice memo recording failed.") } - $0.recordingMemo = nil - } - } + didFinish.continuation.finish(throwing: SomeError()) + await store.receive(\.recordingMemo.audioRecorderDidFinish.failure) + await store.receive(\.recordingMemo.delegate.didFinish.failure) { + $0.alert = AlertState { TextState("Voice memo recording failed.") } + $0.recordingMemo = nil + } + } - // Demonstration of how to write a non-exhaustive test for recording a memo and it failing to - // record. - @MainActor - func testRecordMemoFailure_NonExhaustive() async { - struct SomeError: Error, Equatable {} - let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) - let clock = TestClock() - let store = TestStore(initialState: VoiceMemos.State()) { - VoiceMemos() - } withDependencies: { - $0.audioRecorder.currentTime = { 2.5 } - $0.audioRecorder.requestRecordPermission = { true } - $0.audioRecorder.startRecording = { @Sendable _ in - try await didFinish.stream.first { _ in true }! - } - $0.continuousClock = clock - $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) - $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } - $0.uuid = .constant(deadbeefID) - } - store.exhaustivity = .off(showSkippedAssertions: true) + /// Demonstration of how to write a non-exhaustive test for recording a memo and it failing to + /// record. + @MainActor + func testRecordMemoFailure_NonExhaustive() async { + struct SomeError: Error, Equatable {} + let didFinish = AsyncThrowingStream.makeStream(of: Bool.self) + let clock = TestClock() + let store = TestStore(initialState: VoiceMemos.State()) { + VoiceMemos() + } withDependencies: { + $0.audioRecorder.currentTime = { 2.5 } + $0.audioRecorder.requestRecordPermission = { true } + $0.audioRecorder.startRecording = { @Sendable _ in + try await didFinish.stream.first { _ in true }! + } + $0.continuousClock = clock + $0.date = .constant(Date(timeIntervalSinceReferenceDate: 0)) + $0.temporaryDirectory = { URL(fileURLWithPath: "/tmp") } + $0.uuid = .constant(deadbeefID) + } + store.exhaustivity = .off(showSkippedAssertions: true) - await store.send(.recordButtonTapped) - await store.send(\.recordingMemo.onTask) - didFinish.continuation.finish(throwing: SomeError()) - await store.receive(\.recordingMemo.delegate.didFinish.failure) { - $0.alert = AlertState { TextState("Voice memo recording failed.") } - $0.recordingMemo = nil - } - } + await store.send(.recordButtonTapped) + await store.send(\.recordingMemo.onTask) + didFinish.continuation.finish(throwing: SomeError()) + await store.receive(\.recordingMemo.delegate.didFinish.failure) { + $0.alert = AlertState { TextState("Voice memo recording failed.") } + $0.recordingMemo = nil + } + } - @MainActor - func testPlayMemoHappyPath() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 1.25, - mode: .notPlaying, - title: "", - url: url - ) - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in - try await clock.sleep(for: .milliseconds(1_250)) - return true - } - $0.continuousClock = clock - } + @MainActor + func testPlayMemoHappyPath() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") + let clock = TestClock() + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 1.25, + mode: .notPlaying, + title: "", + url: url + ), + ] + ) + ) { + VoiceMemos() + } withDependencies: { + $0.audioPlayer.play = { @Sendable _ in + try await clock.sleep(for: .milliseconds(1250)) + return true + } + $0.continuousClock = clock + } - await store.send(\.voiceMemos[id:url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id:url].delegate.playbackStarted) - await clock.advance(by: .milliseconds(500)) - await store.receive(\.voiceMemos[id:url].timerUpdated) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0.4) - } - await clock.advance(by: .milliseconds(500)) - await store.receive(\.voiceMemos[id:url].timerUpdated) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0.8) - } - await clock.advance(by: .milliseconds(250)) - await store.receive(\.voiceMemos[id:url].audioPlayerClient.success) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - } + await store.send(\.voiceMemos[id: url].playButtonTapped) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0) + } + await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) + await clock.advance(by: .milliseconds(500)) + await store.receive(\.voiceMemos[id: url].timerUpdated) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0.4) + } + await clock.advance(by: .milliseconds(500)) + await store.receive(\.voiceMemos[id: url].timerUpdated) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0.8) + } + await clock.advance(by: .milliseconds(250)) + await store.receive(\.voiceMemos[id: url].audioPlayerClient.success) { + $0.voiceMemos[id: url]?.mode = .notPlaying + } + } - @MainActor - func testPlayMemoFailure() async { - struct SomeError: Error, Equatable {} + @MainActor + func testPlayMemoFailure() async { + struct SomeError: Error, Equatable {} - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .notPlaying, - title: "", - url: url - ) - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in throw SomeError() } - $0.continuousClock = clock - } + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") + let clock = TestClock() + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 30, + mode: .notPlaying, + title: "", + url: url + ), + ] + ) + ) { + VoiceMemos() + } withDependencies: { + $0.audioPlayer.play = { @Sendable _ in throw SomeError() } + $0.continuousClock = clock + } - let task = await store.send(\.voiceMemos[id:url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id:url].delegate.playbackStarted) - await store.receive(\.voiceMemos[id:url].audioPlayerClient.failure) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - await store.receive(\.voiceMemos[id:url].delegate.playbackFailed) { - $0.alert = AlertState { TextState("Voice memo playback failed.") } - } - await task.cancel() - } + let task = await store.send(\.voiceMemos[id: url].playButtonTapped) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0) + } + await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) + await store.receive(\.voiceMemos[id: url].audioPlayerClient.failure) { + $0.voiceMemos[id: url]?.mode = .notPlaying + } + await store.receive(\.voiceMemos[id: url].delegate.playbackFailed) { + $0.alert = AlertState { TextState("Voice memo playback failed.") } + } + await task.cancel() + } - @MainActor - func testStopMemo() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .playing(progress: 0.3), - title: "", - url: url - ) - ] - ) - ) { - VoiceMemos() - } + @MainActor + func testStopMemo() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 30, + mode: .playing(progress: 0.3), + title: "", + url: url + ), + ] + ) + ) { + VoiceMemos() + } - await store.send(\.voiceMemos[id:url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .notPlaying - } - } + await store.send(\.voiceMemos[id: url].playButtonTapped) { + $0.voiceMemos[id: url]?.mode = .notPlaying + } + } - @MainActor - func testDeleteMemo() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 30, - mode: .playing(progress: 0.3), - title: "", - url: url - ) - ] - ) - ) { - VoiceMemos() - } + @MainActor + func testDeleteMemo() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 30, + mode: .playing(progress: 0.3), + title: "", + url: url + ), + ] + ) + ) { + VoiceMemos() + } - await store.send(.onDelete([0])) { - $0.voiceMemos = [] - } - } + await store.send(.onDelete([0])) { + $0.voiceMemos = [] + } + } - @MainActor - func testDeleteMemos() async { - let date = Date() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 1", - url: URL(fileURLWithPath: "pointfreeco/1.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 2", - url: URL(fileURLWithPath: "pointfreeco/2.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 3", - url: URL(fileURLWithPath: "pointfreeco/3.m4a") - ), - ] - ) - ) { - VoiceMemos() - } + @MainActor + func testDeleteMemos() async { + let date = Date() + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: date, + duration: 30, + mode: .playing(progress: 0.3), + title: "Episode 1", + url: URL(fileURLWithPath: "pointfreeco/1.m4a") + ), + VoiceMemo.State( + date: date, + duration: 30, + mode: .playing(progress: 0.3), + title: "Episode 2", + url: URL(fileURLWithPath: "pointfreeco/2.m4a") + ), + VoiceMemo.State( + date: date, + duration: 30, + mode: .playing(progress: 0.3), + title: "Episode 3", + url: URL(fileURLWithPath: "pointfreeco/3.m4a") + ), + ] + ) + ) { + VoiceMemos() + } - await store.send(.onDelete([1])) { - $0.voiceMemos = [ - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 1", - url: URL(fileURLWithPath: "pointfreeco/1.m4a") - ), - VoiceMemo.State( - date: date, - duration: 30, - mode: .playing(progress: 0.3), - title: "Episode 3", - url: URL(fileURLWithPath: "pointfreeco/3.m4a") - ), - ] - } - } + await store.send(.onDelete([1])) { + $0.voiceMemos = [ + VoiceMemo.State( + date: date, + duration: 30, + mode: .playing(progress: 0.3), + title: "Episode 1", + url: URL(fileURLWithPath: "pointfreeco/1.m4a") + ), + VoiceMemo.State( + date: date, + duration: 30, + mode: .playing(progress: 0.3), + title: "Episode 3", + url: URL(fileURLWithPath: "pointfreeco/3.m4a") + ), + ] + } + } - @MainActor - func testDeleteMemoWhilePlaying() async { - let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") - let clock = TestClock() - let store = TestStore( - initialState: VoiceMemos.State( - voiceMemos: [ - VoiceMemo.State( - date: Date(), - duration: 10, - mode: .notPlaying, - title: "", - url: url - ) - ] - ) - ) { - VoiceMemos() - } withDependencies: { - $0.audioPlayer.play = { @Sendable _ in try await Task.never() } - $0.continuousClock = clock - } + @MainActor + func testDeleteMemoWhilePlaying() async { + let url = URL(fileURLWithPath: "pointfreeco/functions.m4a") + let clock = TestClock() + let store = TestStore( + initialState: VoiceMemos.State( + voiceMemos: [ + VoiceMemo.State( + date: Date(), + duration: 10, + mode: .notPlaying, + title: "", + url: url + ), + ] + ) + ) { + VoiceMemos() + } withDependencies: { + $0.audioPlayer.play = { @Sendable _ in try await Task.never() } + $0.continuousClock = clock + } - await store.send(\.voiceMemos[id:url].playButtonTapped) { - $0.voiceMemos[id: url]?.mode = .playing(progress: 0) - } - await store.receive(\.voiceMemos[id:url].delegate.playbackStarted) - await store.send(.onDelete([0])) { - $0.voiceMemos = [] - } - await store.finish() - } + await store.send(\.voiceMemos[id: url].playButtonTapped) { + $0.voiceMemos[id: url]?.mode = .playing(progress: 0) + } + await store.receive(\.voiceMemos[id: url].delegate.playbackStarted) + await store.send(.onDelete([0])) { + $0.voiceMemos = [] + } + await store.finish() + } } diff --git a/README.md b/README.md index 968bfb9..bc5d0ef 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.25.0") + .package(url: "https://github.com/dankinsoid/VDStore.git", from: "0.26.0") ], targets: [ .target(name: "SomeProject", dependencies: ["VDStore"]) diff --git a/Sources/VDStore/Dependencies/CancellableStorage.swift b/Sources/VDStore/Dependencies/CancellableStorage.swift index b68e00b..de0d8b2 100644 --- a/Sources/VDStore/Dependencies/CancellableStorage.swift +++ b/Sources/VDStore/Dependencies/CancellableStorage.swift @@ -3,8 +3,8 @@ import Combine extension StoreDIValues { var cancellableStorage: CancellableStorage { - get { self[\.cancellableStorage] ?? .shared } - set { self[\.cancellableStorage] = newValue } + get { get(\.cancellableStorage, or: .shared) } + set { set(\.cancellableStorage, newValue) } } /// Stores cancellables for Combine subscriptions. diff --git a/Sources/VDStore/Dependencies/TasksStorage.swift b/Sources/VDStore/Dependencies/TasksStorage.swift index eae2080..c811359 100644 --- a/Sources/VDStore/Dependencies/TasksStorage.swift +++ b/Sources/VDStore/Dependencies/TasksStorage.swift @@ -4,8 +4,8 @@ public extension StoreDIValues { /// Returns the storage of async tasks. Allows to store and cancel tasks. var tasksStorage: TasksStorage { - get { self[\.tasksStorage] ?? .shared } - set { self[\.tasksStorage] = newValue } + get { get(\.tasksStorage, or: .shared) } + set { set(\.tasksStorage, newValue) } } } @@ -61,7 +61,10 @@ public extension Store { id: AnyHashable, _ task: @escaping @Sendable () async throws -> T ) -> Task { - Task(operation: task).store(in: di.tasksStorage, id: id) + Task { + try await withDIValues(operation: task) + } + .store(in: di.tasksStorage, id: id) } /// Create a task with cancellation id. @@ -94,10 +97,10 @@ public extension Store { public extension Task { /// Store the task in the storage by it cancellation id. - @MainActor + @MainActor @discardableResult func store(in storage: TasksStorage, id: AnyHashable) -> Task { - storage.add(for: id, self) + storage.add(for: id, self) return self } } diff --git a/Sources/VDStore/Dependencies/UUID.swift b/Sources/VDStore/Dependencies/UUID.swift index bc71568..2749e8c 100644 --- a/Sources/VDStore/Dependencies/UUID.swift +++ b/Sources/VDStore/Dependencies/UUID.swift @@ -1,116 +1,116 @@ import Foundation -extension StoreDIValues { +public extension StoreDIValues { - /// A dependency that generates UUIDs. - /// - /// Introduce controllable UUID generation to your features by using the ``di`` property - /// with a key path to this property. The wrapped value is an instance of - /// ``UUIDGenerator``, which can be called with a closure to create UUIDs. (It can be called - /// directly because it defines ``UUIDGenerator/callAsFunction()``, which is called when you - /// invoke the instance as you would invoke a function.) - /// - /// For example, you could introduce controllable UUID generation to an observable object model - /// that creates to-dos with unique identifiers: - /// - /// ```swift - /// extension Store { - /// - /// func addButtonTapped() { - /// state.todos.append(Todo(id: di.uuid())) - /// } - /// } - /// ``` - /// - /// By default, a "live" generator is supplied, which returns a random UUID when called by - /// invoking `UUID.init` under the hood. When used in tests, an "unimplemented" generator that - /// additionally reports test failures if invoked, unless explicitly overridden. - /// - /// To test a feature that depends on UUID generation, you can override its generator using - /// ``di(_:_:)-4uz6m`` to override the underlying ``UUIDGenerator``: - /// - /// * ``UUIDGenerator/incrementing`` for reproducible UUIDs that count up from - /// `00000000-0000-0000-0000-000000000000`. - /// - /// * ``UUIDGenerator/constant(_:)`` for a generator that always returns the given UUID. - /// - /// For example, you could test the to-do-creating model by supplying an - /// ``UUIDGenerator/incrementing`` generator as a dependency: - /// - /// ```swift - /// func testFeature() { - /// let model = store.di(\.uuid, .incrementing) - /// - /// model.addButtonTapped() - /// XCTAssertEqual( - /// model.state.todos, - /// [Todo(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)] - /// ) - /// } - /// ``` - public var uuid: UUIDGenerator { - get { self[\.uuid] ?? UUIDGenerator { UUID() } } - set { self[\.uuid] = newValue } - } + /// A dependency that generates UUIDs. + /// + /// Introduce controllable UUID generation to your features by using the ``di`` property + /// with a key path to this property. The wrapped value is an instance of + /// ``UUIDGenerator``, which can be called with a closure to create UUIDs. (It can be called + /// directly because it defines ``UUIDGenerator/callAsFunction()``, which is called when you + /// invoke the instance as you would invoke a function.) + /// + /// For example, you could introduce controllable UUID generation to an observable object model + /// that creates to-dos with unique identifiers: + /// + /// ```swift + /// extension Store { + /// + /// func addButtonTapped() { + /// state.todos.append(Todo(id: di.uuid())) + /// } + /// } + /// ``` + /// + /// By default, a "live" generator is supplied, which returns a random UUID when called by + /// invoking `UUID.init` under the hood. When used in tests, an "unimplemented" generator that + /// additionally reports test failures if invoked, unless explicitly overridden. + /// + /// To test a feature that depends on UUID generation, you can override its generator using + /// ``di(_:_:)-4uz6m`` to override the underlying ``UUIDGenerator``: + /// + /// * ``UUIDGenerator/incrementing`` for reproducible UUIDs that count up from + /// `00000000-0000-0000-0000-000000000000`. + /// + /// * ``UUIDGenerator/constant(_:)`` for a generator that always returns the given UUID. + /// + /// For example, you could test the to-do-creating model by supplying an + /// ``UUIDGenerator/incrementing`` generator as a dependency: + /// + /// ```swift + /// func testFeature() { + /// let model = store.di(\.uuid, .incrementing) + /// + /// model.addButtonTapped() + /// XCTAssertEqual( + /// model.state.todos, + /// [Todo(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)] + /// ) + /// } + /// ``` + var uuid: UUIDGenerator { + get { get(\.uuid, or: UUIDGenerator { UUID() }) } + set { set(\.uuid, newValue) } + } } /// A dependency that generates a UUID. /// /// See ``StoreDIValues/uuid`` for more information. public struct UUIDGenerator: Sendable { - private let generate: @Sendable () -> UUID - - /// A generator that returns a constant UUID. - /// - /// - Parameter uuid: A UUID to return. - /// - Returns: A generator that always returns the given UUID. - public static func constant(_ uuid: UUID) -> Self { - Self { uuid } - } - - /// A generator that generates UUIDs in incrementing order. - /// - /// For example: - /// - /// ```swift - /// let generate = UUIDGenerator.incrementing - /// generate() // UUID(00000000-0000-0000-0000-000000000000) - /// generate() // UUID(00000000-0000-0000-0000-000000000001) - /// generate() // UUID(00000000-0000-0000-0000-000000000002) - /// ``` - public static var incrementing: Self { - let generator = IncrementingUUIDGenerator() - return Self { generator() } - } - - /// Initializes a UUID generator that generates a UUID from a closure. - /// - /// - Parameter generate: A closure that returns the current date when called. - public init(_ generate: @escaping @Sendable () -> UUID) { - self.generate = generate - } - - public func callAsFunction() -> UUID { - self.generate() - } + private let generate: @Sendable () -> UUID + + /// A generator that returns a constant UUID. + /// + /// - Parameter uuid: A UUID to return. + /// - Returns: A generator that always returns the given UUID. + public static func constant(_ uuid: UUID) -> Self { + Self { uuid } + } + + /// A generator that generates UUIDs in incrementing order. + /// + /// For example: + /// + /// ```swift + /// let generate = UUIDGenerator.incrementing + /// generate() // UUID(00000000-0000-0000-0000-000000000000) + /// generate() // UUID(00000000-0000-0000-0000-000000000001) + /// generate() // UUID(00000000-0000-0000-0000-000000000002) + /// ``` + public static var incrementing: Self { + let generator = IncrementingUUIDGenerator() + return Self { generator() } + } + + /// Initializes a UUID generator that generates a UUID from a closure. + /// + /// - Parameter generate: A closure that returns the current date when called. + public init(_ generate: @escaping @Sendable () -> UUID) { + self.generate = generate + } + + public func callAsFunction() -> UUID { + generate() + } } -extension UUID { - public init(_ intValue: Int) { - self.init(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", intValue))")! - } +public extension UUID { + init(_ intValue: Int) { + self.init(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", intValue))")! + } } private final class IncrementingUUIDGenerator: @unchecked Sendable { - private let lock = NSLock() - private var sequence = 0 - - func callAsFunction() -> UUID { - self.lock.lock() - defer { - self.sequence += 1 - self.lock.unlock() - } - return UUID(self.sequence) - } + private let lock = NSLock() + private var sequence = 0 + + func callAsFunction() -> UUID { + lock.lock() + defer { + self.sequence += 1 + self.lock.unlock() + } + return UUID(sequence) + } } diff --git a/Sources/VDStore/Middleware.swift b/Sources/VDStore/Middleware.swift index 47e641a..37a0841 100644 --- a/Sources/VDStore/Middleware.swift +++ b/Sources/VDStore/Middleware.swift @@ -36,8 +36,8 @@ public extension StoreDIValues { extension StoreDIValues { var middlewares: Middlewares { - get { self[\.middlewares] ?? Middlewares() } - set { self[\.middlewares] = newValue } + get { get(\.middlewares, or: Middlewares()) } + set { set(\.middlewares, newValue) } } } diff --git a/Sources/VDStore/Store.swift b/Sources/VDStore/Store.swift index 9e90cea..baefdb6 100644 --- a/Sources/VDStore/Store.swift +++ b/Sources/VDStore/Store.swift @@ -96,7 +96,7 @@ public struct Store: Sendable { /// Injected dependencies. public nonisolated var di: StoreDIValues { - diModifier(StoreDIValues().with(store: self)) + diModifier(StoreDIValues.current.with(store: self)) } /// A publisher that emits when state changes. @@ -108,27 +108,30 @@ public struct Store: Sendable { /// .sink { ... } /// ``` public nonisolated var publisher: StorePublisher { - StorePublisher(upstream: box.eraseToAnyPublisher()) + StorePublisher(upstream: withDI(box)) } - /// An async sequence that emits when state changes. - /// - /// This sequence supports dynamic member lookup so that you can pluck out a specific field in the state: - /// - /// ```swift - /// for await state in store.async.alert { ... } - /// ``` - public nonisolated var async: StoreAsyncSequence { - StoreAsyncSequence(upstream: box.eraseToAnyPublisher()) - } + /// An async sequence that emits when state changes. + /// + /// This sequence supports dynamic member lookup so that you can pluck out a specific field in the state: + /// + /// ```swift + /// for await state in store.async.alert { ... } + /// ``` + public nonisolated var async: StoreAsyncSequence { + StoreAsyncSequence(upstream: withDI(box)) + } /// The publisher that emits before the state is going to be changed. Required by `SwiftUI`. - nonisolated var willSet: AnyPublisher { - box.willSet.eraseToAnyPublisher() + nonisolated var willSet: AnyPublisher { + withDI(box.willSet) } private let box: StoreBox - private let diModifier: @Sendable (StoreDIValues) -> StoreDIValues + private let _diModifier: @Sendable (StoreDIValues) -> StoreDIValues + private nonisolated var diModifier: @Sendable (StoreDIValues) -> StoreDIValues { + { _diModifier($0.with(store: self)) } + } public var wrappedValue: State { get { state } @@ -150,12 +153,12 @@ public struct Store: Sendable { self.init(box: StoreBox(state)) } - nonisolated init( + nonisolated init( box: StoreBox, di: @escaping @Sendable (StoreDIValues) -> StoreDIValues = { $0 } ) { self.box = box - diModifier = di + _diModifier = di } /// Scopes the store to one that exposes child state. @@ -196,7 +199,7 @@ public struct Store: Sendable { ) -> Store { Store( box: StoreBox(parent: box, get: getter, set: setter), - di: { [self] in diModifier($0).with(store: self) } + di: { [self] in diModifier($0) } ) } @@ -277,7 +280,7 @@ public struct Store: Sendable { _ keyPath: WritableKeyPath, _ value: DIValue ) -> Store { - di { + di { $0.with(keyPath, value) } } @@ -289,8 +292,8 @@ public struct Store: Sendable { public nonisolated func di( _ transform: @escaping (StoreDIValues) -> StoreDIValues ) -> Store { - Store(box: box) { [diModifier] in - transform(diModifier($0)) + Store(box: box) { [_diModifier] in + transform(_diModifier($0)) } } @@ -301,7 +304,7 @@ public struct Store: Sendable { public nonisolated func transformDI( _ transform: @escaping (inout StoreDIValues) -> Void ) -> Store { - di { + di { var result = $0 transform(&result) return result @@ -310,20 +313,31 @@ public struct Store: Sendable { /// Suspends the store from updating the UI until the block returns. public func update(_ update: @MainActor () throws -> T) rethrows -> T { - box.startUpdate() + box.startUpdate() defer { box.endUpdate() } - let result = try update() - return result + return try withDIValues(operation: update) + } + + public nonisolated func withDIValues(operation: () throws -> T) rethrows -> T { + try StoreDIValues.$current.withValue(diModifier, operation: operation) + } + + public nonisolated func withDIValues(operation: () async throws -> T) async rethrows -> T { + try await StoreDIValues.$current.withValue(diModifier, operation: operation) + } + + func forceUpdateIfNeeded() { + box.forceUpdate() } } public extension Store where State: MutableCollection { - nonisolated subscript(_ index: State.Index) -> Store { + nonisolated subscript(_ index: State.Index) -> Store { scope(index) } - nonisolated func scope(_ index: State.Index) -> Store { + nonisolated func scope(_ index: State.Index) -> Store { scope { $0[index] } set: { @@ -337,8 +351,8 @@ public var suspendAllSyncStoreUpdates = true public extension StoreDIValues { private var stores: [ObjectIdentifier: Any] { - get { self[\.stores] ?? [:] } - set { self[\.stores] = newValue } + get { get(\.stores, or: [:]) } + set { set(\.stores, newValue) } } /// Injected store with the given state type. @@ -356,6 +370,12 @@ public extension StoreDIValues { } } -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -extension Store: Observable { +private extension Store { + + nonisolated func withDI(_ publisher: P) -> AnyPublisher { + DIPublisher(base: publisher, modifier: diModifier).eraseToAnyPublisher() + } } + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +extension Store: Observable {} diff --git a/Sources/VDStore/StoreDIValues.swift b/Sources/VDStore/StoreDIValues.swift new file mode 100644 index 0000000..9c97ac6 --- /dev/null +++ b/Sources/VDStore/StoreDIValues.swift @@ -0,0 +1,162 @@ +import Foundation + +/// The storage of injected dependencies. +public struct StoreDIValues { + + @TaskLocal + public static var current = StoreDIValues() + + typealias Key = PartialKeyPath + + private let storage = Storage() + private var dependencies: [Key: Any] = [:] + + /// Creates an empty storage. + public init() {} + + /// Returns the stored dependency by its key path. + public func get( + _ keyPath: WritableKeyPath, + or value: @autoclosure () -> DIValue + ) -> DIValue { + (dependencies[keyPath] as? DIValue) ?? storage.value(for: keyPath, default: value()) + } + + /// Modify the stored dependency by its key path. + public mutating func set( + _ keyPath: WritableKeyPath, + _ value: DIValue + ) { + dependencies[keyPath] = value + } + + /// Injects the given value into the storage. + /// - Parameters: + /// - keyPath: A key path to the value in the storage. + /// - value: The value to inject. + /// - Returns: A new storage with the injected value. + public func with( + _ keyPath: WritableKeyPath, + _ value: DIValue + ) -> StoreDIValues { + var new = self + new[keyPath: keyPath] = value + return new + } + + /// Transforms the storage's injected dependencies. + /// - Parameters: + /// - keyPath: A key path to the value in the storage. + /// - transform: A closure that transforms the value. + /// - Returns: A new storage with the transformed value. + public func transform( + _ keyPath: WritableKeyPath, + _ transform: (inout DIValue) -> Void + ) -> StoreDIValues { + var value = self[keyPath: keyPath] + transform(&value) + return with(keyPath, value) + } + + /// Merges the storage's injected dependencies with the given ones. + /// - Parameters: + /// - dependencies: The dependencies to merge with. + /// - Returns: A new storage with the merged dependencies. + /// - Note: The given dependencies have higher priority than the stored ones. + public func merging(with dependencies: StoreDIValues) -> StoreDIValues { + var new = self + new.dependencies.merge(dependencies.dependencies) { _, new in new } + return new + } +} + +extension StoreDIValues { + + final class Storage { + private var cache: [Key: Any] = [:] + private let lock = NSRecursiveLock() + + func value(for key: Key, default: @autoclosure () -> DIValue) -> DIValue { + lock.lock() + defer { lock.unlock() } + if let value = cache[key] as? DIValue { + return value + } + let value = `default`() + cache[key] = value + return value + } + } +} + +public extension TaskLocal { + + func withValue(_ value: (StoreDIValues) -> StoreDIValues, operation: () throws -> Result) rethrows -> Result { + try withValue(value(wrappedValue), operation: operation) + } + + func withValue(_ value: (StoreDIValues) -> StoreDIValues, operation: () async throws -> Result) async rethrows -> Result { + try await withValue(value(wrappedValue), operation: operation) + } +} + +/// Returns the value for the current environment. +/// - Parameters: +/// - live: The value is return when a `preview` or `test` environment is not detected. +/// - test: The value is return when running code from an XCTestCase. If missed `live` value is used. +/// - preview: The value is return when running code from an Xcode preview. If missed `test` value is used. +public func valueFor( + live: @autoclosure () -> Value, + test: @autoclosure () -> Value? = nil, + preview: @autoclosure () -> Value? = nil +) -> Value { + #if DEBUG + if _isPreview { + return preview() ?? test() ?? live() + } else if _XCTIsTesting { + return test() ?? preview() ?? live() + } else { + return live() + } + #else + return live() + #endif +} + +public let _isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + +#if !os(WASI) +public let _XCTIsTesting: Bool = ProcessInfo.processInfo.environment.keys.contains("XCTestBundlePath") + || ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath") + || ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier") + || (ProcessInfo.processInfo.arguments.first + .flatMap(URL.init(fileURLWithPath:)) + .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" } + ?? false) + || XCTCurrentTestCase != nil +#else +public let _XCTIsTesting = false +#endif + +#if canImport(ObjectiveC) +private var XCTCurrentTestCase: AnyObject? { + guard + let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), + let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, + let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? + .takeUnretainedValue(), + let observers = shared.perform(Selector(("observers")))? + .takeUnretainedValue() as? [AnyObject], + let observer = + observers + .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), + let currentTestCase = observer.perform(Selector(("currentTestCase")))? + .takeUnretainedValue() + else { return nil } + return currentTestCase +} +#else +private var XCTCurrentTestCase: AnyObject? { + nil +} +#endif diff --git a/Sources/VDStore/StoreDependencies.swift b/Sources/VDStore/StoreDependencies.swift deleted file mode 100644 index e726ab9..0000000 --- a/Sources/VDStore/StoreDependencies.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation - -/// The storage of injected dependencies. -public struct StoreDIValues { - - private var dependencies: [PartialKeyPath: Any] = [:] - - /// Creates an empty storage. - public init() {} - - /// Returns or modify the stored dependency by its key path. - public subscript(_ keyPath: WritableKeyPath) -> DIValue? { - get { - dependencies[keyPath] as? DIValue - } - set { - dependencies[keyPath] = newValue - } - } - - /// Injects the given value into the storage. - /// - Parameters: - /// - keyPath: A key path to the value in the storage. - /// - value: The value to inject. - /// - Returns: A new storage with the injected value. - public func with( - _ keyPath: WritableKeyPath, - _ value: DIValue - ) -> StoreDIValues { - var new = self - new[keyPath: keyPath] = value - return new - } - - /// Transforms the storage's injected dependencies. - /// - Parameters: - /// - keyPath: A key path to the value in the storage. - /// - transform: A closure that transforms the value. - /// - Returns: A new storage with the transformed value. - public func transform( - _ keyPath: WritableKeyPath, - _ transform: (inout DIValue) -> Void - ) -> StoreDIValues { - var value = self[keyPath: keyPath] - transform(&value) - return with(keyPath, value) - } - - /// Merges the storage's injected dependencies with the given ones. - /// - Parameters: - /// - dependencies: The dependencies to merge with. - /// - Returns: A new storage with the merged dependencies. - /// - Note: The given dependencies have higher priority than the stored ones. - public func merging(with dependencies: StoreDIValues) -> StoreDIValues { - var new = self - new.dependencies.merge(dependencies.dependencies) { _, new in new } - return new - } -} - -/// Returns the value for the current environment. -/// - Parameters: -/// - live: The value is return when a `preview` or `test` environment is not detected. -/// - test: The value is return when running code from an XCTestCase. If missed `live` value is used. -/// - preview: The value is return when running code from an Xcode preview. If missed `test` value is used. -public func valueFor( - live: @autoclosure () -> Value, - test: @autoclosure () -> Value? = nil, - preview: @autoclosure () -> Value? = nil -) -> Value { - #if DEBUG - if _isPreview { - return preview() ?? test() ?? live() - } else if _XCTIsTesting { - return test() ?? preview() ?? live() - } else { - return live() - } - #else - return live() - #endif -} - -public let _isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - -#if !os(WASI) -public let _XCTIsTesting: Bool = { - ProcessInfo.processInfo.environment.keys.contains("XCTestBundlePath") - || ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath") - || ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier") - || (ProcessInfo.processInfo.arguments.first - .flatMap(URL.init(fileURLWithPath:)) - .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" } - ?? false) - || XCTCurrentTestCase != nil -}() -#else -public let _XCTIsTesting = false -#endif - -#if canImport(ObjectiveC) -private var XCTCurrentTestCase: AnyObject? { - guard - let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), - let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, - let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? - .takeUnretainedValue(), - let observers = shared.perform(Selector(("observers")))? - .takeUnretainedValue() as? [AnyObject], - let observer = - observers - .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), - let currentTestCase = observer.perform(Selector(("currentTestCase")))? - .takeUnretainedValue() - else { return nil } - return currentTestCase -} -#else -private var XCTCurrentTestCase: AnyObject? { - nil -} -#endif diff --git a/Sources/VDStore/StoreExtensions/ForEach.swift b/Sources/VDStore/StoreExtensions/ForEach.swift index 4be4095..0963986 100644 --- a/Sources/VDStore/StoreExtensions/ForEach.swift +++ b/Sources/VDStore/StoreExtensions/ForEach.swift @@ -1,18 +1,18 @@ import Foundation public extension Store where State: MutableCollection { - - @MainActor - func forEach(_ operation: (Store) throws -> Void) rethrows { - for index in state.indices { - try operation(self[index]) - } - } - - @MainActor - func forEach(_ operation: (Store) async throws -> Void) async rethrows { - for index in state.indices { - try await operation(self[index]) - } - } + + @MainActor + func forEach(_ operation: (Store) throws -> Void) rethrows { + for index in state.indices { + try operation(self[index]) + } + } + + @MainActor + func forEach(_ operation: (Store) async throws -> Void) async rethrows { + for index in state.indices { + try await operation(self[index]) + } + } } diff --git a/Sources/VDStore/StoreExtensions/Or.swift b/Sources/VDStore/StoreExtensions/Or.swift index f7c729b..b5e7674 100644 --- a/Sources/VDStore/StoreExtensions/Or.swift +++ b/Sources/VDStore/StoreExtensions/Or.swift @@ -1,12 +1,12 @@ import Foundation public extension Store { - - func or(_ defaultValue: @escaping @autoclosure () -> T) -> Store where T? == State { - scope { - $0 ?? defaultValue() - } set: { - $0 = $1 - } - } + + func or(_ defaultValue: @escaping @autoclosure () -> T) -> Store where T? == State { + scope { + $0 ?? defaultValue() + } set: { + $0 = $1 + } + } } diff --git a/Sources/VDStore/StoreExtensions/WithAnimaiton.swift b/Sources/VDStore/StoreExtensions/WithAnimaiton.swift index 023fd85..91755b2 100644 --- a/Sources/VDStore/StoreExtensions/WithAnimaiton.swift +++ b/Sources/VDStore/StoreExtensions/WithAnimaiton.swift @@ -1,12 +1,14 @@ import SwiftUI -extension Store { - - @MainActor - /// Suspends the store from updating the UI until the block returns. - public func withAnimation(_ animation: Animation? = .default, _ update: @MainActor () throws -> T) rethrows -> T { - try SwiftUI.withAnimation(animation) { - try self.update(update) - } - } +public extension Store { + + @MainActor + /// Suspends the store from updating the UI until the block returns. + func withAnimation(_ animation: Animation? = .default, _ operation: @MainActor () throws -> T) rethrows -> T { + try SwiftUI.withAnimation(animation) { + let result = try update(operation) + forceUpdateIfNeeded() + return result + } + } } diff --git a/Sources/VDStore/Utils/Binding++.swift b/Sources/VDStore/Utils/Binding++.swift index a91364c..d6b5ad9 100644 --- a/Sources/VDStore/Utils/Binding++.swift +++ b/Sources/VDStore/Utils/Binding++.swift @@ -1,26 +1,26 @@ import SwiftUI -extension Binding { +public extension Binding { - public func `didSet`(_ action: @escaping (Value, Value) -> Void) -> Binding { - Binding( - get: { wrappedValue }, - set: { newValue in - let oldValue = wrappedValue - wrappedValue = newValue - action(oldValue, newValue) - } - ) - } + func didSet(_ action: @escaping (Value, Value) -> Void) -> Binding { + Binding( + get: { wrappedValue }, + set: { newValue in + let oldValue = wrappedValue + wrappedValue = newValue + action(oldValue, newValue) + } + ) + } - public func `willSet`(_ action: @escaping (Value, Value) -> Void) -> Binding { - Binding( - get: { wrappedValue }, - set: { newValue in - let oldValue = wrappedValue - action(oldValue, newValue) - wrappedValue = newValue - } - ) - } + func willSet(_ action: @escaping (Value, Value) -> Void) -> Binding { + Binding( + get: { wrappedValue }, + set: { newValue in + let oldValue = wrappedValue + action(oldValue, newValue) + wrappedValue = newValue + } + ) + } } diff --git a/Sources/VDStore/Utils/DIPublisher.swift b/Sources/VDStore/Utils/DIPublisher.swift new file mode 100644 index 0000000..9f60c61 --- /dev/null +++ b/Sources/VDStore/Utils/DIPublisher.swift @@ -0,0 +1,56 @@ +import Combine +import Dependencies +import Foundation + +struct DIPublisher: Publisher { + + typealias Output = Base.Output + typealias Failure = Base.Failure + + let base: Base + let values: (StoreDIValues) -> StoreDIValues + + init(base: Base, modifier: @escaping (StoreDIValues) -> StoreDIValues) { + self.base = base + values = modifier + } + + func receive(subscriber: S) where S: Subscriber, Base.Failure == S.Failure, Base.Output == S.Input { + base.receive(subscriber: DISubscriber(base: subscriber, values: values)) + } +} + +struct DISubscriber: Subscriber { + + typealias Input = Base.Input + typealias Failure = Base.Failure + + let base: Base + let values: (StoreDIValues) -> StoreDIValues + + var combineIdentifier: CombineIdentifier { base.combineIdentifier } + + func receive(subscription: Subscription) { + execute { + base.receive(subscription: subscription) + } + } + + func receive(_ input: Base.Input) -> Subscribers.Demand { + execute { + base.receive(input) + } + } + + func receive(completion: Subscribers.Completion) { + execute { + base.receive(completion: completion) + } + } + + func execute(_ operation: () -> T) -> T { + StoreDIValues.$current.withValue(values) { + operation() + } + } +} diff --git a/Sources/VDStore/Utils/StoreBox.swift b/Sources/VDStore/Utils/StoreBox.swift index 519052e..2ba01d1 100644 --- a/Sources/VDStore/Utils/StoreBox.swift +++ b/Sources/VDStore/Utils/StoreBox.swift @@ -13,6 +13,7 @@ struct StoreBox: Publisher { let willSet: AnyPublisher let startUpdate: () -> Void let endUpdate: () -> Void + let forceUpdate: () -> Void private let getter: () -> Output private let setter: (Output) -> Void private let valuePublisher: AnyPublisher @@ -25,6 +26,7 @@ struct StoreBox: Publisher { setter = { rootBox.state = $0 } startUpdate = rootBox.startUpdate endUpdate = rootBox.endUpdate + forceUpdate = rootBox.forceUpdateIfNeeded } init( @@ -42,6 +44,7 @@ struct StoreBox: Publisher { } startUpdate = parent.startUpdate endUpdate = parent.endUpdate + forceUpdate = parent.forceUpdate } func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { @@ -54,12 +57,12 @@ private final class StoreRootBox: Publisher { typealias Output = State typealias Failure = Never - private var _state: State + private var _state: State var state: State { - get { - _$observationRegistrar.access(box: self) - return _state - } + get { + _$observationRegistrar.access(box: self) + return _state + } set { if updatesCounter == 0 { if suspendAllSyncStoreUpdates { @@ -67,39 +70,39 @@ private final class StoreRootBox: Publisher { suspendSyncUpdates() } } else { - sendWillSet() + sendWillSet() } } - - _state = newValue - - if updatesCounter == 0, asyncUpdatesCounter == 0 { - sendDidSet() - } + + _state = newValue + + if updatesCounter == 0, asyncUpdatesCounter == 0 { + sendDidSet() + } } } var willSetPublisher: AnyPublisher { - willSetSubject.eraseToAnyPublisher() + willSetSubject.eraseToAnyPublisher() } private var updatesCounter = 0 private var asyncUpdatesCounter = 0 private let willSetSubject = PassthroughSubject() private let didSetSubject = PassthroughSubject() - private let _$observationRegistrar: ObservationRegistrarProtocol + private let _$observationRegistrar: ObservationRegistrarProtocol init(_ state: State) { _state = state - if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { - _$observationRegistrar = ObservationRegistrar() - } else { - _$observationRegistrar = MockObservationRegistrar() - } + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + _$observationRegistrar = ObservationRegistrar() + } else { + _$observationRegistrar = MockObservationRegistrar() + } } func receive(subscriber: S) where S: Subscriber, Never == S.Failure, Output == S.Input { - didSetSubject + didSetSubject .compactMap { [weak self] in self?._state } .prepend(_state) .receive(subscriber: subscriber) @@ -107,7 +110,7 @@ private final class StoreRootBox: Publisher { func startUpdate() { if updatesCounter == 0, asyncUpdatesCounter == 0 { - sendWillSet() + sendWillSet() } updatesCounter &+= 1 } @@ -115,13 +118,19 @@ private final class StoreRootBox: Publisher { func endUpdate() { updatesCounter &-= 1 guard updatesCounter == 0 else { return } - sendDidSet() + sendDidSet() if asyncUpdatesCounter > 0 { - sendWillSet() + sendWillSet() } } + func forceUpdateIfNeeded() { + guard updatesCounter > 0 || asyncUpdatesCounter > 0 else { return } + sendDidSet() + sendWillSet() + } + private func suspendSyncUpdates() { startAsyncUpdate() DispatchQueue.main.async { [self] in @@ -131,7 +140,7 @@ private final class StoreRootBox: Publisher { private func startAsyncUpdate() { if asyncUpdatesCounter == 0 { - sendWillSet() + sendWillSet() } asyncUpdatesCounter &+= 1 } @@ -139,55 +148,56 @@ private final class StoreRootBox: Publisher { private func endAsyncUpdate() { asyncUpdatesCounter &-= 1 if asyncUpdatesCounter == 0 { - sendDidSet() + sendDidSet() } } - - private func sendWillSet() { - willSetSubject.send() - _$observationRegistrar.willSet(box: self) - } - - private func sendDidSet() { - didSetSubject.send() - _$observationRegistrar.didSet(box: self) - } + + private func sendWillSet() { + willSetSubject.send() + _$observationRegistrar.willSet(box: self) + } + + private func sendDidSet() { + didSetSubject.send() + _$observationRegistrar.didSet(box: self) + } } private protocol ObservationRegistrarProtocol { - func access(box: StoreRootBox) - func willSet(box: StoreRootBox) - func didSet(box: StoreRootBox) - func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T + func access(box: StoreRootBox) + func willSet(box: StoreRootBox) + func didSet(box: StoreRootBox) + func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T } @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -extension StoreRootBox: Observable { -} +extension StoreRootBox: Observable {} @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) extension ObservationRegistrar: ObservationRegistrarProtocol { - fileprivate func access(box: StoreRootBox) { - access(box, keyPath: \.state) - } - - fileprivate func willSet(box: StoreRootBox) { - willSet(box, keyPath: \.state) - } - fileprivate func didSet(box: StoreRootBox) { - didSet(box, keyPath: \.state) - } - fileprivate func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T { - try withMutation(of: box, keyPath: \.state, mutation) - } + fileprivate func access(box: StoreRootBox) { + access(box, keyPath: \.state) + } + + fileprivate func willSet(box: StoreRootBox) { + willSet(box, keyPath: \.state) + } + + fileprivate func didSet(box: StoreRootBox) { + didSet(box, keyPath: \.state) + } + + fileprivate func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T { + try withMutation(of: box, keyPath: \.state, mutation) + } } private struct MockObservationRegistrar: ObservationRegistrarProtocol { - func access(box: StoreRootBox){} - func willSet(box: StoreRootBox) {} - func didSet(box: StoreRootBox) {} - func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T { - try mutation() - } + func access(box: StoreRootBox) {} + func willSet(box: StoreRootBox) {} + func didSet(box: StoreRootBox) {} + func withMutation(box: StoreRootBox, _ mutation: () throws -> T) rethrows -> T { + try mutation() + } } diff --git a/Sources/VDStore/Utils/StoreUpdates.swift b/Sources/VDStore/Utils/StoreUpdates.swift index ed25c92..7c290b7 100644 --- a/Sources/VDStore/Utils/StoreUpdates.swift +++ b/Sources/VDStore/Utils/StoreUpdates.swift @@ -18,7 +18,7 @@ public struct StorePublisher: Publisher { public subscript( dynamicMember keyPath: KeyPath ) -> StorePublisher { - StorePublisher(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) + StorePublisher(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) } /// Returns the resulting sequence of a given key path. @@ -26,45 +26,45 @@ public struct StorePublisher: Publisher { public subscript( dynamicMember keyPath: KeyPath ) -> StorePublisher { - StorePublisher(upstream: upstream.map(keyPath).eraseToAnyPublisher()) + StorePublisher(upstream: upstream.map(keyPath).eraseToAnyPublisher()) } } /// An async sequence and publisher of store state. @dynamicMemberLookup public struct StoreAsyncSequence: AsyncSequence { - - public typealias AsyncIterator = AsyncStream.AsyncIterator - public typealias Element = State - - let upstream: AnyPublisher - - /// Returns the resulting sequence of a given key path. - public subscript( - dynamicMember keyPath: KeyPath - ) -> StoreAsyncSequence { - StoreAsyncSequence(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) - } - - /// Returns the resulting sequence of a given key path. - @_disfavoredOverload - public subscript( - dynamicMember keyPath: KeyPath - ) -> StoreAsyncSequence { - StoreAsyncSequence(upstream: upstream.map(keyPath).eraseToAnyPublisher()) - } - - public func makeAsyncIterator() -> AsyncStream.AsyncIterator { - AsyncStream { continuation in - let cancellable = upstream.sink { _ in - continuation.finish() - } receiveValue: { - continuation.yield($0) - } - continuation.onTermination = { _ in - cancellable.cancel() - } - } - .makeAsyncIterator() - } + + public typealias AsyncIterator = AsyncStream.AsyncIterator + public typealias Element = State + + let upstream: AnyPublisher + + /// Returns the resulting sequence of a given key path. + public subscript( + dynamicMember keyPath: KeyPath + ) -> StoreAsyncSequence { + StoreAsyncSequence(upstream: upstream.map(keyPath).removeDuplicates().eraseToAnyPublisher()) + } + + /// Returns the resulting sequence of a given key path. + @_disfavoredOverload + public subscript( + dynamicMember keyPath: KeyPath + ) -> StoreAsyncSequence { + StoreAsyncSequence(upstream: upstream.map(keyPath).eraseToAnyPublisher()) + } + + public func makeAsyncIterator() -> AsyncStream.AsyncIterator { + AsyncStream { continuation in + let cancellable = upstream.sink { _ in + continuation.finish() + } receiveValue: { + continuation.yield($0) + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + .makeAsyncIterator() + } } diff --git a/Sources/VDStore/ViewStore.swift b/Sources/VDStore/ViewStore.swift index f121cb8..b481b6d 100644 --- a/Sources/VDStore/ViewStore.swift +++ b/Sources/VDStore/ViewStore.swift @@ -75,8 +75,8 @@ public struct ViewStore: DynamicProperty { extension StoreDIValues { var isViewStore: Bool { - get { self[\.isViewStore] ?? false } - set { self[\.isViewStore] = newValue } + get { get(\.isViewStore, or: false) } + set { set(\.isViewStore, newValue) } } } @@ -104,11 +104,11 @@ public extension Store { state = $0 } } - - /// SwiftUI environment values. Available in SwiftUI view hierarchy. - var env: EnvironmentValues { - Environment(\.self).wrappedValue - } + + /// SwiftUI environment values. Available in SwiftUI view hierarchy. + var env: EnvironmentValues { + Environment(\.self).wrappedValue + } } @available(iOS 14.0, macOS 11.00, tvOS 14.0, watchOS 7.0, *) diff --git a/Sources/VDStoreMacros/ActionsMacro.swift b/Sources/VDStoreMacros/ActionsMacro.swift index 52bfb85..f10acdd 100644 --- a/Sources/VDStoreMacros/ActionsMacro.swift +++ b/Sources/VDStoreMacros/ActionsMacro.swift @@ -129,7 +129,7 @@ private func expansion( var executeDecl = funcDecl executeDecl.remove(attribute: "Action") executeDecl.remove(attribute: "_disfavoredOverload") -// executeDecl.add(attribute: "MainActor") + // executeDecl.add(attribute: "MainActor") // executeDecl.modifiers.remove(at: privateIndex) var parameterList = executeDecl.signature.parameterClause.parameters.map { FunctionParameterSyntax( diff --git a/Sources/VDStoreMacros/Extensions.swift b/Sources/VDStoreMacros/Extensions.swift index 0e33d55..84d80a4 100644 --- a/Sources/VDStoreMacros/Extensions.swift +++ b/Sources/VDStoreMacros/Extensions.swift @@ -21,16 +21,16 @@ extension FunctionDeclSyntax { attributes.remove(at: i) } } - - mutating func add(attribute: String) { - if attributes.contains(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) { - return - } - attributes.insert( - .attribute(AttributeSyntax("\(raw: attribute)")), - at: attributes.startIndex - ) - } + + mutating func add(attribute: String) { + if attributes.contains(where: { $0.as(AttributeSyntax.self)?.attributeName.description == attribute }) { + return + } + attributes.insert( + .attribute(AttributeSyntax("\(raw: attribute)")), + at: attributes.startIndex + ) + } } extension MacroExpansionContext { diff --git a/Sources/VDStoreMacros/StoreDependenciesMacros.swift b/Sources/VDStoreMacros/StoreDependenciesMacros.swift index 037560b..f46659b 100644 --- a/Sources/VDStoreMacros/StoreDependenciesMacros.swift +++ b/Sources/VDStoreMacros/StoreDependenciesMacros.swift @@ -37,10 +37,10 @@ public struct StoreDIValueMacro: AccessorMacro { return [ """ - get { self[\\.\(raw: identifier).self] ?? \(raw: defaultValue) } + get { get(\\.\(raw: identifier), or: \(raw: defaultValue)) } """, """ - set { self[\\.\(raw: identifier).self] = newValue } + set { set(\\.\(raw: identifier), newValue) } """, ] } diff --git a/Tests/VDStoreTests/VDStoreTests.swift b/Tests/VDStoreTests/VDStoreTests.swift index d9fb24a..ea21259 100644 --- a/Tests/VDStoreTests/VDStoreTests.swift +++ b/Tests/VDStoreTests/VDStoreTests.swift @@ -90,19 +90,19 @@ final class VDStoreTests: XCTestCase { XCTAssertEqual(count, 2) } - /// Test that the publisher property of a Store sends updates when the state changes. - func testAsyncSequenceUpdates() async { - let initialCounter = Counter(counter: 0) - let store = Store(initialCounter) - Task { - store.add() - } - for await newState in store.async { - if newState.counter == 1 { - break - } - } - } + /// Test that the publisher property of a Store sends updates when the state changes. + func testAsyncSequenceUpdates() async { + let initialCounter = Counter(counter: 0) + let store = Store(initialCounter) + Task { + store.add() + } + for await newState in store.async { + if newState.counter == 1 { + break + } + } + } #if swift(>=5.9) /// Test that the publisher property of a Store sends updates when the state changes. @@ -237,54 +237,54 @@ final class VDStoreTests: XCTestCase { XCTAssertEqual(updatesCount, 2) } -// @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) -// func testObservation() async { -// let store = Store(Counter()).counter -// let expectation = expectation(description: "Counter") -//// store.state += 1 -// withObservationTracking { -// store.state += 1 -// } onChange: { -// expectation.fulfill() -// } -//// withContinousObservation(of: store.state) { state in -//// print("onChange") -//// expectation.fulfill() -//// } -//// store.state += 1 -// await fulfillment(of: [expectation], timeout: 0.1) -// } + // @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + // func testObservation() async { + // let store = Store(Counter()).counter + // let expectation = expectation(description: "Counter") + //// store.state += 1 + // withObservationTracking { + // store.state += 1 + // } onChange: { + // expectation.fulfill() + // } + //// withContinousObservation(of: store.state) { state in + //// print("onChange") + //// expectation.fulfill() + //// } + //// store.state += 1 + // await fulfillment(of: [expectation], timeout: 0.1) + // } #endif } @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) func withContinousObservation(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) { - withObservationTracking { - execute(value()) - } onChange: { - DispatchQueue.main.async { - withContinousObservation(of: value(), execute: execute) - } - } + withObservationTracking { + execute(value()) + } onChange: { + DispatchQueue.main.async { + withContinousObservation(of: value(), execute: execute) + } + } } @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) func observationTrackingStream( - of value: @escaping @autoclosure () -> T + of value: @escaping @autoclosure () -> T ) -> AsyncStream { - AsyncStream { continuation in - @Sendable func observe() { - let result = withObservationTracking { - value() - } onChange: { - DispatchQueue.main.async { - observe() - } - } - continuation.yield(result) - } - observe() - } + AsyncStream { continuation in + @Sendable func observe() { + let result = withObservationTracking { + value() + } onChange: { + DispatchQueue.main.async { + observe() + } + } + continuation.yield(result) + } + observe() + } } struct Counter: Equatable {