├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── TCASwiftLog.xcscheme ├── Examples ├── Package.swift ├── Screenshot001.png ├── Screenshot002.png └── Standups │ ├── Dependencies │ ├── DataManager.swift │ ├── OpenSettings.swift │ └── SpeechRecognizer.swift │ ├── LICENSE │ ├── README.md │ ├── Standups.xcodeproj │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ └── Standups.xcscheme │ ├── Standups │ ├── App.swift │ ├── AppFeature.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── Themes │ │ │ ├── Contents.json │ │ │ ├── bubblegum.colorset │ │ │ └── Contents.json │ │ │ ├── buttercup.colorset │ │ │ └── Contents.json │ │ │ ├── indigo.colorset │ │ │ └── Contents.json │ │ │ ├── lavender.colorset │ │ │ └── Contents.json │ │ │ ├── magenta.colorset │ │ │ └── Contents.json │ │ │ ├── navy.colorset │ │ │ └── Contents.json │ │ │ ├── orange.colorset │ │ │ └── Contents.json │ │ │ ├── oxblood.colorset │ │ │ └── Contents.json │ │ │ ├── periwinkle.colorset │ │ │ └── Contents.json │ │ │ ├── poppy.colorset │ │ │ └── Contents.json │ │ │ ├── purple.colorset │ │ │ └── Contents.json │ │ │ ├── seafoam.colorset │ │ │ └── Contents.json │ │ │ ├── sky.colorset │ │ │ └── Contents.json │ │ │ ├── tan.colorset │ │ │ └── Contents.json │ │ │ ├── teal.colorset │ │ │ └── Contents.json │ │ │ └── yellow.colorset │ │ │ └── Contents.json │ ├── Meeting.swift │ ├── Models.swift │ ├── RecordMeeting.swift │ ├── Resources │ │ └── ding.wav │ ├── StandupDetail.swift │ ├── StandupForm.swift │ └── StandupsList.swift │ ├── StandupsTests │ ├── AppFeatureTests.swift │ ├── RecordMeetingTests.swift │ ├── StandupDetailTests.swift │ ├── StandupFormTests.swift │ └── StandupsListTests.swift │ └── StandupsUITests │ └── StandupsUITests.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── TCASwiftLog │ └── ReducerPrinter.swift ├── TCASwiftLog.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved └── Tests └── TCASwiftLogTests └── ReducerPrinterTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/TCASwiftLog.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | // This is only here so that Xcode doesn't display this directory inside the main package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package(name: "", products: [], targets: []) 7 | -------------------------------------------------------------------------------- /Examples/Screenshot001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrarski/tca-swift-log/51107c6137cb7a5dd05723117b6815915feeefd2/Examples/Screenshot001.png -------------------------------------------------------------------------------- /Examples/Screenshot002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrarski/tca-swift-log/51107c6137cb7a5dd05723117b6815915feeefd2/Examples/Screenshot002.png -------------------------------------------------------------------------------- /Examples/Standups/Dependencies/DataManager.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | 4 | struct DataManager: Sendable { 5 | var load: @Sendable (URL) throws -> Data 6 | var save: @Sendable (Data, URL) async throws -> Void 7 | } 8 | 9 | extension DataManager: DependencyKey { 10 | static let liveValue = Self( 11 | load: { url in try Data(contentsOf: url) }, 12 | save: { data, url in try data.write(to: url) } 13 | ) 14 | 15 | static let testValue = Self( 16 | load: unimplemented("DataManager.load"), 17 | save: unimplemented("DataManager.save") 18 | ) 19 | } 20 | 21 | extension DependencyValues { 22 | var dataManager: DataManager { 23 | get { self[DataManager.self] } 24 | set { self[DataManager.self] = newValue } 25 | } 26 | } 27 | 28 | extension DataManager { 29 | static func mock(initialData: Data? = nil) -> Self { 30 | let data = LockIsolated(initialData) 31 | return Self( 32 | load: { _ in 33 | guard let data = data.value 34 | else { 35 | struct FileNotFound: Error {} 36 | throw FileNotFound() 37 | } 38 | return data 39 | }, 40 | save: { newData, _ in data.setValue(newData) } 41 | ) 42 | } 43 | 44 | static let failToWrite = Self( 45 | load: { _ in Data() }, 46 | save: { _, _ in 47 | struct SaveError: Error {} 48 | throw SaveError() 49 | } 50 | ) 51 | 52 | static let failToLoad = Self( 53 | load: { _ in 54 | struct LoadError: Error {} 55 | throw LoadError() 56 | }, 57 | save: { _, _ in } 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /Examples/Standups/Dependencies/OpenSettings.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import UIKit 3 | 4 | extension DependencyValues { 5 | var openSettings: @Sendable () async -> Void { 6 | get { self[OpenSettingsKey.self] } 7 | set { self[OpenSettingsKey.self] = newValue } 8 | } 9 | 10 | private enum OpenSettingsKey: DependencyKey { 11 | typealias Value = @Sendable () async -> Void 12 | 13 | static let liveValue: @Sendable () async -> Void = { 14 | await MainActor.run { 15 | UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/Standups/Dependencies/SpeechRecognizer.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | @preconcurrency import Speech 3 | 4 | struct SpeechClient { 5 | var authorizationStatus: @Sendable () -> SFSpeechRecognizerAuthorizationStatus 6 | var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus 7 | var startTask: 8 | @Sendable (SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< 9 | SpeechRecognitionResult, Error 10 | > 11 | } 12 | 13 | extension SpeechClient: DependencyKey { 14 | static var liveValue: SpeechClient { 15 | let speech = Speech() 16 | return SpeechClient( 17 | authorizationStatus: { SFSpeechRecognizer.authorizationStatus() }, 18 | requestAuthorization: { 19 | await withUnsafeContinuation { continuation in 20 | SFSpeechRecognizer.requestAuthorization { status in 21 | continuation.resume(returning: status) 22 | } 23 | } 24 | }, 25 | startTask: { request in 26 | await speech.startTask(request: request) 27 | } 28 | ) 29 | } 30 | 31 | static var previewValue: SpeechClient { 32 | let isRecording = ActorIsolated(false) 33 | return Self( 34 | authorizationStatus: { .authorized }, 35 | requestAuthorization: { .authorized }, 36 | startTask: { _ in 37 | AsyncThrowingStream { continuation in 38 | Task { @MainActor in 39 | await isRecording.setValue(true) 40 | var finalText = """ 41 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ 42 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ 43 | exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ 44 | irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ 45 | pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui \ 46 | officia deserunt mollit anim id est laborum. 47 | """ 48 | var text = "" 49 | while await isRecording.value { 50 | let word = finalText.prefix { $0 != " " } 51 | try await Task.sleep(for: .milliseconds(word.count * 50 + .random(in: 0...200))) 52 | finalText.removeFirst(word.count) 53 | if finalText.first == " " { 54 | finalText.removeFirst() 55 | } 56 | text += word + " " 57 | continuation.yield( 58 | SpeechRecognitionResult( 59 | bestTranscription: Transcription( 60 | formattedString: text 61 | ), 62 | isFinal: false 63 | ) 64 | ) 65 | } 66 | } 67 | } 68 | } 69 | ) 70 | } 71 | 72 | static let testValue = SpeechClient( 73 | authorizationStatus: unimplemented("SpeechClient.authorizationStatus", placeholder: .denied), 74 | requestAuthorization: unimplemented("SpeechClient.requestAuthorization", placeholder: .denied), 75 | startTask: unimplemented("SpeechClient.startTask") 76 | ) 77 | 78 | static func fail(after duration: Duration) -> Self { 79 | return Self( 80 | authorizationStatus: { .authorized }, 81 | requestAuthorization: { .authorized }, 82 | startTask: { request in 83 | AsyncThrowingStream { continuation in 84 | Task { @MainActor in 85 | let start = ContinuousClock.now 86 | do { 87 | for try await result in await Self.previewValue.startTask(request) { 88 | if ContinuousClock.now - start > duration { 89 | struct SpeechRecognitionFailed: Error {} 90 | continuation.finish(throwing: SpeechRecognitionFailed()) 91 | break 92 | } else { 93 | continuation.yield(result) 94 | } 95 | } 96 | continuation.finish() 97 | } catch { 98 | continuation.finish(throwing: error) 99 | } 100 | } 101 | } 102 | } 103 | ) 104 | } 105 | } 106 | 107 | extension DependencyValues { 108 | var speechClient: SpeechClient { 109 | get { self[SpeechClient.self] } 110 | set { self[SpeechClient.self] = newValue } 111 | } 112 | } 113 | 114 | struct SpeechRecognitionResult: Equatable { 115 | var bestTranscription: Transcription 116 | var isFinal: Bool 117 | } 118 | 119 | struct Transcription: Equatable { 120 | var formattedString: String 121 | } 122 | 123 | extension SpeechRecognitionResult { 124 | init(_ speechRecognitionResult: SFSpeechRecognitionResult) { 125 | self.bestTranscription = Transcription(speechRecognitionResult.bestTranscription) 126 | self.isFinal = speechRecognitionResult.isFinal 127 | } 128 | } 129 | 130 | extension Transcription { 131 | init(_ transcription: SFTranscription) { 132 | self.formattedString = transcription.formattedString 133 | } 134 | } 135 | 136 | private actor Speech { 137 | private var audioEngine: AVAudioEngine? = nil 138 | private var recognitionTask: SFSpeechRecognitionTask? = nil 139 | private var recognitionContinuation: 140 | AsyncThrowingStream.Continuation? 141 | 142 | func startTask( 143 | request: SFSpeechAudioBufferRecognitionRequest 144 | ) -> AsyncThrowingStream { 145 | AsyncThrowingStream { continuation in 146 | self.recognitionContinuation = continuation 147 | let audioSession = AVAudioSession.sharedInstance() 148 | do { 149 | try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) 150 | try audioSession.setActive(true, options: .notifyOthersOnDeactivation) 151 | } catch { 152 | continuation.finish(throwing: error) 153 | return 154 | } 155 | 156 | self.audioEngine = AVAudioEngine() 157 | let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))! 158 | self.recognitionTask = speechRecognizer.recognitionTask(with: request) { result, error in 159 | switch (result, error) { 160 | case let (.some(result), _): 161 | continuation.yield(SpeechRecognitionResult(result)) 162 | case (_, .some): 163 | continuation.finish(throwing: error) 164 | case (.none, .none): 165 | fatalError("It should not be possible to have both a nil result and nil error.") 166 | } 167 | } 168 | 169 | continuation.onTermination = { [audioEngine, recognitionTask] _ in 170 | _ = speechRecognizer 171 | audioEngine?.stop() 172 | audioEngine?.inputNode.removeTap(onBus: 0) 173 | recognitionTask?.finish() 174 | } 175 | 176 | self.audioEngine?.inputNode.installTap( 177 | onBus: 0, 178 | bufferSize: 1024, 179 | format: self.audioEngine?.inputNode.outputFormat(forBus: 0) 180 | ) { buffer, when in 181 | request.append(buffer) 182 | } 183 | 184 | self.audioEngine?.prepare() 185 | do { 186 | try self.audioEngine?.start() 187 | } catch { 188 | continuation.finish(throwing: error) 189 | return 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Examples/Standups/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Point-Free, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Examples/Standups/README.md: -------------------------------------------------------------------------------- 1 | # Standups 2 | 3 | This project demonstrates how to build a complex, real world application that deals with many forms 4 | of navigation (_e.g._, sheets, drill-downs, alerts), many side effects (timers, speech recognizer, 5 | data persistence), and do so in a way that is testable and modular. 6 | 7 | The inspiration for this application comes Apple's [Scrumdinger][scrumdinger] tutorial: 8 | 9 | > This module guides you through the development of Scrumdinger, an iOS app that helps users manage 10 | > their daily scrums. To help keep scrums short and focused, Scrumdinger uses visual and audio cues 11 | > to indicate when and how long each attendee should speak. The app also displays a progress screen 12 | > that shows the time remaining in the meeting and creates a transcript that users can refer to 13 | > later. 14 | 15 | The Scrumdinger app is one of Apple's most interesting code samples as it deals with many real 16 | world problems that one faces in application development. It shows off many types of navigation, 17 | it deals with complex effects such as timers and speech recognition, and it persists application 18 | data to disk. 19 | 20 | However, it is not necessarily built in the most ideal way. It uses mostly fire-and-forget style 21 | navigation, which means you can't easily deep link into any screen of the app, which is handy for 22 | push notifications and opening URLs. It also uses uncontrolled dependencies, including file system 23 | access, timers and a speech recognizer, which makes it nearly impossible to write automated tests 24 | and even hinders the ability to preview the app in Xcode previews. 25 | 26 | But, the simplicity of Apple's Scrumdinger codebase is not a defect. In fact, it's a feature! 27 | Apple's sample code is viewed by hundreds of thousands of developers across the world, and so its 28 | goal is to be as approachable as possible in order to teach the basics of SwiftUI. But, that doesn't mean there isn't room for improvement. 29 | 30 | ## Composable Standups 31 | 32 | Our Standups application is a rebuild of Apple's Scrumdinger application, but with a focus on 33 | modern, best practices for SwiftUI development. We faithfully recreate the Scrumdinger, but with 34 | some key additions: 35 | 36 | 1. Identifiers are made type safe using our [Tagged library][tagged-gh]. This prevents us from 37 | writing nonsensical code, such as comparing a `Standup.ID` to a `Attendee.ID`. 38 | 2. Instead of using bare arrays in feature logic we use an "identified" array from our 39 | [IdentifiedCollections][identified-collections-gh] library. This allows you to read and modify 40 | elements of the collection via their ID rather than positional index, which can be error-prone 41 | and lead to bugs or crashes. 42 | 3. _All_ navigation is driven off of state, including sheets, drill-downs and alerts. This makes 43 | it possible to deep link into any screen of the app by just constructing a piece of state and 44 | handing it off to SwiftUI. 45 | 4. Further, each view represents its navigation destinations as a single enum, which gives us 46 | compile time proof that two destinations cannot be active at the same time. This cannot be 47 | accomplished with default SwiftUI tools, but can be done easily with the tools that the 48 | Composable Architecture provides. 49 | 5. All side effects are controlled. This includes access to the file system for persistence, access 50 | to time-based asynchrony for timers, access to speech recognition APIs, and even the creation 51 | of dates and UUIDs. This allows us to run our application in specific execution contexts, which 52 | is very useful in tests and Xcode previews. We accomplish this using our 53 | [Dependencies][dependencies-gh] library. 54 | 6. The project includes a full test suite. Since all of navigation is driven off of state, and 55 | because we controlled all dependencies, we can write very comprehensive and nuanced tests. For 56 | example, we can write a unit test that proves that when a standup meeting's timer runs out the 57 | screen pops off the stack and a new transcript is added to the standup. Such a test would be 58 | very difficult, if not impossible, without controlling dependencies. 59 | 60 | [scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger 61 | [scrumdinger-dl]: https://docs-assets.developer.apple.com/published/1ea2eec121b90031e354288912a76357/TranscribingSpeechToText.zip 62 | [tagged-gh]: http://github.com/pointfreeco/swift-tagged 63 | [identified-collections-gh]: http://github.com/pointfreeco/swift-identified-collections 64 | [dependencies-gh]: http://github.com/pointfreeco/swift-dependencies 65 | -------------------------------------------------------------------------------- /Examples/Standups/Standups.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 316511EC2A84391700666623 /* TCASwiftLog in Frameworks */ = {isa = PBXBuildFile; productRef = 316511EB2A84391700666623 /* TCASwiftLog */; }; 11 | 316511F42A84399A00666623 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 316511F32A84399A00666623 /* ComposableArchitecture */; }; 12 | 316511F72A8439B100666623 /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = 316511F62A8439B100666623 /* Pulse */; }; 13 | 316511F92A8439B100666623 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = 316511F82A8439B100666623 /* PulseUI */; }; 14 | 316511FC2A8439C600666623 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = 316511FB2A8439C600666623 /* PulseLogHandler */; }; 15 | CA0934B62A12A9680020DEF5 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA0934B52A12A9680020DEF5 /* SwiftUINavigation */; }; 16 | DC7CE4E729E9E6E4006B6263 /* ding.wav in Resources */ = {isa = PBXBuildFile; fileRef = DC7CE4E629E9E6E4006B6263 /* ding.wav */; }; 17 | DC808D7329E9C3AC0072B4A9 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D7229E9C3AC0072B4A9 /* App.swift */; }; 18 | DC808D7729E9C3AD0072B4A9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC808D7629E9C3AD0072B4A9 /* Assets.xcassets */; }; 19 | DC808D8429E9C3AD0072B4A9 /* StandupFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D8329E9C3AD0072B4A9 /* StandupFormTests.swift */; }; 20 | DC808D8E29E9C3AD0072B4A9 /* StandupsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D8D29E9C3AD0072B4A9 /* StandupsUITests.swift */; }; 21 | DC808DA029E9C4340072B4A9 /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808D9F29E9C4340072B4A9 /* DataManager.swift */; }; 22 | DC808DA529E9C4C70072B4A9 /* OpenSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */; }; 23 | DC808DA729E9C4D60072B4A9 /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */; }; 24 | DC808DA929E9C5090072B4A9 /* StandupForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DA829E9C5090072B4A9 /* StandupForm.swift */; }; 25 | DC808DAB29E9C51D0072B4A9 /* Meeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DAA29E9C51D0072B4A9 /* Meeting.swift */; }; 26 | DC808DAD29E9C52A0072B4A9 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DAC29E9C52A0072B4A9 /* Models.swift */; }; 27 | DC808DAF29E9C53E0072B4A9 /* RecordMeeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DAE29E9C53E0072B4A9 /* RecordMeeting.swift */; }; 28 | DC808DB129E9C54A0072B4A9 /* StandupDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DB029E9C54A0072B4A9 /* StandupDetail.swift */; }; 29 | DC808DB329E9C5540072B4A9 /* StandupsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC808DB229E9C5540072B4A9 /* StandupsList.swift */; }; 30 | DC808DB629E9C58F0072B4A9 /* Tagged in Frameworks */ = {isa = PBXBuildFile; productRef = DC808DB529E9C58F0072B4A9 /* Tagged */; }; 31 | DC80B1E929EDE5D1001CC0CC /* AppFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC80B1E829EDE5D1001CC0CC /* AppFeature.swift */; }; 32 | DC80B1EB29EE12A7001CC0CC /* AppFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC80B1EA29EE12A7001CC0CC /* AppFeatureTests.swift */; }; 33 | DC8C2B0C29EB084900C65286 /* RecordMeetingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8C2B0B29EB084900C65286 /* RecordMeetingTests.swift */; }; 34 | DC8C2B0E29EB085600C65286 /* StandupDetailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8C2B0D29EB085600C65286 /* StandupDetailTests.swift */; }; 35 | DC8C2B1029EB086A00C65286 /* StandupsListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8C2B0F29EB086A00C65286 /* StandupsListTests.swift */; }; 36 | /* End PBXBuildFile section */ 37 | 38 | /* Begin PBXContainerItemProxy section */ 39 | DC808D8029E9C3AD0072B4A9 /* PBXContainerItemProxy */ = { 40 | isa = PBXContainerItemProxy; 41 | containerPortal = DC808D6729E9C3AC0072B4A9 /* Project object */; 42 | proxyType = 1; 43 | remoteGlobalIDString = DC808D6E29E9C3AC0072B4A9; 44 | remoteInfo = Standups; 45 | }; 46 | DC808D8A29E9C3AD0072B4A9 /* PBXContainerItemProxy */ = { 47 | isa = PBXContainerItemProxy; 48 | containerPortal = DC808D6729E9C3AC0072B4A9 /* Project object */; 49 | proxyType = 1; 50 | remoteGlobalIDString = DC808D6E29E9C3AC0072B4A9; 51 | remoteInfo = Standups; 52 | }; 53 | /* End PBXContainerItemProxy section */ 54 | 55 | /* Begin PBXFileReference section */ 56 | 316511EA2A8438F900666623 /* tca-swift-log */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "tca-swift-log"; path = ../..; sourceTree = ""; }; 57 | DC7CE4E629E9E6E4006B6263 /* ding.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ding.wav; sourceTree = ""; }; 58 | DC808D6F29E9C3AC0072B4A9 /* Standups.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Standups.app; sourceTree = BUILT_PRODUCTS_DIR; }; 59 | DC808D7229E9C3AC0072B4A9 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 60 | DC808D7629E9C3AD0072B4A9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 61 | DC808D7F29E9C3AD0072B4A9 /* StandupsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StandupsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 62 | DC808D8329E9C3AD0072B4A9 /* StandupFormTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupFormTests.swift; sourceTree = ""; }; 63 | DC808D8929E9C3AD0072B4A9 /* StandupsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StandupsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 64 | DC808D8D29E9C3AD0072B4A9 /* StandupsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupsUITests.swift; sourceTree = ""; }; 65 | DC808D9F29E9C4340072B4A9 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; 66 | DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettings.swift; sourceTree = ""; }; 67 | DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; 68 | DC808DA829E9C5090072B4A9 /* StandupForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupForm.swift; sourceTree = ""; }; 69 | DC808DAA29E9C51D0072B4A9 /* Meeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meeting.swift; sourceTree = ""; }; 70 | DC808DAC29E9C52A0072B4A9 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 71 | DC808DAE29E9C53E0072B4A9 /* RecordMeeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordMeeting.swift; sourceTree = ""; }; 72 | DC808DB029E9C54A0072B4A9 /* StandupDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupDetail.swift; sourceTree = ""; }; 73 | DC808DB229E9C5540072B4A9 /* StandupsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupsList.swift; sourceTree = ""; }; 74 | DC80B1E829EDE5D1001CC0CC /* AppFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeature.swift; sourceTree = ""; }; 75 | DC80B1EA29EE12A7001CC0CC /* AppFeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeatureTests.swift; sourceTree = ""; }; 76 | DC8C2B0B29EB084900C65286 /* RecordMeetingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordMeetingTests.swift; sourceTree = ""; }; 77 | DC8C2B0D29EB085600C65286 /* StandupDetailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupDetailTests.swift; sourceTree = ""; }; 78 | DC8C2B0F29EB086A00C65286 /* StandupsListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandupsListTests.swift; sourceTree = ""; }; 79 | /* End PBXFileReference section */ 80 | 81 | /* Begin PBXFrameworksBuildPhase section */ 82 | DC808D6C29E9C3AC0072B4A9 /* Frameworks */ = { 83 | isa = PBXFrameworksBuildPhase; 84 | buildActionMask = 2147483647; 85 | files = ( 86 | 316511F92A8439B100666623 /* PulseUI in Frameworks */, 87 | DC808DB629E9C58F0072B4A9 /* Tagged in Frameworks */, 88 | 316511EC2A84391700666623 /* TCASwiftLog in Frameworks */, 89 | 316511F72A8439B100666623 /* Pulse in Frameworks */, 90 | 316511FC2A8439C600666623 /* PulseLogHandler in Frameworks */, 91 | 316511F42A84399A00666623 /* ComposableArchitecture in Frameworks */, 92 | CA0934B62A12A9680020DEF5 /* SwiftUINavigation in Frameworks */, 93 | ); 94 | runOnlyForDeploymentPostprocessing = 0; 95 | }; 96 | DC808D7C29E9C3AD0072B4A9 /* Frameworks */ = { 97 | isa = PBXFrameworksBuildPhase; 98 | buildActionMask = 2147483647; 99 | files = ( 100 | ); 101 | runOnlyForDeploymentPostprocessing = 0; 102 | }; 103 | DC808D8629E9C3AD0072B4A9 /* Frameworks */ = { 104 | isa = PBXFrameworksBuildPhase; 105 | buildActionMask = 2147483647; 106 | files = ( 107 | ); 108 | runOnlyForDeploymentPostprocessing = 0; 109 | }; 110 | /* End PBXFrameworksBuildPhase section */ 111 | 112 | /* Begin PBXGroup section */ 113 | DC7CE4E529E9E6E4006B6263 /* Resources */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | DC7CE4E629E9E6E4006B6263 /* ding.wav */, 117 | ); 118 | path = Resources; 119 | sourceTree = ""; 120 | }; 121 | DC808D6629E9C3AC0072B4A9 = { 122 | isa = PBXGroup; 123 | children = ( 124 | 316511EA2A8438F900666623 /* tca-swift-log */, 125 | DC808D9E29E9C4040072B4A9 /* Dependencies */, 126 | DC808D7029E9C3AC0072B4A9 /* Products */, 127 | DC808D7129E9C3AC0072B4A9 /* Standups */, 128 | DC808D8229E9C3AD0072B4A9 /* StandupsTests */, 129 | DC808D8C29E9C3AD0072B4A9 /* StandupsUITests */, 130 | DC808DA129E9C4490072B4A9 /* Frameworks */, 131 | ); 132 | sourceTree = ""; 133 | }; 134 | DC808D7029E9C3AC0072B4A9 /* Products */ = { 135 | isa = PBXGroup; 136 | children = ( 137 | DC808D6F29E9C3AC0072B4A9 /* Standups.app */, 138 | DC808D7F29E9C3AD0072B4A9 /* StandupsTests.xctest */, 139 | DC808D8929E9C3AD0072B4A9 /* StandupsUITests.xctest */, 140 | ); 141 | name = Products; 142 | sourceTree = ""; 143 | }; 144 | DC808D7129E9C3AC0072B4A9 /* Standups */ = { 145 | isa = PBXGroup; 146 | children = ( 147 | DC808D7229E9C3AC0072B4A9 /* App.swift */, 148 | DC80B1E829EDE5D1001CC0CC /* AppFeature.swift */, 149 | DC808DAA29E9C51D0072B4A9 /* Meeting.swift */, 150 | DC808DAC29E9C52A0072B4A9 /* Models.swift */, 151 | DC808DAE29E9C53E0072B4A9 /* RecordMeeting.swift */, 152 | DC808DB029E9C54A0072B4A9 /* StandupDetail.swift */, 153 | DC808DA829E9C5090072B4A9 /* StandupForm.swift */, 154 | DC808DB229E9C5540072B4A9 /* StandupsList.swift */, 155 | DC808D7629E9C3AD0072B4A9 /* Assets.xcassets */, 156 | DC7CE4E529E9E6E4006B6263 /* Resources */, 157 | ); 158 | path = Standups; 159 | sourceTree = ""; 160 | }; 161 | DC808D8229E9C3AD0072B4A9 /* StandupsTests */ = { 162 | isa = PBXGroup; 163 | children = ( 164 | DC80B1EA29EE12A7001CC0CC /* AppFeatureTests.swift */, 165 | DC8C2B0B29EB084900C65286 /* RecordMeetingTests.swift */, 166 | DC8C2B0D29EB085600C65286 /* StandupDetailTests.swift */, 167 | DC808D8329E9C3AD0072B4A9 /* StandupFormTests.swift */, 168 | DC8C2B0F29EB086A00C65286 /* StandupsListTests.swift */, 169 | ); 170 | path = StandupsTests; 171 | sourceTree = ""; 172 | }; 173 | DC808D8C29E9C3AD0072B4A9 /* StandupsUITests */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | DC808D8D29E9C3AD0072B4A9 /* StandupsUITests.swift */, 177 | ); 178 | path = StandupsUITests; 179 | sourceTree = ""; 180 | }; 181 | DC808D9E29E9C4040072B4A9 /* Dependencies */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | DC808D9F29E9C4340072B4A9 /* DataManager.swift */, 185 | DC808DA429E9C4C70072B4A9 /* OpenSettings.swift */, 186 | DC808DA629E9C4D60072B4A9 /* SpeechRecognizer.swift */, 187 | ); 188 | path = Dependencies; 189 | sourceTree = ""; 190 | }; 191 | DC808DA129E9C4490072B4A9 /* Frameworks */ = { 192 | isa = PBXGroup; 193 | children = ( 194 | ); 195 | name = Frameworks; 196 | sourceTree = ""; 197 | }; 198 | /* End PBXGroup section */ 199 | 200 | /* Begin PBXNativeTarget section */ 201 | DC808D6E29E9C3AC0072B4A9 /* Standups */ = { 202 | isa = PBXNativeTarget; 203 | buildConfigurationList = DC808D9329E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "Standups" */; 204 | buildPhases = ( 205 | DC808D6B29E9C3AC0072B4A9 /* Sources */, 206 | DC808D6C29E9C3AC0072B4A9 /* Frameworks */, 207 | DC808D6D29E9C3AC0072B4A9 /* Resources */, 208 | ); 209 | buildRules = ( 210 | ); 211 | dependencies = ( 212 | ); 213 | name = Standups; 214 | packageProductDependencies = ( 215 | DC808DB529E9C58F0072B4A9 /* Tagged */, 216 | CA0934B52A12A9680020DEF5 /* SwiftUINavigation */, 217 | 316511EB2A84391700666623 /* TCASwiftLog */, 218 | 316511F32A84399A00666623 /* ComposableArchitecture */, 219 | 316511F62A8439B100666623 /* Pulse */, 220 | 316511F82A8439B100666623 /* PulseUI */, 221 | 316511FB2A8439C600666623 /* PulseLogHandler */, 222 | ); 223 | productName = Standups; 224 | productReference = DC808D6F29E9C3AC0072B4A9 /* Standups.app */; 225 | productType = "com.apple.product-type.application"; 226 | }; 227 | DC808D7E29E9C3AD0072B4A9 /* StandupsTests */ = { 228 | isa = PBXNativeTarget; 229 | buildConfigurationList = DC808D9629E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "StandupsTests" */; 230 | buildPhases = ( 231 | DC808D7B29E9C3AD0072B4A9 /* Sources */, 232 | DC808D7C29E9C3AD0072B4A9 /* Frameworks */, 233 | DC808D7D29E9C3AD0072B4A9 /* Resources */, 234 | ); 235 | buildRules = ( 236 | ); 237 | dependencies = ( 238 | DC808D8129E9C3AD0072B4A9 /* PBXTargetDependency */, 239 | ); 240 | name = StandupsTests; 241 | productName = StandupsTests; 242 | productReference = DC808D7F29E9C3AD0072B4A9 /* StandupsTests.xctest */; 243 | productType = "com.apple.product-type.bundle.unit-test"; 244 | }; 245 | DC808D8829E9C3AD0072B4A9 /* StandupsUITests */ = { 246 | isa = PBXNativeTarget; 247 | buildConfigurationList = DC808D9929E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "StandupsUITests" */; 248 | buildPhases = ( 249 | DC808D8529E9C3AD0072B4A9 /* Sources */, 250 | DC808D8629E9C3AD0072B4A9 /* Frameworks */, 251 | DC808D8729E9C3AD0072B4A9 /* Resources */, 252 | ); 253 | buildRules = ( 254 | ); 255 | dependencies = ( 256 | DC808D8B29E9C3AD0072B4A9 /* PBXTargetDependency */, 257 | ); 258 | name = StandupsUITests; 259 | productName = StandupsUITests; 260 | productReference = DC808D8929E9C3AD0072B4A9 /* StandupsUITests.xctest */; 261 | productType = "com.apple.product-type.bundle.ui-testing"; 262 | }; 263 | /* End PBXNativeTarget section */ 264 | 265 | /* Begin PBXProject section */ 266 | DC808D6729E9C3AC0072B4A9 /* Project object */ = { 267 | isa = PBXProject; 268 | attributes = { 269 | BuildIndependentTargetsInParallel = 1; 270 | LastSwiftUpdateCheck = 1430; 271 | LastUpgradeCheck = 1430; 272 | TargetAttributes = { 273 | DC808D6E29E9C3AC0072B4A9 = { 274 | CreatedOnToolsVersion = 14.3; 275 | }; 276 | DC808D7E29E9C3AD0072B4A9 = { 277 | CreatedOnToolsVersion = 14.3; 278 | TestTargetID = DC808D6E29E9C3AC0072B4A9; 279 | }; 280 | DC808D8829E9C3AD0072B4A9 = { 281 | CreatedOnToolsVersion = 14.3; 282 | TestTargetID = DC808D6E29E9C3AC0072B4A9; 283 | }; 284 | }; 285 | }; 286 | buildConfigurationList = DC808D6A29E9C3AC0072B4A9 /* Build configuration list for PBXProject "Standups" */; 287 | compatibilityVersion = "Xcode 14.0"; 288 | developmentRegion = en; 289 | hasScannedForEncodings = 0; 290 | knownRegions = ( 291 | en, 292 | Base, 293 | ); 294 | mainGroup = DC808D6629E9C3AC0072B4A9; 295 | packageReferences = ( 296 | DC808DB429E9C58F0072B4A9 /* XCRemoteSwiftPackageReference "swift-tagged" */, 297 | CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, 298 | 316511F02A84398300666623 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, 299 | 316511F52A8439B000666623 /* XCRemoteSwiftPackageReference "Pulse" */, 300 | 316511FA2A8439C600666623 /* XCRemoteSwiftPackageReference "PulseLogHandler" */, 301 | ); 302 | productRefGroup = DC808D7029E9C3AC0072B4A9 /* Products */; 303 | projectDirPath = ""; 304 | projectRoot = ""; 305 | targets = ( 306 | DC808D6E29E9C3AC0072B4A9 /* Standups */, 307 | DC808D7E29E9C3AD0072B4A9 /* StandupsTests */, 308 | DC808D8829E9C3AD0072B4A9 /* StandupsUITests */, 309 | ); 310 | }; 311 | /* End PBXProject section */ 312 | 313 | /* Begin PBXResourcesBuildPhase section */ 314 | DC808D6D29E9C3AC0072B4A9 /* Resources */ = { 315 | isa = PBXResourcesBuildPhase; 316 | buildActionMask = 2147483647; 317 | files = ( 318 | DC808D7729E9C3AD0072B4A9 /* Assets.xcassets in Resources */, 319 | DC7CE4E729E9E6E4006B6263 /* ding.wav in Resources */, 320 | ); 321 | runOnlyForDeploymentPostprocessing = 0; 322 | }; 323 | DC808D7D29E9C3AD0072B4A9 /* Resources */ = { 324 | isa = PBXResourcesBuildPhase; 325 | buildActionMask = 2147483647; 326 | files = ( 327 | ); 328 | runOnlyForDeploymentPostprocessing = 0; 329 | }; 330 | DC808D8729E9C3AD0072B4A9 /* Resources */ = { 331 | isa = PBXResourcesBuildPhase; 332 | buildActionMask = 2147483647; 333 | files = ( 334 | ); 335 | runOnlyForDeploymentPostprocessing = 0; 336 | }; 337 | /* End PBXResourcesBuildPhase section */ 338 | 339 | /* Begin PBXSourcesBuildPhase section */ 340 | DC808D6B29E9C3AC0072B4A9 /* Sources */ = { 341 | isa = PBXSourcesBuildPhase; 342 | buildActionMask = 2147483647; 343 | files = ( 344 | DC808DAB29E9C51D0072B4A9 /* Meeting.swift in Sources */, 345 | DC808DA729E9C4D60072B4A9 /* SpeechRecognizer.swift in Sources */, 346 | DC80B1E929EDE5D1001CC0CC /* AppFeature.swift in Sources */, 347 | DC808DA529E9C4C70072B4A9 /* OpenSettings.swift in Sources */, 348 | DC808DAD29E9C52A0072B4A9 /* Models.swift in Sources */, 349 | DC808DB129E9C54A0072B4A9 /* StandupDetail.swift in Sources */, 350 | DC808DA029E9C4340072B4A9 /* DataManager.swift in Sources */, 351 | DC808D7329E9C3AC0072B4A9 /* App.swift in Sources */, 352 | DC808DB329E9C5540072B4A9 /* StandupsList.swift in Sources */, 353 | DC808DAF29E9C53E0072B4A9 /* RecordMeeting.swift in Sources */, 354 | DC808DA929E9C5090072B4A9 /* StandupForm.swift in Sources */, 355 | ); 356 | runOnlyForDeploymentPostprocessing = 0; 357 | }; 358 | DC808D7B29E9C3AD0072B4A9 /* Sources */ = { 359 | isa = PBXSourcesBuildPhase; 360 | buildActionMask = 2147483647; 361 | files = ( 362 | DC8C2B0C29EB084900C65286 /* RecordMeetingTests.swift in Sources */, 363 | DC80B1EB29EE12A7001CC0CC /* AppFeatureTests.swift in Sources */, 364 | DC8C2B0E29EB085600C65286 /* StandupDetailTests.swift in Sources */, 365 | DC808D8429E9C3AD0072B4A9 /* StandupFormTests.swift in Sources */, 366 | DC8C2B1029EB086A00C65286 /* StandupsListTests.swift in Sources */, 367 | ); 368 | runOnlyForDeploymentPostprocessing = 0; 369 | }; 370 | DC808D8529E9C3AD0072B4A9 /* Sources */ = { 371 | isa = PBXSourcesBuildPhase; 372 | buildActionMask = 2147483647; 373 | files = ( 374 | DC808D8E29E9C3AD0072B4A9 /* StandupsUITests.swift in Sources */, 375 | ); 376 | runOnlyForDeploymentPostprocessing = 0; 377 | }; 378 | /* End PBXSourcesBuildPhase section */ 379 | 380 | /* Begin PBXTargetDependency section */ 381 | DC808D8129E9C3AD0072B4A9 /* PBXTargetDependency */ = { 382 | isa = PBXTargetDependency; 383 | target = DC808D6E29E9C3AC0072B4A9 /* Standups */; 384 | targetProxy = DC808D8029E9C3AD0072B4A9 /* PBXContainerItemProxy */; 385 | }; 386 | DC808D8B29E9C3AD0072B4A9 /* PBXTargetDependency */ = { 387 | isa = PBXTargetDependency; 388 | target = DC808D6E29E9C3AC0072B4A9 /* Standups */; 389 | targetProxy = DC808D8A29E9C3AD0072B4A9 /* PBXContainerItemProxy */; 390 | }; 391 | /* End PBXTargetDependency section */ 392 | 393 | /* Begin XCBuildConfiguration section */ 394 | DC808D9129E9C3AD0072B4A9 /* Debug */ = { 395 | isa = XCBuildConfiguration; 396 | buildSettings = { 397 | ALWAYS_SEARCH_USER_PATHS = NO; 398 | CLANG_ANALYZER_NONNULL = YES; 399 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 400 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 401 | CLANG_ENABLE_MODULES = YES; 402 | CLANG_ENABLE_OBJC_ARC = YES; 403 | CLANG_ENABLE_OBJC_WEAK = YES; 404 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 405 | CLANG_WARN_BOOL_CONVERSION = YES; 406 | CLANG_WARN_COMMA = YES; 407 | CLANG_WARN_CONSTANT_CONVERSION = YES; 408 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 409 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 410 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 411 | CLANG_WARN_EMPTY_BODY = YES; 412 | CLANG_WARN_ENUM_CONVERSION = YES; 413 | CLANG_WARN_INFINITE_RECURSION = YES; 414 | CLANG_WARN_INT_CONVERSION = YES; 415 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 416 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 417 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 418 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 419 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 420 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 421 | CLANG_WARN_STRICT_PROTOTYPES = YES; 422 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 423 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 424 | CLANG_WARN_UNREACHABLE_CODE = YES; 425 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 426 | COPY_PHASE_STRIP = NO; 427 | DEBUG_INFORMATION_FORMAT = dwarf; 428 | ENABLE_STRICT_OBJC_MSGSEND = YES; 429 | ENABLE_TESTABILITY = YES; 430 | GCC_C_LANGUAGE_STANDARD = gnu11; 431 | GCC_DYNAMIC_NO_PIC = NO; 432 | GCC_NO_COMMON_BLOCKS = YES; 433 | GCC_OPTIMIZATION_LEVEL = 0; 434 | GCC_PREPROCESSOR_DEFINITIONS = ( 435 | "DEBUG=1", 436 | "$(inherited)", 437 | ); 438 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 439 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 440 | GCC_WARN_UNDECLARED_SELECTOR = YES; 441 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 442 | GCC_WARN_UNUSED_FUNCTION = YES; 443 | GCC_WARN_UNUSED_VARIABLE = YES; 444 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 445 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 446 | MTL_FAST_MATH = YES; 447 | ONLY_ACTIVE_ARCH = YES; 448 | SDKROOT = iphoneos; 449 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 450 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 451 | SWIFT_STRICT_CONCURRENCY = complete; 452 | }; 453 | name = Debug; 454 | }; 455 | DC808D9229E9C3AD0072B4A9 /* Release */ = { 456 | isa = XCBuildConfiguration; 457 | buildSettings = { 458 | ALWAYS_SEARCH_USER_PATHS = NO; 459 | CLANG_ANALYZER_NONNULL = YES; 460 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 461 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 462 | CLANG_ENABLE_MODULES = YES; 463 | CLANG_ENABLE_OBJC_ARC = YES; 464 | CLANG_ENABLE_OBJC_WEAK = YES; 465 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 466 | CLANG_WARN_BOOL_CONVERSION = YES; 467 | CLANG_WARN_COMMA = YES; 468 | CLANG_WARN_CONSTANT_CONVERSION = YES; 469 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 470 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 471 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 472 | CLANG_WARN_EMPTY_BODY = YES; 473 | CLANG_WARN_ENUM_CONVERSION = YES; 474 | CLANG_WARN_INFINITE_RECURSION = YES; 475 | CLANG_WARN_INT_CONVERSION = YES; 476 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 477 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 478 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 479 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 480 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 481 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 482 | CLANG_WARN_STRICT_PROTOTYPES = YES; 483 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 484 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 485 | CLANG_WARN_UNREACHABLE_CODE = YES; 486 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 487 | COPY_PHASE_STRIP = NO; 488 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 489 | ENABLE_NS_ASSERTIONS = NO; 490 | ENABLE_STRICT_OBJC_MSGSEND = YES; 491 | GCC_C_LANGUAGE_STANDARD = gnu11; 492 | GCC_NO_COMMON_BLOCKS = YES; 493 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 494 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 495 | GCC_WARN_UNDECLARED_SELECTOR = YES; 496 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 497 | GCC_WARN_UNUSED_FUNCTION = YES; 498 | GCC_WARN_UNUSED_VARIABLE = YES; 499 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 500 | MTL_ENABLE_DEBUG_INFO = NO; 501 | MTL_FAST_MATH = YES; 502 | SDKROOT = iphoneos; 503 | SWIFT_COMPILATION_MODE = wholemodule; 504 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 505 | SWIFT_STRICT_CONCURRENCY = complete; 506 | VALIDATE_PRODUCT = YES; 507 | }; 508 | name = Release; 509 | }; 510 | DC808D9429E9C3AD0072B4A9 /* Debug */ = { 511 | isa = XCBuildConfiguration; 512 | buildSettings = { 513 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 514 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 | CODE_SIGN_STYLE = Automatic; 516 | CURRENT_PROJECT_VERSION = 1; 517 | DEVELOPMENT_TEAM = VFRXY8HC3H; 518 | ENABLE_PREVIEWS = YES; 519 | GENERATE_INFOPLIST_FILE = YES; 520 | INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To transcribe meeting notes."; 521 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 522 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 523 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 524 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 525 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 526 | LD_RUNPATH_SEARCH_PATHS = ( 527 | "$(inherited)", 528 | "@executable_path/Frameworks", 529 | ); 530 | MARKETING_VERSION = 1.0; 531 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Standups; 532 | PRODUCT_NAME = "$(TARGET_NAME)"; 533 | SWIFT_EMIT_LOC_STRINGS = YES; 534 | SWIFT_VERSION = 5.0; 535 | TARGETED_DEVICE_FAMILY = "1,2"; 536 | }; 537 | name = Debug; 538 | }; 539 | DC808D9529E9C3AD0072B4A9 /* Release */ = { 540 | isa = XCBuildConfiguration; 541 | buildSettings = { 542 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 543 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 544 | CODE_SIGN_STYLE = Automatic; 545 | CURRENT_PROJECT_VERSION = 1; 546 | DEVELOPMENT_TEAM = VFRXY8HC3H; 547 | ENABLE_PREVIEWS = YES; 548 | GENERATE_INFOPLIST_FILE = YES; 549 | INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "To transcribe meeting notes."; 550 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 551 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 552 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 553 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 554 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 555 | LD_RUNPATH_SEARCH_PATHS = ( 556 | "$(inherited)", 557 | "@executable_path/Frameworks", 558 | ); 559 | MARKETING_VERSION = 1.0; 560 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Standups; 561 | PRODUCT_NAME = "$(TARGET_NAME)"; 562 | SWIFT_EMIT_LOC_STRINGS = YES; 563 | SWIFT_VERSION = 5.0; 564 | TARGETED_DEVICE_FAMILY = "1,2"; 565 | }; 566 | name = Release; 567 | }; 568 | DC808D9729E9C3AD0072B4A9 /* Debug */ = { 569 | isa = XCBuildConfiguration; 570 | buildSettings = { 571 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 572 | BUNDLE_LOADER = "$(TEST_HOST)"; 573 | CODE_SIGN_STYLE = Automatic; 574 | CURRENT_PROJECT_VERSION = 1; 575 | DEVELOPMENT_TEAM = VFRXY8HC3H; 576 | GENERATE_INFOPLIST_FILE = YES; 577 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 578 | MARKETING_VERSION = 1.0; 579 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.StandupsTests; 580 | PRODUCT_NAME = "$(TARGET_NAME)"; 581 | SWIFT_EMIT_LOC_STRINGS = NO; 582 | SWIFT_VERSION = 5.0; 583 | TARGETED_DEVICE_FAMILY = "1,2"; 584 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Standups.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Standups"; 585 | }; 586 | name = Debug; 587 | }; 588 | DC808D9829E9C3AD0072B4A9 /* Release */ = { 589 | isa = XCBuildConfiguration; 590 | buildSettings = { 591 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 592 | BUNDLE_LOADER = "$(TEST_HOST)"; 593 | CODE_SIGN_STYLE = Automatic; 594 | CURRENT_PROJECT_VERSION = 1; 595 | DEVELOPMENT_TEAM = VFRXY8HC3H; 596 | GENERATE_INFOPLIST_FILE = YES; 597 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 598 | MARKETING_VERSION = 1.0; 599 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.StandupsTests; 600 | PRODUCT_NAME = "$(TARGET_NAME)"; 601 | SWIFT_EMIT_LOC_STRINGS = NO; 602 | SWIFT_VERSION = 5.0; 603 | TARGETED_DEVICE_FAMILY = "1,2"; 604 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Standups.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Standups"; 605 | }; 606 | name = Release; 607 | }; 608 | DC808D9A29E9C3AD0072B4A9 /* Debug */ = { 609 | isa = XCBuildConfiguration; 610 | buildSettings = { 611 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 612 | CODE_SIGN_STYLE = Automatic; 613 | CURRENT_PROJECT_VERSION = 1; 614 | DEVELOPMENT_TEAM = VFRXY8HC3H; 615 | GENERATE_INFOPLIST_FILE = YES; 616 | MARKETING_VERSION = 1.0; 617 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.StandupsUITests; 618 | PRODUCT_NAME = "$(TARGET_NAME)"; 619 | SWIFT_EMIT_LOC_STRINGS = NO; 620 | SWIFT_VERSION = 5.0; 621 | TARGETED_DEVICE_FAMILY = "1,2"; 622 | TEST_TARGET_NAME = Standups; 623 | }; 624 | name = Debug; 625 | }; 626 | DC808D9B29E9C3AD0072B4A9 /* Release */ = { 627 | isa = XCBuildConfiguration; 628 | buildSettings = { 629 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 630 | CODE_SIGN_STYLE = Automatic; 631 | CURRENT_PROJECT_VERSION = 1; 632 | DEVELOPMENT_TEAM = VFRXY8HC3H; 633 | GENERATE_INFOPLIST_FILE = YES; 634 | MARKETING_VERSION = 1.0; 635 | PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.StandupsUITests; 636 | PRODUCT_NAME = "$(TARGET_NAME)"; 637 | SWIFT_EMIT_LOC_STRINGS = NO; 638 | SWIFT_VERSION = 5.0; 639 | TARGETED_DEVICE_FAMILY = "1,2"; 640 | TEST_TARGET_NAME = Standups; 641 | }; 642 | name = Release; 643 | }; 644 | /* End XCBuildConfiguration section */ 645 | 646 | /* Begin XCConfigurationList section */ 647 | DC808D6A29E9C3AC0072B4A9 /* Build configuration list for PBXProject "Standups" */ = { 648 | isa = XCConfigurationList; 649 | buildConfigurations = ( 650 | DC808D9129E9C3AD0072B4A9 /* Debug */, 651 | DC808D9229E9C3AD0072B4A9 /* Release */, 652 | ); 653 | defaultConfigurationIsVisible = 0; 654 | defaultConfigurationName = Release; 655 | }; 656 | DC808D9329E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "Standups" */ = { 657 | isa = XCConfigurationList; 658 | buildConfigurations = ( 659 | DC808D9429E9C3AD0072B4A9 /* Debug */, 660 | DC808D9529E9C3AD0072B4A9 /* Release */, 661 | ); 662 | defaultConfigurationIsVisible = 0; 663 | defaultConfigurationName = Release; 664 | }; 665 | DC808D9629E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "StandupsTests" */ = { 666 | isa = XCConfigurationList; 667 | buildConfigurations = ( 668 | DC808D9729E9C3AD0072B4A9 /* Debug */, 669 | DC808D9829E9C3AD0072B4A9 /* Release */, 670 | ); 671 | defaultConfigurationIsVisible = 0; 672 | defaultConfigurationName = Release; 673 | }; 674 | DC808D9929E9C3AD0072B4A9 /* Build configuration list for PBXNativeTarget "StandupsUITests" */ = { 675 | isa = XCConfigurationList; 676 | buildConfigurations = ( 677 | DC808D9A29E9C3AD0072B4A9 /* Debug */, 678 | DC808D9B29E9C3AD0072B4A9 /* Release */, 679 | ); 680 | defaultConfigurationIsVisible = 0; 681 | defaultConfigurationName = Release; 682 | }; 683 | /* End XCConfigurationList section */ 684 | 685 | /* Begin XCRemoteSwiftPackageReference section */ 686 | 316511F02A84398300666623 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { 687 | isa = XCRemoteSwiftPackageReference; 688 | repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; 689 | requirement = { 690 | kind = upToNextMajorVersion; 691 | minimumVersion = 1.0.0; 692 | }; 693 | }; 694 | 316511F52A8439B000666623 /* XCRemoteSwiftPackageReference "Pulse" */ = { 695 | isa = XCRemoteSwiftPackageReference; 696 | repositoryURL = "https://github.com/kean/Pulse.git"; 697 | requirement = { 698 | kind = upToNextMajorVersion; 699 | minimumVersion = 4.0.0; 700 | }; 701 | }; 702 | 316511FA2A8439C600666623 /* XCRemoteSwiftPackageReference "PulseLogHandler" */ = { 703 | isa = XCRemoteSwiftPackageReference; 704 | repositoryURL = "https://github.com/kean/PulseLogHandler.git"; 705 | requirement = { 706 | kind = upToNextMajorVersion; 707 | minimumVersion = 4.0.0; 708 | }; 709 | }; 710 | CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { 711 | isa = XCRemoteSwiftPackageReference; 712 | repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; 713 | requirement = { 714 | kind = upToNextMajorVersion; 715 | minimumVersion = 1.0.0; 716 | }; 717 | }; 718 | DC808DB429E9C58F0072B4A9 /* XCRemoteSwiftPackageReference "swift-tagged" */ = { 719 | isa = XCRemoteSwiftPackageReference; 720 | repositoryURL = "https://github.com/pointfreeco/swift-tagged.git"; 721 | requirement = { 722 | kind = upToNextMajorVersion; 723 | minimumVersion = 0.10.0; 724 | }; 725 | }; 726 | /* End XCRemoteSwiftPackageReference section */ 727 | 728 | /* Begin XCSwiftPackageProductDependency section */ 729 | 316511EB2A84391700666623 /* TCASwiftLog */ = { 730 | isa = XCSwiftPackageProductDependency; 731 | productName = TCASwiftLog; 732 | }; 733 | 316511F32A84399A00666623 /* ComposableArchitecture */ = { 734 | isa = XCSwiftPackageProductDependency; 735 | package = 316511F02A84398300666623 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; 736 | productName = ComposableArchitecture; 737 | }; 738 | 316511F62A8439B100666623 /* Pulse */ = { 739 | isa = XCSwiftPackageProductDependency; 740 | package = 316511F52A8439B000666623 /* XCRemoteSwiftPackageReference "Pulse" */; 741 | productName = Pulse; 742 | }; 743 | 316511F82A8439B100666623 /* PulseUI */ = { 744 | isa = XCSwiftPackageProductDependency; 745 | package = 316511F52A8439B000666623 /* XCRemoteSwiftPackageReference "Pulse" */; 746 | productName = PulseUI; 747 | }; 748 | 316511FB2A8439C600666623 /* PulseLogHandler */ = { 749 | isa = XCSwiftPackageProductDependency; 750 | package = 316511FA2A8439C600666623 /* XCRemoteSwiftPackageReference "PulseLogHandler" */; 751 | productName = PulseLogHandler; 752 | }; 753 | CA0934B52A12A9680020DEF5 /* SwiftUINavigation */ = { 754 | isa = XCSwiftPackageProductDependency; 755 | package = CA0934B42A12A9680020DEF5 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; 756 | productName = SwiftUINavigation; 757 | }; 758 | DC808DB529E9C58F0072B4A9 /* Tagged */ = { 759 | isa = XCSwiftPackageProductDependency; 760 | package = DC808DB429E9C58F0072B4A9 /* XCRemoteSwiftPackageReference "swift-tagged" */; 761 | productName = Tagged; 762 | }; 763 | /* End XCSwiftPackageProductDependency section */ 764 | }; 765 | rootObject = DC808D6729E9C3AC0072B4A9 /* Project object */; 766 | } 767 | -------------------------------------------------------------------------------- /Examples/Standups/Standups.xcodeproj/xcshareddata/xcschemes/Standups.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 81 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/App.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Logging 3 | import PulseLogHandler 4 | import PulseUI 5 | import SwiftUI 6 | import TCASwiftLog 7 | 8 | @main 9 | struct StandupsApp: App { 10 | init() { 11 | LoggingSystem.bootstrap(PersistentLogHandler.init) 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | // NB: This conditional is here only to facilitate UI testing so that we can mock out certain 17 | // dependencies for the duration of the test (e.g. the data manager). We do not really 18 | // recommend performing UI tests in general, but we do want to demonstrate how it can be 19 | // done. 20 | if ProcessInfo.processInfo.environment["UITesting"] == "true" { 21 | UITestingView() 22 | } else if _XCTIsTesting { 23 | // NB: Don't run application when testing so that it doesn't interfere with tests. 24 | EmptyView() 25 | } else { 26 | TabView { 27 | AppView( 28 | store: Store(initialState: AppFeature.State()) { 29 | AppFeature() 30 | ._printChanges(.swiftLog(label: "ComposableArchitecture")) 31 | } 32 | ) 33 | .tabItem { 34 | Label("Standups", systemImage: "app") 35 | } 36 | 37 | NavigationStack { 38 | ConsoleView(store: .shared) 39 | } 40 | .tabItem { 41 | Label("Logs Console", systemImage: "list.dash.header.rectangle") 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | struct UITestingView: View { 50 | var body: some View { 51 | AppView( 52 | store: Store(initialState: AppFeature.State()) { 53 | AppFeature() 54 | } withDependencies: { 55 | $0.dataManager = .mock() 56 | } 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/AppFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct AppFeature: Reducer { 5 | struct State: Equatable { 6 | var path = StackState() 7 | var standupsList = StandupsList.State() 8 | } 9 | 10 | enum Action: Equatable { 11 | case path(StackAction) 12 | case standupsList(StandupsList.Action) 13 | } 14 | 15 | @Dependency(\.continuousClock) var clock 16 | @Dependency(\.date.now) var now 17 | @Dependency(\.dataManager.save) var saveData 18 | @Dependency(\.uuid) var uuid 19 | 20 | private enum CancelID { 21 | case saveDebounce 22 | } 23 | 24 | var body: some ReducerOf { 25 | Scope(state: \.standupsList, action: /Action.standupsList) { 26 | StandupsList() 27 | } 28 | Reduce { state, action in 29 | switch action { 30 | case let .path(.element(id, .detail(.delegate(delegateAction)))): 31 | guard case let .some(.detail(detailState)) = state.path[id: id] 32 | else { return .none } 33 | 34 | switch delegateAction { 35 | case .deleteStandup: 36 | state.standupsList.standups.remove(id: detailState.standup.id) 37 | return .none 38 | 39 | case let .standupUpdated(standup): 40 | state.standupsList.standups[id: standup.id] = standup 41 | return .none 42 | 43 | case .startMeeting: 44 | state.path.append(.record(RecordMeeting.State(standup: detailState.standup))) 45 | return .none 46 | } 47 | 48 | case let .path(.element(_, .record(.delegate(delegateAction)))): 49 | switch delegateAction { 50 | case let .save(transcript: transcript): 51 | guard let id = state.path.ids.dropLast().last 52 | else { 53 | XCTFail( 54 | """ 55 | Record meeting is the only element in the stack. A detail feature should precede it. 56 | """ 57 | ) 58 | return .none 59 | } 60 | 61 | state.path[id: id, case: /Path.State.detail]?.standup.meetings.insert( 62 | Meeting( 63 | id: Meeting.ID(self.uuid()), 64 | date: self.now, 65 | transcript: transcript 66 | ), 67 | at: 0 68 | ) 69 | guard let standup = state.path[id: id, case: /Path.State.detail]?.standup 70 | else { return .none } 71 | state.standupsList.standups[id: standup.id] = standup 72 | return .none 73 | } 74 | 75 | case .path: 76 | return .none 77 | 78 | case .standupsList: 79 | return .none 80 | } 81 | } 82 | .forEach(\.path, action: /Action.path) { 83 | Path() 84 | } 85 | 86 | Reduce { state, action in 87 | return .run { [standups = state.standupsList.standups] _ in 88 | try await withTaskCancellation(id: CancelID.saveDebounce, cancelInFlight: true) { 89 | try await self.clock.sleep(for: .seconds(1)) 90 | try await self.saveData(JSONEncoder().encode(standups), .standups) 91 | } 92 | } catch: { _, _ in 93 | } 94 | } 95 | } 96 | 97 | struct Path: Reducer { 98 | enum State: Equatable { 99 | case detail(StandupDetail.State) 100 | case meeting(MeetingReducer.State) 101 | case record(RecordMeeting.State) 102 | } 103 | 104 | enum Action: Equatable { 105 | case detail(StandupDetail.Action) 106 | case meeting(MeetingReducer.Action) 107 | case record(RecordMeeting.Action) 108 | } 109 | 110 | var body: some Reducer { 111 | Scope(state: /State.detail, action: /Action.detail) { 112 | StandupDetail() 113 | } 114 | Scope(state: /State.meeting, action: /Action.meeting) { 115 | MeetingReducer() 116 | } 117 | Scope(state: /State.record, action: /Action.record) { 118 | RecordMeeting() 119 | } 120 | } 121 | } 122 | } 123 | 124 | struct AppView: View { 125 | let store: StoreOf 126 | 127 | var body: some View { 128 | NavigationStackStore(self.store.scope(state: \.path, action: { .path($0) })) { 129 | StandupsListView( 130 | store: self.store.scope(state: \.standupsList, action: { .standupsList($0) }) 131 | ) 132 | } destination: { 133 | switch $0 { 134 | case .detail: 135 | CaseLet( 136 | /AppFeature.Path.State.detail, 137 | action: AppFeature.Path.Action.detail, 138 | then: StandupDetailView.init(store:) 139 | ) 140 | case .meeting: 141 | CaseLet( 142 | /AppFeature.Path.State.meeting, 143 | action: AppFeature.Path.Action.meeting, 144 | then: MeetingView.init(store:) 145 | ) 146 | case .record: 147 | CaseLet( 148 | /AppFeature.Path.State.record, 149 | action: AppFeature.Path.Action.record, 150 | then: RecordMeetingView.init(store:) 151 | ) 152 | } 153 | } 154 | } 155 | } 156 | 157 | extension URL { 158 | static let standups = Self.documentsDirectory.appending(component: "standups.json") 159 | } 160 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/bubblegum.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.820", 9 | "green" : "0.502", 10 | "red" : "0.933" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.820", 27 | "green" : "0.502", 28 | "red" : "0.933" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/buttercup.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.588", 9 | "green" : "0.945", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.588", 27 | "green" : "0.945", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/indigo.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.443", 9 | "green" : "0.000", 10 | "red" : "0.212" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.443", 27 | "green" : "0.000", 28 | "red" : "0.212" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/lavender.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.808", 10 | "red" : "0.812" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.808", 28 | "red" : "0.812" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/magenta.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.467", 9 | "green" : "0.075", 10 | "red" : "0.647" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.467", 27 | "green" : "0.075", 28 | "red" : "0.647" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/navy.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.255", 9 | "green" : "0.078", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.255", 27 | "green" : "0.078", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/orange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.259", 9 | "green" : "0.545", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.259", 27 | "green" : "0.545", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/oxblood.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.043", 9 | "green" : "0.027", 10 | "red" : "0.290" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.043", 27 | "green" : "0.027", 28 | "red" : "0.290" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/periwinkle.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.510", 10 | "red" : "0.525" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.510", 28 | "red" : "0.525" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/poppy.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.369", 9 | "green" : "0.369", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.369", 27 | "green" : "0.369", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/purple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.949", 9 | "green" : "0.294", 10 | "red" : "0.569" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.949", 27 | "green" : "0.294", 28 | "red" : "0.569" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/seafoam.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.898", 9 | "green" : "0.918", 10 | "red" : "0.796" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.898", 27 | "green" : "0.918", 28 | "red" : "0.796" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/sky.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.573", 10 | "red" : "0.431" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.573", 28 | "red" : "0.431" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/tan.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.494", 9 | "green" : "0.608", 10 | "red" : "0.761" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.494", 27 | "green" : "0.608", 28 | "red" : "0.761" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/teal.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.620", 9 | "green" : "0.561", 10 | "red" : "0.133" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.620", 27 | "green" : "0.561", 28 | "red" : "0.133" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Assets.xcassets/Themes/yellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.302", 9 | "green" : "0.875", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.302", 27 | "green" : "0.875", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Meeting.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct MeetingReducer: Reducer { 5 | struct State: Equatable { 6 | let meeting: Meeting 7 | let standup: Standup 8 | } 9 | enum Action: Equatable {} 10 | func reduce(into state: inout State, action: Action) -> Effect { 11 | } 12 | } 13 | 14 | struct MeetingView: View { 15 | let store: StoreOf 16 | 17 | var body: some View { 18 | WithViewStore(self.store, observe: { $0 }) { viewStore in 19 | ScrollView { 20 | VStack(alignment: .leading) { 21 | Divider() 22 | .padding(.bottom) 23 | Text("Attendees") 24 | .font(.headline) 25 | ForEach(viewStore.standup.attendees) { attendee in 26 | Text(attendee.name) 27 | } 28 | Text("Transcript") 29 | .font(.headline) 30 | .padding(.top) 31 | Text(viewStore.meeting.transcript) 32 | } 33 | } 34 | .navigationTitle(Text(viewStore.meeting.date, style: .date)) 35 | .padding() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Models.swift: -------------------------------------------------------------------------------- 1 | import IdentifiedCollections 2 | import SwiftUI 3 | import Tagged 4 | 5 | struct Standup: Equatable, Identifiable, Codable { 6 | let id: Tagged 7 | var attendees: IdentifiedArrayOf = [] 8 | var duration: Duration = .seconds(60 * 5) 9 | var meetings: IdentifiedArrayOf = [] 10 | var theme: Theme = .bubblegum 11 | var title = "" 12 | 13 | var durationPerAttendee: Duration { 14 | self.duration / self.attendees.count 15 | } 16 | } 17 | 18 | struct Attendee: Equatable, Identifiable, Codable { 19 | let id: Tagged 20 | var name = "" 21 | } 22 | 23 | struct Meeting: Equatable, Identifiable, Codable { 24 | let id: Tagged 25 | let date: Date 26 | var transcript: String 27 | } 28 | 29 | enum Theme: String, CaseIterable, Equatable, Identifiable, Codable { 30 | case bubblegum 31 | case buttercup 32 | case indigo 33 | case lavender 34 | case magenta 35 | case navy 36 | case orange 37 | case oxblood 38 | case periwinkle 39 | case poppy 40 | case purple 41 | case seafoam 42 | case sky 43 | case tan 44 | case teal 45 | case yellow 46 | 47 | var id: Self { self } 48 | 49 | var accentColor: Color { 50 | switch self { 51 | case .bubblegum, .buttercup, .lavender, .orange, .periwinkle, .poppy, .seafoam, .sky, .tan, 52 | .teal, .yellow: 53 | return .black 54 | case .indigo, .magenta, .navy, .oxblood, .purple: 55 | return .white 56 | } 57 | } 58 | 59 | var mainColor: Color { Color(self.rawValue) } 60 | 61 | var name: String { self.rawValue.capitalized } 62 | } 63 | 64 | extension Standup { 65 | static let mock = Self( 66 | id: Standup.ID(), 67 | attendees: [ 68 | Attendee(id: Attendee.ID(), name: "Blob"), 69 | Attendee(id: Attendee.ID(), name: "Blob Jr"), 70 | Attendee(id: Attendee.ID(), name: "Blob Sr"), 71 | Attendee(id: Attendee.ID(), name: "Blob Esq"), 72 | Attendee(id: Attendee.ID(), name: "Blob III"), 73 | Attendee(id: Attendee.ID(), name: "Blob I"), 74 | ], 75 | duration: .seconds(60), 76 | meetings: [ 77 | Meeting( 78 | id: Meeting.ID(), 79 | date: Date().addingTimeInterval(-60 * 60 * 24 * 7), 80 | transcript: """ 81 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ 82 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ 83 | exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ 84 | dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ 85 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ 86 | mollit anim id est laborum. 87 | """ 88 | ) 89 | ], 90 | theme: .orange, 91 | title: "Design" 92 | ) 93 | 94 | static let engineeringMock = Self( 95 | id: Standup.ID(), 96 | attendees: [ 97 | Attendee(id: Attendee.ID(), name: "Blob"), 98 | Attendee(id: Attendee.ID(), name: "Blob Jr"), 99 | ], 100 | duration: .seconds(60 * 10), 101 | theme: .periwinkle, 102 | title: "Engineering" 103 | ) 104 | 105 | static let designMock = Self( 106 | id: Standup.ID(), 107 | attendees: [ 108 | Attendee(id: Attendee.ID(), name: "Blob Sr"), 109 | Attendee(id: Attendee.ID(), name: "Blob Jr"), 110 | ], 111 | duration: .seconds(60 * 30), 112 | theme: .poppy, 113 | title: "Product" 114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/RecordMeeting.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Speech 3 | import SwiftUI 4 | 5 | struct RecordMeeting: Reducer { 6 | struct State: Equatable { 7 | @PresentationState var alert: AlertState? 8 | var secondsElapsed = 0 9 | var speakerIndex = 0 10 | var standup: Standup 11 | var transcript = "" 12 | 13 | var durationRemaining: Duration { 14 | self.standup.duration - .seconds(self.secondsElapsed) 15 | } 16 | } 17 | enum Action: Equatable { 18 | case alert(PresentationAction) 19 | case delegate(Delegate) 20 | case endMeetingButtonTapped 21 | case nextButtonTapped 22 | case onTask 23 | case timerTick 24 | case speechFailure 25 | case speechResult(SpeechRecognitionResult) 26 | 27 | enum Alert { 28 | case confirmDiscard 29 | case confirmSave 30 | } 31 | enum Delegate: Equatable { 32 | case save(transcript: String) 33 | } 34 | } 35 | 36 | @Dependency(\.continuousClock) var clock 37 | @Dependency(\.dismiss) var dismiss 38 | @Dependency(\.speechClient) var speechClient 39 | 40 | var body: some ReducerOf { 41 | Reduce { state, action in 42 | switch action { 43 | case .alert(.presented(.confirmDiscard)): 44 | return .run { _ in 45 | await self.dismiss() 46 | } 47 | 48 | case .alert(.presented(.confirmSave)): 49 | return .run { [transcript = state.transcript] send in 50 | await send(.delegate(.save(transcript: transcript))) 51 | await self.dismiss() 52 | } 53 | 54 | case .alert: 55 | return .none 56 | 57 | case .delegate: 58 | return .none 59 | 60 | case .endMeetingButtonTapped: 61 | state.alert = .endMeeting(isDiscardable: true) 62 | return .none 63 | 64 | case .nextButtonTapped: 65 | guard state.speakerIndex < state.standup.attendees.count - 1 66 | else { 67 | state.alert = .endMeeting(isDiscardable: false) 68 | return .none 69 | } 70 | state.speakerIndex += 1 71 | state.secondsElapsed = 72 | state.speakerIndex * Int(state.standup.durationPerAttendee.components.seconds) 73 | return .none 74 | 75 | case .onTask: 76 | return .run { send in 77 | let authorization = 78 | await self.speechClient.authorizationStatus() == .notDetermined 79 | ? self.speechClient.requestAuthorization() 80 | : self.speechClient.authorizationStatus() 81 | 82 | await withTaskGroup(of: Void.self) { group in 83 | if authorization == .authorized { 84 | group.addTask { 85 | await self.startSpeechRecognition(send: send) 86 | } 87 | } 88 | group.addTask { 89 | await self.startTimer(send: send) 90 | } 91 | } 92 | } 93 | 94 | case .timerTick: 95 | guard state.alert == nil 96 | else { return .none } 97 | 98 | state.secondsElapsed += 1 99 | 100 | let secondsPerAttendee = Int(state.standup.durationPerAttendee.components.seconds) 101 | if state.secondsElapsed.isMultiple(of: secondsPerAttendee) { 102 | if state.speakerIndex == state.standup.attendees.count - 1 { 103 | return .run { [transcript = state.transcript] send in 104 | await send(.delegate(.save(transcript: transcript))) 105 | await self.dismiss() 106 | } 107 | } 108 | state.speakerIndex += 1 109 | } 110 | 111 | return .none 112 | 113 | case .speechFailure: 114 | if !state.transcript.isEmpty { 115 | state.transcript += " ❌" 116 | } 117 | state.alert = .speechRecognizerFailed 118 | return .none 119 | 120 | case let .speechResult(result): 121 | state.transcript = result.bestTranscription.formattedString 122 | return .none 123 | } 124 | } 125 | .ifLet(\.$alert, action: /Action.alert) 126 | } 127 | 128 | private func startSpeechRecognition(send: Send) async { 129 | do { 130 | let speechTask = await self.speechClient.startTask(SFSpeechAudioBufferRecognitionRequest()) 131 | for try await result in speechTask { 132 | await send(.speechResult(result)) 133 | } 134 | } catch { 135 | await send(.speechFailure) 136 | } 137 | } 138 | 139 | private func startTimer(send: Send) async { 140 | for await _ in self.clock.timer(interval: .seconds(1)) { 141 | await send(.timerTick) 142 | } 143 | } 144 | } 145 | 146 | struct RecordMeetingView: View { 147 | let store: StoreOf 148 | 149 | struct ViewState: Equatable { 150 | let durationRemaining: Duration 151 | let secondsElapsed: Int 152 | let speakerIndex: Int 153 | let standup: Standup 154 | init(state: RecordMeeting.State) { 155 | self.durationRemaining = state.durationRemaining 156 | self.secondsElapsed = state.secondsElapsed 157 | self.standup = state.standup 158 | self.speakerIndex = state.speakerIndex 159 | } 160 | } 161 | 162 | var body: some View { 163 | WithViewStore(self.store, observe: ViewState.init) { viewStore in 164 | ZStack { 165 | RoundedRectangle(cornerRadius: 16) 166 | .fill(viewStore.standup.theme.mainColor) 167 | 168 | VStack { 169 | MeetingHeaderView( 170 | secondsElapsed: viewStore.secondsElapsed, 171 | durationRemaining: viewStore.durationRemaining, 172 | theme: viewStore.standup.theme 173 | ) 174 | MeetingTimerView( 175 | standup: viewStore.standup, 176 | speakerIndex: viewStore.speakerIndex 177 | ) 178 | MeetingFooterView( 179 | standup: viewStore.standup, 180 | nextButtonTapped: { 181 | viewStore.send(.nextButtonTapped) 182 | }, 183 | speakerIndex: viewStore.speakerIndex 184 | ) 185 | } 186 | } 187 | .padding() 188 | .foregroundColor(viewStore.standup.theme.accentColor) 189 | .navigationBarTitleDisplayMode(.inline) 190 | .toolbar { 191 | ToolbarItem(placement: .cancellationAction) { 192 | Button("End meeting") { 193 | viewStore.send(.endMeetingButtonTapped) 194 | } 195 | } 196 | } 197 | .navigationBarBackButtonHidden(true) 198 | .alert(store: self.store.scope(state: \.$alert, action: { .alert($0) })) 199 | .task { await viewStore.send(.onTask).finish() } 200 | } 201 | } 202 | } 203 | 204 | extension AlertState where Action == RecordMeeting.Action.Alert { 205 | static func endMeeting(isDiscardable: Bool) -> Self { 206 | Self { 207 | TextState("End meeting?") 208 | } actions: { 209 | ButtonState(action: .confirmSave) { 210 | TextState("Save and end") 211 | } 212 | if isDiscardable { 213 | ButtonState(role: .destructive, action: .confirmDiscard) { 214 | TextState("Discard") 215 | } 216 | } 217 | ButtonState(role: .cancel) { 218 | TextState("Resume") 219 | } 220 | } message: { 221 | TextState("You are ending the meeting early. What would you like to do?") 222 | } 223 | } 224 | 225 | static let speechRecognizerFailed = Self { 226 | TextState("Speech recognition failure") 227 | } actions: { 228 | ButtonState(role: .cancel) { 229 | TextState("Continue meeting") 230 | } 231 | ButtonState(role: .destructive, action: .confirmDiscard) { 232 | TextState("Discard meeting") 233 | } 234 | } message: { 235 | TextState( 236 | """ 237 | The speech recognizer has failed for some reason and so your meeting will no longer be \ 238 | recorded. What do you want to do? 239 | """ 240 | ) 241 | } 242 | } 243 | 244 | struct MeetingHeaderView: View { 245 | let secondsElapsed: Int 246 | let durationRemaining: Duration 247 | let theme: Theme 248 | 249 | var body: some View { 250 | VStack { 251 | ProgressView(value: self.progress) 252 | .progressViewStyle(MeetingProgressViewStyle(theme: self.theme)) 253 | HStack { 254 | VStack(alignment: .leading) { 255 | Text("Time Elapsed") 256 | .font(.caption) 257 | Label( 258 | Duration.seconds(self.secondsElapsed).formatted(.units()), 259 | systemImage: "hourglass.bottomhalf.fill" 260 | ) 261 | } 262 | Spacer() 263 | VStack(alignment: .trailing) { 264 | Text("Time Remaining") 265 | .font(.caption) 266 | Label(self.durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill") 267 | .font(.body.monospacedDigit()) 268 | .labelStyle(.trailingIcon) 269 | } 270 | } 271 | } 272 | .padding([.top, .horizontal]) 273 | } 274 | 275 | private var totalDuration: Duration { 276 | .seconds(self.secondsElapsed) + self.durationRemaining 277 | } 278 | 279 | private var progress: Double { 280 | guard self.totalDuration > .seconds(0) else { return 0 } 281 | return Double(self.secondsElapsed) / Double(self.totalDuration.components.seconds) 282 | } 283 | } 284 | 285 | struct MeetingProgressViewStyle: ProgressViewStyle { 286 | var theme: Theme 287 | 288 | func makeBody(configuration: Configuration) -> some View { 289 | ZStack { 290 | RoundedRectangle(cornerRadius: 10) 291 | .fill(self.theme.accentColor) 292 | .frame(height: 20) 293 | 294 | ProgressView(configuration) 295 | .tint(self.theme.mainColor) 296 | .frame(height: 12) 297 | .padding(.horizontal) 298 | } 299 | } 300 | } 301 | 302 | struct MeetingTimerView: View { 303 | let standup: Standup 304 | let speakerIndex: Int 305 | 306 | var body: some View { 307 | Circle() 308 | .strokeBorder(lineWidth: 24) 309 | .overlay { 310 | VStack { 311 | Group { 312 | if self.speakerIndex < self.standup.attendees.count { 313 | Text(self.standup.attendees[self.speakerIndex].name) 314 | } else { 315 | Text("Someone") 316 | } 317 | } 318 | .font(.title) 319 | Text("is speaking") 320 | Image(systemName: "mic.fill") 321 | .font(.largeTitle) 322 | .padding(.top) 323 | } 324 | .foregroundStyle(self.standup.theme.accentColor) 325 | } 326 | .overlay { 327 | ForEach(Array(self.standup.attendees.enumerated()), id: \.element.id) { index, attendee in 328 | if index < self.speakerIndex + 1 { 329 | SpeakerArc(totalSpeakers: self.standup.attendees.count, speakerIndex: index) 330 | .rotation(Angle(degrees: -90)) 331 | .stroke(self.standup.theme.mainColor, lineWidth: 12) 332 | } 333 | } 334 | } 335 | .padding(.horizontal) 336 | } 337 | } 338 | 339 | struct SpeakerArc: Shape { 340 | let totalSpeakers: Int 341 | let speakerIndex: Int 342 | 343 | func path(in rect: CGRect) -> Path { 344 | let diameter = min(rect.size.width, rect.size.height) - 24 345 | let radius = diameter / 2 346 | let center = CGPoint(x: rect.midX, y: rect.midY) 347 | return Path { path in 348 | path.addArc( 349 | center: center, 350 | radius: radius, 351 | startAngle: self.startAngle, 352 | endAngle: self.endAngle, 353 | clockwise: false 354 | ) 355 | } 356 | } 357 | 358 | private var degreesPerSpeaker: Double { 359 | 360 / Double(self.totalSpeakers) 360 | } 361 | private var startAngle: Angle { 362 | Angle(degrees: self.degreesPerSpeaker * Double(self.speakerIndex) + 1) 363 | } 364 | private var endAngle: Angle { 365 | Angle(degrees: self.startAngle.degrees + self.degreesPerSpeaker - 1) 366 | } 367 | } 368 | 369 | struct MeetingFooterView: View { 370 | let standup: Standup 371 | var nextButtonTapped: () -> Void 372 | let speakerIndex: Int 373 | 374 | var body: some View { 375 | VStack { 376 | HStack { 377 | if self.speakerIndex < self.standup.attendees.count - 1 { 378 | Text("Speaker \(self.speakerIndex + 1) of \(self.standup.attendees.count)") 379 | } else { 380 | Text("No more speakers.") 381 | } 382 | Spacer() 383 | Button(action: self.nextButtonTapped) { 384 | Image(systemName: "forward.fill") 385 | } 386 | } 387 | } 388 | .padding([.bottom, .horizontal]) 389 | } 390 | } 391 | 392 | struct RecordMeeting_Previews: PreviewProvider { 393 | static var previews: some View { 394 | NavigationStack { 395 | RecordMeetingView( 396 | store: Store(initialState: RecordMeeting.State(standup: .mock)) { 397 | RecordMeeting() 398 | } 399 | ) 400 | } 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/Resources/ding.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darrarski/tca-swift-log/51107c6137cb7a5dd05723117b6815915feeefd2/Examples/Standups/Standups/Resources/ding.wav -------------------------------------------------------------------------------- /Examples/Standups/Standups/StandupDetail.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct StandupDetail: Reducer { 5 | struct State: Equatable { 6 | @PresentationState var destination: Destination.State? 7 | var standup: Standup 8 | } 9 | enum Action: Equatable, Sendable { 10 | case cancelEditButtonTapped 11 | case delegate(Delegate) 12 | case deleteButtonTapped 13 | case deleteMeetings(atOffsets: IndexSet) 14 | case destination(PresentationAction) 15 | case doneEditingButtonTapped 16 | case editButtonTapped 17 | case startMeetingButtonTapped 18 | 19 | enum Delegate: Equatable { 20 | case deleteStandup 21 | case standupUpdated(Standup) 22 | case startMeeting 23 | } 24 | } 25 | 26 | @Dependency(\.dismiss) var dismiss 27 | @Dependency(\.openSettings) var openSettings 28 | @Dependency(\.speechClient.authorizationStatus) var authorizationStatus 29 | 30 | struct Destination: Reducer { 31 | enum State: Equatable { 32 | case alert(AlertState) 33 | case edit(StandupForm.State) 34 | } 35 | enum Action: Equatable, Sendable { 36 | case alert(Alert) 37 | case edit(StandupForm.Action) 38 | 39 | enum Alert { 40 | case confirmDeletion 41 | case continueWithoutRecording 42 | case openSettings 43 | } 44 | } 45 | var body: some ReducerOf { 46 | Scope(state: /State.edit, action: /Action.edit) { 47 | StandupForm() 48 | } 49 | } 50 | } 51 | 52 | var body: some ReducerOf { 53 | Reduce { state, action in 54 | switch action { 55 | case .cancelEditButtonTapped: 56 | state.destination = nil 57 | return .none 58 | 59 | case .delegate: 60 | return .none 61 | 62 | case .deleteButtonTapped: 63 | state.destination = .alert(.deleteStandup) 64 | return .none 65 | 66 | case let .deleteMeetings(atOffsets: indices): 67 | state.standup.meetings.remove(atOffsets: indices) 68 | return .none 69 | 70 | case let .destination(.presented(.alert(alertAction))): 71 | switch alertAction { 72 | case .confirmDeletion: 73 | return .run { send in 74 | await send(.delegate(.deleteStandup), animation: .default) 75 | await self.dismiss() 76 | } 77 | case .continueWithoutRecording: 78 | return .send(.delegate(.startMeeting)) 79 | case .openSettings: 80 | return .run { _ in 81 | await self.openSettings() 82 | } 83 | } 84 | 85 | case .destination: 86 | return .none 87 | 88 | case .doneEditingButtonTapped: 89 | guard case let .some(.edit(editState)) = state.destination 90 | else { return .none } 91 | state.standup = editState.standup 92 | state.destination = nil 93 | return .none 94 | 95 | case .editButtonTapped: 96 | state.destination = .edit(StandupForm.State(standup: state.standup)) 97 | return .none 98 | 99 | case .startMeetingButtonTapped: 100 | switch self.authorizationStatus() { 101 | case .notDetermined, .authorized: 102 | return .send(.delegate(.startMeeting)) 103 | 104 | case .denied: 105 | state.destination = .alert(.speechRecognitionDenied) 106 | return .none 107 | 108 | case .restricted: 109 | state.destination = .alert(.speechRecognitionRestricted) 110 | return .none 111 | 112 | @unknown default: 113 | return .none 114 | } 115 | } 116 | } 117 | .ifLet(\.$destination, action: /Action.destination) { 118 | Destination() 119 | } 120 | .onChange(of: \.standup) { oldValue, newValue in 121 | Reduce { state, action in 122 | .send(.delegate(.standupUpdated(newValue))) 123 | } 124 | } 125 | } 126 | } 127 | 128 | struct StandupDetailView: View { 129 | let store: StoreOf 130 | 131 | struct ViewState: Equatable { 132 | let standup: Standup 133 | init(state: StandupDetail.State) { 134 | self.standup = state.standup 135 | } 136 | } 137 | 138 | var body: some View { 139 | WithViewStore(self.store, observe: ViewState.init) { viewStore in 140 | List { 141 | Section { 142 | Button { 143 | viewStore.send(.startMeetingButtonTapped) 144 | } label: { 145 | Label("Start Meeting", systemImage: "timer") 146 | .font(.headline) 147 | .foregroundColor(.accentColor) 148 | } 149 | HStack { 150 | Label("Length", systemImage: "clock") 151 | Spacer() 152 | Text(viewStore.standup.duration.formatted(.units())) 153 | } 154 | 155 | HStack { 156 | Label("Theme", systemImage: "paintpalette") 157 | Spacer() 158 | Text(viewStore.standup.theme.name) 159 | .padding(4) 160 | .foregroundColor(viewStore.standup.theme.accentColor) 161 | .background(viewStore.standup.theme.mainColor) 162 | .cornerRadius(4) 163 | } 164 | } header: { 165 | Text("Standup Info") 166 | } 167 | 168 | if !viewStore.standup.meetings.isEmpty { 169 | Section { 170 | ForEach(viewStore.standup.meetings) { meeting in 171 | NavigationLink( 172 | state: AppFeature.Path.State.meeting( 173 | MeetingReducer.State(meeting: meeting, standup: viewStore.standup) 174 | ) 175 | ) { 176 | HStack { 177 | Image(systemName: "calendar") 178 | Text(meeting.date, style: .date) 179 | Text(meeting.date, style: .time) 180 | } 181 | } 182 | } 183 | .onDelete { indices in 184 | viewStore.send(.deleteMeetings(atOffsets: indices)) 185 | } 186 | } header: { 187 | Text("Past meetings") 188 | } 189 | } 190 | 191 | Section { 192 | ForEach(viewStore.standup.attendees) { attendee in 193 | Label(attendee.name, systemImage: "person") 194 | } 195 | } header: { 196 | Text("Attendees") 197 | } 198 | 199 | Section { 200 | Button("Delete") { 201 | viewStore.send(.deleteButtonTapped) 202 | } 203 | .foregroundColor(.red) 204 | .frame(maxWidth: .infinity) 205 | } 206 | } 207 | .navigationTitle(viewStore.standup.title) 208 | .toolbar { 209 | Button("Edit") { 210 | viewStore.send(.editButtonTapped) 211 | } 212 | } 213 | .alert( 214 | store: self.store.scope(state: \.$destination, action: { .destination($0) }), 215 | state: /StandupDetail.Destination.State.alert, 216 | action: StandupDetail.Destination.Action.alert 217 | ) 218 | .sheet( 219 | store: self.store.scope(state: \.$destination, action: { .destination($0) }), 220 | state: /StandupDetail.Destination.State.edit, 221 | action: StandupDetail.Destination.Action.edit 222 | ) { store in 223 | NavigationStack { 224 | StandupFormView(store: store) 225 | .navigationTitle(viewStore.standup.title) 226 | .toolbar { 227 | ToolbarItem(placement: .cancellationAction) { 228 | Button("Cancel") { 229 | viewStore.send(.cancelEditButtonTapped) 230 | } 231 | } 232 | ToolbarItem(placement: .confirmationAction) { 233 | Button("Done") { 234 | viewStore.send(.doneEditingButtonTapped) 235 | } 236 | } 237 | } 238 | } 239 | } 240 | } 241 | } 242 | } 243 | 244 | extension AlertState where Action == StandupDetail.Destination.Action.Alert { 245 | static let deleteStandup = Self { 246 | TextState("Delete?") 247 | } actions: { 248 | ButtonState(role: .destructive, action: .confirmDeletion) { 249 | TextState("Yes") 250 | } 251 | ButtonState(role: .cancel) { 252 | TextState("Nevermind") 253 | } 254 | } message: { 255 | TextState("Are you sure you want to delete this meeting?") 256 | } 257 | 258 | static let speechRecognitionDenied = Self { 259 | TextState("Speech recognition denied") 260 | } actions: { 261 | ButtonState(action: .continueWithoutRecording) { 262 | TextState("Continue without recording") 263 | } 264 | ButtonState(action: .openSettings) { 265 | TextState("Open settings") 266 | } 267 | ButtonState(role: .cancel) { 268 | TextState("Cancel") 269 | } 270 | } message: { 271 | TextState( 272 | """ 273 | You previously denied speech recognition and so your meeting meeting will not be \ 274 | recorded. You can enable speech recognition in settings, or you can continue without \ 275 | recording. 276 | """ 277 | ) 278 | } 279 | 280 | static let speechRecognitionRestricted = Self { 281 | TextState("Speech recognition restricted") 282 | } actions: { 283 | ButtonState(action: .continueWithoutRecording) { 284 | TextState("Continue without recording") 285 | } 286 | ButtonState(role: .cancel) { 287 | TextState("Cancel") 288 | } 289 | } message: { 290 | TextState( 291 | """ 292 | Your device does not support speech recognition and so your meeting will not be recorded. 293 | """ 294 | ) 295 | } 296 | } 297 | 298 | struct StandupDetail_Previews: PreviewProvider { 299 | static var previews: some View { 300 | NavigationStack { 301 | StandupDetailView( 302 | store: Store(initialState: StandupDetail.State(standup: .mock)) { 303 | StandupDetail() 304 | } 305 | ) 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/StandupForm.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | import SwiftUINavigation 4 | 5 | struct StandupForm: Reducer { 6 | struct State: Equatable, Sendable { 7 | @BindingState var focus: Field? = .title 8 | @BindingState var standup: Standup 9 | 10 | init(focus: Field? = .title, standup: Standup) { 11 | self.focus = focus 12 | self.standup = standup 13 | if self.standup.attendees.isEmpty { 14 | @Dependency(\.uuid) var uuid 15 | self.standup.attendees.append(Attendee(id: Attendee.ID(uuid()))) 16 | } 17 | } 18 | 19 | enum Field: Hashable { 20 | case attendee(Attendee.ID) 21 | case title 22 | } 23 | } 24 | enum Action: BindableAction, Equatable, Sendable { 25 | case addAttendeeButtonTapped 26 | case binding(BindingAction) 27 | case deleteAttendees(atOffsets: IndexSet) 28 | } 29 | 30 | @Dependency(\.uuid) var uuid 31 | 32 | var body: some ReducerOf { 33 | BindingReducer() 34 | Reduce { state, action in 35 | switch action { 36 | case .addAttendeeButtonTapped: 37 | let attendee = Attendee(id: Attendee.ID(self.uuid())) 38 | state.standup.attendees.append(attendee) 39 | state.focus = .attendee(attendee.id) 40 | return .none 41 | 42 | case .binding: 43 | return .none 44 | 45 | case let .deleteAttendees(atOffsets: indices): 46 | state.standup.attendees.remove(atOffsets: indices) 47 | if state.standup.attendees.isEmpty { 48 | state.standup.attendees.append(Attendee(id: Attendee.ID(self.uuid()))) 49 | } 50 | guard let firstIndex = indices.first 51 | else { return .none } 52 | let index = min(firstIndex, state.standup.attendees.count - 1) 53 | state.focus = .attendee(state.standup.attendees[index].id) 54 | return .none 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct StandupFormView: View { 61 | let store: StoreOf 62 | @FocusState var focus: StandupForm.State.Field? 63 | 64 | var body: some View { 65 | WithViewStore(self.store, observe: { $0 }) { viewStore in 66 | Form { 67 | Section { 68 | TextField("Title", text: viewStore.$standup.title) 69 | .focused(self.$focus, equals: .title) 70 | HStack { 71 | Slider(value: viewStore.$standup.duration.seconds, in: 5...30, step: 1) { 72 | Text("Length") 73 | } 74 | Spacer() 75 | Text(viewStore.standup.duration.formatted(.units())) 76 | } 77 | ThemePicker(selection: viewStore.$standup.theme) 78 | } header: { 79 | Text("Standup Info") 80 | } 81 | Section { 82 | ForEach(viewStore.$standup.attendees) { $attendee in 83 | TextField("Name", text: $attendee.name) 84 | .focused(self.$focus, equals: .attendee(attendee.id)) 85 | } 86 | .onDelete { indices in 87 | viewStore.send(.deleteAttendees(atOffsets: indices)) 88 | } 89 | 90 | Button("New attendee") { 91 | viewStore.send(.addAttendeeButtonTapped) 92 | } 93 | } header: { 94 | Text("Attendees") 95 | } 96 | } 97 | .bind(viewStore.$focus, to: self.$focus) 98 | } 99 | } 100 | } 101 | 102 | struct ThemePicker: View { 103 | @Binding var selection: Theme 104 | 105 | var body: some View { 106 | Picker("Theme", selection: self.$selection) { 107 | ForEach(Theme.allCases) { theme in 108 | ZStack { 109 | RoundedRectangle(cornerRadius: 4) 110 | .fill(theme.mainColor) 111 | Label(theme.name, systemImage: "paintpalette") 112 | .padding(4) 113 | } 114 | .foregroundColor(theme.accentColor) 115 | .fixedSize(horizontal: false, vertical: true) 116 | .tag(theme) 117 | } 118 | } 119 | } 120 | } 121 | 122 | extension Duration { 123 | fileprivate var seconds: Double { 124 | get { Double(self.components.seconds / 60) } 125 | set { self = .seconds(newValue * 60) } 126 | } 127 | } 128 | 129 | struct EditStandup_Previews: PreviewProvider { 130 | static var previews: some View { 131 | NavigationStack { 132 | StandupFormView( 133 | store: Store(initialState: StandupForm.State(standup: .mock)) { 134 | StandupForm() 135 | } 136 | ) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Examples/Standups/Standups/StandupsList.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct StandupsList: Reducer { 5 | struct State: Equatable { 6 | @PresentationState var destination: Destination.State? 7 | var standups: IdentifiedArrayOf = [] 8 | 9 | init( 10 | destination: Destination.State? = nil 11 | ) { 12 | self.destination = destination 13 | 14 | do { 15 | @Dependency(\.dataManager.load) var load 16 | self.standups = try JSONDecoder().decode(IdentifiedArray.self, from: load(.standups)) 17 | } catch is DecodingError { 18 | self.destination = .alert(.dataFailedToLoad) 19 | } catch { 20 | } 21 | } 22 | } 23 | enum Action: Equatable { 24 | case addStandupButtonTapped 25 | case confirmAddStandupButtonTapped 26 | case destination(PresentationAction) 27 | case dismissAddStandupButtonTapped 28 | } 29 | struct Destination: Reducer { 30 | enum State: Equatable { 31 | case add(StandupForm.State) 32 | case alert(AlertState) 33 | } 34 | 35 | enum Action: Equatable { 36 | case add(StandupForm.Action) 37 | case alert(Alert) 38 | 39 | enum Alert { 40 | case confirmLoadMockData 41 | } 42 | } 43 | 44 | var body: some ReducerOf { 45 | Scope(state: /State.add, action: /Action.add) { 46 | StandupForm() 47 | } 48 | } 49 | } 50 | 51 | @Dependency(\.continuousClock) var clock 52 | @Dependency(\.uuid) var uuid 53 | 54 | var body: some ReducerOf { 55 | Reduce { state, action in 56 | switch action { 57 | case .addStandupButtonTapped: 58 | state.destination = .add(StandupForm.State(standup: Standup(id: Standup.ID(self.uuid())))) 59 | return .none 60 | 61 | case .confirmAddStandupButtonTapped: 62 | guard case let .some(.add(editState)) = state.destination 63 | else { return .none } 64 | var standup = editState.standup 65 | standup.attendees.removeAll { attendee in 66 | attendee.name.allSatisfy(\.isWhitespace) 67 | } 68 | if standup.attendees.isEmpty { 69 | standup.attendees.append( 70 | editState.standup.attendees.first 71 | ?? Attendee(id: Attendee.ID(self.uuid())) 72 | ) 73 | } 74 | state.standups.append(standup) 75 | state.destination = nil 76 | return .none 77 | 78 | case .destination(.presented(.alert(.confirmLoadMockData))): 79 | state.standups = [ 80 | .mock, 81 | .designMock, 82 | .engineeringMock, 83 | ] 84 | return .none 85 | 86 | case .destination: 87 | return .none 88 | 89 | case .dismissAddStandupButtonTapped: 90 | state.destination = nil 91 | return .none 92 | } 93 | } 94 | .ifLet(\.$destination, action: /Action.destination) { 95 | Destination() 96 | } 97 | } 98 | } 99 | 100 | struct StandupsListView: View { 101 | let store: StoreOf 102 | 103 | var body: some View { 104 | WithViewStore(self.store, observe: \.standups) { viewStore in 105 | List { 106 | ForEach(viewStore.state) { standup in 107 | NavigationLink( 108 | state: AppFeature.Path.State.detail(StandupDetail.State(standup: standup)) 109 | ) { 110 | CardView(standup: standup) 111 | } 112 | .listRowBackground(standup.theme.mainColor) 113 | } 114 | } 115 | .toolbar { 116 | Button { 117 | viewStore.send(.addStandupButtonTapped) 118 | } label: { 119 | Image(systemName: "plus") 120 | } 121 | } 122 | .navigationTitle("Daily Standups") 123 | .alert( 124 | store: self.store.scope(state: \.$destination, action: { .destination($0) }), 125 | state: /StandupsList.Destination.State.alert, 126 | action: StandupsList.Destination.Action.alert 127 | ) 128 | .sheet( 129 | store: self.store.scope(state: \.$destination, action: { .destination($0) }), 130 | state: /StandupsList.Destination.State.add, 131 | action: StandupsList.Destination.Action.add 132 | ) { store in 133 | NavigationStack { 134 | StandupFormView(store: store) 135 | .navigationTitle("New standup") 136 | .toolbar { 137 | ToolbarItem(placement: .cancellationAction) { 138 | Button("Dismiss") { 139 | viewStore.send(.dismissAddStandupButtonTapped) 140 | } 141 | } 142 | ToolbarItem(placement: .confirmationAction) { 143 | Button("Add") { 144 | viewStore.send(.confirmAddStandupButtonTapped) 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | 154 | extension AlertState where Action == StandupsList.Destination.Action.Alert { 155 | static let dataFailedToLoad = Self { 156 | TextState("Data failed to load") 157 | } actions: { 158 | ButtonState(action: .send(.confirmLoadMockData, animation: .default)) { 159 | TextState("Yes") 160 | } 161 | ButtonState(role: .cancel) { 162 | TextState("No") 163 | } 164 | } message: { 165 | TextState( 166 | """ 167 | Unfortunately your past data failed to load. Would you like to load some mock data to play \ 168 | around with? 169 | """ 170 | ) 171 | } 172 | } 173 | 174 | struct CardView: View { 175 | let standup: Standup 176 | 177 | var body: some View { 178 | VStack(alignment: .leading) { 179 | Text(self.standup.title) 180 | .font(.headline) 181 | Spacer() 182 | HStack { 183 | Label("\(self.standup.attendees.count)", systemImage: "person.3") 184 | Spacer() 185 | Label(self.standup.duration.formatted(.units()), systemImage: "clock") 186 | .labelStyle(.trailingIcon) 187 | } 188 | .font(.caption) 189 | } 190 | .padding() 191 | .foregroundColor(self.standup.theme.accentColor) 192 | } 193 | } 194 | 195 | struct TrailingIconLabelStyle: LabelStyle { 196 | func makeBody(configuration: Configuration) -> some View { 197 | HStack { 198 | configuration.title 199 | configuration.icon 200 | } 201 | } 202 | } 203 | 204 | extension LabelStyle where Self == TrailingIconLabelStyle { 205 | static var trailingIcon: Self { Self() } 206 | } 207 | 208 | struct StandupsList_Previews: PreviewProvider { 209 | static var previews: some View { 210 | StandupsListView( 211 | store: Store(initialState: StandupsList.State()) { 212 | StandupsList() 213 | } withDependencies: { 214 | $0.dataManager.load = { _ in 215 | try JSONEncoder().encode([ 216 | Standup.mock, 217 | .designMock, 218 | .engineeringMock, 219 | ]) 220 | } 221 | } 222 | ) 223 | 224 | StandupsListView( 225 | store: Store(initialState: StandupsList.State()) { 226 | StandupsList() 227 | } withDependencies: { 228 | $0.dataManager = .mock(initialData: Data("!@#$% bad data ^&*()".utf8)) 229 | } 230 | ) 231 | .previewDisplayName("Load data failure") 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsTests/AppFeatureTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import XCTest 3 | 4 | @testable import Standups 5 | 6 | @MainActor 7 | final class AppFeatureTests: XCTestCase { 8 | func testDelete() async throws { 9 | let standup = Standup.mock 10 | 11 | let store = TestStore(initialState: AppFeature.State()) { 12 | AppFeature() 13 | } withDependencies: { 14 | $0.continuousClock = ImmediateClock() 15 | $0.dataManager = .mock( 16 | initialData: try! JSONEncoder().encode([standup]) 17 | ) 18 | } 19 | 20 | await store.send(.path(.push(id: 0, state: .detail(StandupDetail.State(standup: standup))))) { 21 | $0.path[id: 0] = .detail(StandupDetail.State(standup: standup)) 22 | } 23 | 24 | await store.send(.path(.element(id: 0, action: .detail(.deleteButtonTapped)))) { 25 | $0.path[id: 0, case: /AppFeature.Path.State.detail]?.destination = .alert(.deleteStandup) 26 | } 27 | 28 | await store.send( 29 | .path(.element(id: 0, action: .detail(.destination(.presented(.alert(.confirmDeletion)))))) 30 | ) { 31 | $0.path[id: 0, case: /AppFeature.Path.State.detail]?.destination = nil 32 | } 33 | 34 | await store.receive(.path(.element(id: 0, action: .detail(.delegate(.deleteStandup))))) { 35 | $0.standupsList.standups = [] 36 | } 37 | await store.receive(.path(.popFrom(id: 0))) { 38 | $0.path = StackState() 39 | } 40 | } 41 | 42 | func testDetailEdit() async throws { 43 | var standup = Standup.mock 44 | let savedData = LockIsolated(Data?.none) 45 | 46 | let store = TestStore(initialState: AppFeature.State()) { 47 | AppFeature() 48 | } withDependencies: { dependencies in 49 | dependencies.continuousClock = ImmediateClock() 50 | dependencies.dataManager = .mock( 51 | initialData: try! JSONEncoder().encode([standup]) 52 | ) 53 | dependencies.dataManager.save = { [dependencies] data, url in 54 | savedData.setValue(data) 55 | try await dependencies.dataManager.save(data, url) 56 | } 57 | } 58 | 59 | await store.send(.path(.push(id: 0, state: .detail(StandupDetail.State(standup: standup))))) { 60 | $0.path[id: 0] = .detail(StandupDetail.State(standup: standup)) 61 | } 62 | 63 | await store.send(.path(.element(id: 0, action: .detail(.editButtonTapped)))) { 64 | $0.path[id: 0, case: /AppFeature.Path.State.detail]?.destination = .edit( 65 | StandupForm.State(standup: standup) 66 | ) 67 | } 68 | 69 | standup.title = "Blob" 70 | await store.send( 71 | .path( 72 | .element( 73 | id: 0, 74 | action: .detail(.destination(.presented(.edit(.set(\.$standup, standup))))) 75 | ) 76 | ) 77 | ) { 78 | $0.path[id: 0, case: /AppFeature.Path.State.detail]? 79 | .$destination[case: /StandupDetail.Destination.State.edit]?.standup.title = "Blob" 80 | } 81 | 82 | await store.send(.path(.element(id: 0, action: .detail(.doneEditingButtonTapped)))) { 83 | XCTModify(&$0.path[id: 0], case: /AppFeature.Path.State.detail) { 84 | $0.destination = nil 85 | $0.standup.title = "Blob" 86 | } 87 | } 88 | 89 | await store.receive( 90 | .path(.element(id: 0, action: .detail(.delegate(.standupUpdated(standup))))) 91 | ) { 92 | $0.standupsList.standups[0].title = "Blob" 93 | } 94 | 95 | var savedStandup = standup 96 | savedStandup.title = "Blob" 97 | XCTAssertNoDifference( 98 | try JSONDecoder().decode([Standup].self, from: savedData.value!), 99 | [savedStandup] 100 | ) 101 | } 102 | 103 | func testRecording() async { 104 | let speechResult = SpeechRecognitionResult( 105 | bestTranscription: Transcription(formattedString: "I completed the project"), 106 | isFinal: true 107 | ) 108 | let standup = Standup( 109 | id: Standup.ID(), 110 | attendees: [ 111 | Attendee(id: Attendee.ID()), 112 | Attendee(id: Attendee.ID()), 113 | Attendee(id: Attendee.ID()), 114 | ], 115 | duration: .seconds(6) 116 | ) 117 | 118 | let store = TestStore( 119 | initialState: AppFeature.State( 120 | path: StackState([ 121 | .detail(StandupDetail.State(standup: standup)), 122 | .record(RecordMeeting.State(standup: standup)), 123 | ]) 124 | ) 125 | ) { 126 | AppFeature() 127 | } withDependencies: { 128 | $0.dataManager = .mock(initialData: try! JSONEncoder().encode([standup])) 129 | $0.date.now = Date(timeIntervalSince1970: 1_234_567_890) 130 | $0.continuousClock = ImmediateClock() 131 | $0.speechClient.authorizationStatus = { .authorized } 132 | $0.speechClient.startTask = { _ in 133 | AsyncThrowingStream { continuation in 134 | continuation.yield(speechResult) 135 | continuation.finish() 136 | } 137 | } 138 | $0.uuid = .incrementing 139 | } 140 | store.exhaustivity = .off 141 | 142 | await store.send(.path(.element(id: 1, action: .record(.onTask)))) 143 | await store.receive( 144 | .path( 145 | .element(id: 1, action: .record(.delegate(.save(transcript: "I completed the project")))) 146 | ) 147 | ) { 148 | $0.path[id: 0, case: /AppFeature.Path.State.detail]?.standup.meetings = [ 149 | Meeting( 150 | id: Meeting.ID(UUID(0)), 151 | date: Date(timeIntervalSince1970: 1_234_567_890), 152 | transcript: "I completed the project" 153 | ) 154 | ] 155 | } 156 | await store.receive(.path(.popFrom(id: 1))) { 157 | XCTAssertEqual($0.path.count, 1) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsTests/RecordMeetingTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import XCTest 3 | 4 | @testable import Standups 5 | 6 | @MainActor 7 | final class RecordMeetingTests: XCTestCase { 8 | func testTimer() async throws { 9 | let clock = TestClock() 10 | let dismissed = self.expectation(description: "dismissed") 11 | 12 | let store = TestStore( 13 | initialState: RecordMeeting.State( 14 | standup: Standup( 15 | id: Standup.ID(), 16 | attendees: [ 17 | Attendee(id: Attendee.ID()), 18 | Attendee(id: Attendee.ID()), 19 | Attendee(id: Attendee.ID()), 20 | ], 21 | duration: .seconds(6) 22 | ) 23 | ) 24 | ) { 25 | RecordMeeting() 26 | } withDependencies: { 27 | $0.continuousClock = clock 28 | $0.dismiss = DismissEffect { dismissed.fulfill() } 29 | $0.speechClient.authorizationStatus = { .denied } 30 | } 31 | 32 | let onTask = await store.send(.onTask) 33 | 34 | await clock.advance(by: .seconds(1)) 35 | await store.receive(.timerTick) { 36 | $0.speakerIndex = 0 37 | $0.secondsElapsed = 1 38 | XCTAssertEqual($0.durationRemaining, .seconds(5)) 39 | } 40 | 41 | await clock.advance(by: .seconds(1)) 42 | await store.receive(.timerTick) { 43 | $0.speakerIndex = 1 44 | $0.secondsElapsed = 2 45 | XCTAssertEqual($0.durationRemaining, .seconds(4)) 46 | } 47 | 48 | await clock.advance(by: .seconds(1)) 49 | await store.receive(.timerTick) { 50 | $0.speakerIndex = 1 51 | $0.secondsElapsed = 3 52 | XCTAssertEqual($0.durationRemaining, .seconds(3)) 53 | } 54 | 55 | await clock.advance(by: .seconds(1)) 56 | await store.receive(.timerTick) { 57 | $0.speakerIndex = 2 58 | $0.secondsElapsed = 4 59 | XCTAssertEqual($0.durationRemaining, .seconds(2)) 60 | } 61 | 62 | await clock.advance(by: .seconds(1)) 63 | await store.receive(.timerTick) { 64 | $0.speakerIndex = 2 65 | $0.secondsElapsed = 5 66 | XCTAssertEqual($0.durationRemaining, .seconds(1)) 67 | } 68 | 69 | await clock.advance(by: .seconds(1)) 70 | await store.receive(.timerTick) { 71 | $0.speakerIndex = 2 72 | $0.secondsElapsed = 6 73 | XCTAssertEqual($0.durationRemaining, .seconds(0)) 74 | } 75 | 76 | // NB: this improves on the onMeetingFinished pattern from vanilla SwiftUI 77 | await store.receive(.delegate(.save(transcript: ""))) 78 | 79 | await self.fulfillment(of: [dismissed]) 80 | await onTask.cancel() 81 | } 82 | 83 | func testRecordTranscript() async throws { 84 | let clock = TestClock() 85 | let dismissed = self.expectation(description: "dismissed") 86 | 87 | let store = TestStore( 88 | initialState: RecordMeeting.State( 89 | standup: Standup( 90 | id: Standup.ID(), 91 | attendees: [ 92 | Attendee(id: Attendee.ID()), 93 | Attendee(id: Attendee.ID()), 94 | Attendee(id: Attendee.ID()), 95 | ], 96 | duration: .seconds(6) 97 | ) 98 | ) 99 | ) { 100 | RecordMeeting() 101 | } withDependencies: { 102 | $0.continuousClock = clock 103 | $0.dismiss = DismissEffect { dismissed.fulfill() } 104 | $0.speechClient.authorizationStatus = { .authorized } 105 | $0.speechClient.startTask = { _ in 106 | AsyncThrowingStream { continuation in 107 | continuation.yield( 108 | SpeechRecognitionResult( 109 | bestTranscription: Transcription(formattedString: "I completed the project"), 110 | isFinal: true 111 | ) 112 | ) 113 | continuation.finish() 114 | } 115 | } 116 | } 117 | 118 | let onTask = await store.send(.onTask) 119 | 120 | await store.receive( 121 | .speechResult( 122 | .init(bestTranscription: .init(formattedString: "I completed the project"), isFinal: true) 123 | ) 124 | ) { 125 | $0.transcript = "I completed the project" 126 | } 127 | 128 | await store.withExhaustivity(.off(showSkippedAssertions: true)) { 129 | await clock.advance(by: .seconds(6)) 130 | await store.receive(.timerTick) 131 | await store.receive(.timerTick) 132 | await store.receive(.timerTick) 133 | await store.receive(.timerTick) 134 | await store.receive(.timerTick) 135 | await store.receive(.timerTick) 136 | } 137 | 138 | await store.receive(.delegate(.save(transcript: "I completed the project"))) 139 | 140 | await self.fulfillment(of: [dismissed]) 141 | await onTask.cancel() 142 | } 143 | 144 | func testEndMeetingSave() async throws { 145 | let clock = TestClock() 146 | let dismissed = self.expectation(description: "dismissed") 147 | 148 | let store = TestStore(initialState: RecordMeeting.State(standup: .mock)) { 149 | RecordMeeting() 150 | } withDependencies: { 151 | $0.continuousClock = clock 152 | $0.dismiss = DismissEffect { dismissed.fulfill() } 153 | $0.speechClient.authorizationStatus = { .denied } 154 | } 155 | 156 | let onTask = await store.send(.onTask) 157 | 158 | await store.send(.endMeetingButtonTapped) { 159 | $0.alert = .endMeeting(isDiscardable: true) 160 | } 161 | 162 | await clock.advance(by: .seconds(3)) 163 | await store.receive(.timerTick) 164 | await store.receive(.timerTick) 165 | await store.receive(.timerTick) 166 | 167 | await store.send(.alert(.presented(.confirmSave))) { 168 | $0.alert = nil 169 | } 170 | 171 | await store.receive(.delegate(.save(transcript: ""))) 172 | 173 | await self.fulfillment(of: [dismissed]) 174 | await onTask.cancel() 175 | } 176 | 177 | func testEndMeetingDiscard() async throws { 178 | let clock = TestClock() 179 | let dismissed = self.expectation(description: "dismissed") 180 | 181 | let store = TestStore(initialState: RecordMeeting.State(standup: .mock)) { 182 | RecordMeeting() 183 | } withDependencies: { 184 | $0.continuousClock = clock 185 | $0.dismiss = DismissEffect { dismissed.fulfill() } 186 | $0.speechClient.authorizationStatus = { .denied } 187 | } 188 | 189 | let task = await store.send(.onTask) 190 | 191 | await store.send(.endMeetingButtonTapped) { 192 | $0.alert = .endMeeting(isDiscardable: true) 193 | } 194 | 195 | await store.send(.alert(.presented(.confirmDiscard))) { 196 | $0.alert = nil 197 | } 198 | 199 | await self.fulfillment(of: [dismissed]) 200 | await task.cancel() 201 | } 202 | 203 | func testNextSpeaker() async throws { 204 | let clock = TestClock() 205 | let dismissed = self.expectation(description: "dismissed") 206 | 207 | let store = TestStore( 208 | initialState: RecordMeeting.State( 209 | standup: Standup( 210 | id: Standup.ID(), 211 | attendees: [ 212 | Attendee(id: Attendee.ID()), 213 | Attendee(id: Attendee.ID()), 214 | Attendee(id: Attendee.ID()), 215 | ], 216 | duration: .seconds(6) 217 | ) 218 | ) 219 | ) { 220 | RecordMeeting() 221 | } withDependencies: { 222 | $0.continuousClock = clock 223 | $0.dismiss = DismissEffect { dismissed.fulfill() } 224 | $0.speechClient.authorizationStatus = { .denied } 225 | } 226 | 227 | let onTask = await store.send(.onTask) 228 | 229 | await store.send(.nextButtonTapped) { 230 | $0.speakerIndex = 1 231 | $0.secondsElapsed = 2 232 | } 233 | 234 | await store.send(.nextButtonTapped) { 235 | $0.speakerIndex = 2 236 | $0.secondsElapsed = 4 237 | } 238 | 239 | await store.send(.nextButtonTapped) { 240 | $0.alert = .endMeeting(isDiscardable: false) 241 | } 242 | 243 | await store.send(.alert(.presented(.confirmSave))) { 244 | $0.alert = nil 245 | } 246 | 247 | await store.receive(.delegate(.save(transcript: ""))) 248 | await self.fulfillment(of: [dismissed]) 249 | await onTask.cancel() 250 | } 251 | 252 | func testSpeechRecognitionFailure_Continue() async throws { 253 | let clock = TestClock() 254 | let dismissed = self.expectation(description: "dismissed") 255 | 256 | let store = TestStore( 257 | initialState: RecordMeeting.State( 258 | standup: Standup( 259 | id: Standup.ID(), 260 | attendees: [ 261 | Attendee(id: Attendee.ID()), 262 | Attendee(id: Attendee.ID()), 263 | Attendee(id: Attendee.ID()), 264 | ], 265 | duration: .seconds(6) 266 | ) 267 | ) 268 | ) { 269 | RecordMeeting() 270 | } withDependencies: { 271 | $0.continuousClock = clock 272 | $0.dismiss = DismissEffect { dismissed.fulfill() } 273 | $0.speechClient.authorizationStatus = { .authorized } 274 | $0.speechClient.startTask = { _ in 275 | AsyncThrowingStream { 276 | $0.yield( 277 | SpeechRecognitionResult( 278 | bestTranscription: Transcription(formattedString: "I completed the project"), 279 | isFinal: true 280 | ) 281 | ) 282 | struct SpeechRecognitionFailure: Error {} 283 | $0.finish(throwing: SpeechRecognitionFailure()) 284 | } 285 | } 286 | } 287 | 288 | let onTask = await store.send(.onTask) 289 | 290 | await store.receive( 291 | .speechResult( 292 | .init(bestTranscription: .init(formattedString: "I completed the project"), isFinal: true) 293 | ) 294 | ) { 295 | $0.transcript = "I completed the project" 296 | } 297 | 298 | await store.receive(.speechFailure) { 299 | $0.alert = .speechRecognizerFailed 300 | $0.transcript = "I completed the project ❌" 301 | } 302 | 303 | await store.send(.alert(.dismiss)) { 304 | $0.alert = nil 305 | } 306 | 307 | await clock.advance(by: .seconds(6)) 308 | 309 | store.exhaustivity = .off(showSkippedAssertions: true) 310 | await store.receive(.timerTick) 311 | await store.receive(.timerTick) 312 | await store.receive(.timerTick) 313 | await store.receive(.timerTick) 314 | await store.receive(.timerTick) 315 | await store.receive(.timerTick) 316 | store.exhaustivity = .on 317 | 318 | await store.receive(.delegate(.save(transcript: "I completed the project ❌"))) 319 | await self.fulfillment(of: [dismissed]) 320 | await onTask.cancel() 321 | } 322 | 323 | func testSpeechRecognitionFailure_Discard() async throws { 324 | let clock = TestClock() 325 | let dismissed = self.expectation(description: "dismissed") 326 | 327 | let store = TestStore(initialState: RecordMeeting.State(standup: .mock)) { 328 | RecordMeeting() 329 | } withDependencies: { 330 | $0.continuousClock = clock 331 | $0.dismiss = DismissEffect { dismissed.fulfill() } 332 | $0.speechClient.authorizationStatus = { .authorized } 333 | $0.speechClient.startTask = { _ in 334 | AsyncThrowingStream { 335 | struct SpeechRecognitionFailure: Error {} 336 | $0.finish(throwing: SpeechRecognitionFailure()) 337 | } 338 | } 339 | } 340 | 341 | let onTask = await store.send(.onTask) 342 | 343 | await store.receive(.speechFailure) { 344 | $0.alert = .speechRecognizerFailed 345 | } 346 | 347 | await store.send(.alert(.presented(.confirmDiscard))) { 348 | $0.alert = nil 349 | } 350 | 351 | await self.fulfillment(of: [dismissed]) 352 | await onTask.cancel() 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsTests/StandupDetailTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import XCTest 3 | 4 | @testable import Standups 5 | 6 | @MainActor 7 | final class StandupDetailTests: XCTestCase { 8 | func testSpeechRestricted() async { 9 | let store = TestStore(initialState: StandupDetail.State(standup: .mock)) { 10 | StandupDetail() 11 | } withDependencies: { 12 | $0.speechClient.authorizationStatus = { .restricted } 13 | } 14 | 15 | await store.send(.startMeetingButtonTapped) { 16 | $0.destination = .alert(.speechRecognitionRestricted) 17 | } 18 | } 19 | 20 | func testSpeechDenied() async throws { 21 | let store = TestStore(initialState: StandupDetail.State(standup: .mock)) { 22 | StandupDetail() 23 | } withDependencies: { 24 | $0.speechClient.authorizationStatus = { 25 | .denied 26 | } 27 | } 28 | 29 | await store.send(.startMeetingButtonTapped) { 30 | $0.destination = .alert(.speechRecognitionDenied) 31 | } 32 | } 33 | 34 | func testOpenSettings() async { 35 | let settingsOpened = LockIsolated(false) 36 | 37 | let store = TestStore( 38 | initialState: StandupDetail.State( 39 | destination: .alert(.speechRecognitionDenied), 40 | standup: .mock 41 | ) 42 | ) { 43 | StandupDetail() 44 | } withDependencies: { 45 | $0.openSettings = { settingsOpened.setValue(true) } 46 | $0.speechClient.authorizationStatus = { .denied } 47 | } 48 | 49 | await store.send(.destination(.presented(.alert(.openSettings)))) { 50 | $0.destination = nil 51 | } 52 | XCTAssertEqual(settingsOpened.value, true) 53 | } 54 | 55 | func testContinueWithoutRecording() async throws { 56 | let store = TestStore( 57 | initialState: StandupDetail.State( 58 | destination: .alert(.speechRecognitionDenied), 59 | standup: .mock 60 | ) 61 | ) { 62 | StandupDetail() 63 | } withDependencies: { 64 | $0.speechClient.authorizationStatus = { .denied } 65 | } 66 | 67 | await store.send(.destination(.presented(.alert(.continueWithoutRecording)))) { 68 | $0.destination = nil 69 | } 70 | 71 | await store.receive(.delegate(.startMeeting)) 72 | } 73 | 74 | func testSpeechAuthorized() async throws { 75 | let store = TestStore(initialState: StandupDetail.State(standup: .mock)) { 76 | StandupDetail() 77 | } withDependencies: { 78 | $0.speechClient.authorizationStatus = { .authorized } 79 | } 80 | 81 | await store.send(.startMeetingButtonTapped) 82 | 83 | await store.receive(.delegate(.startMeeting)) 84 | } 85 | 86 | func testEdit() async { 87 | var standup = Standup.mock 88 | let store = TestStore(initialState: StandupDetail.State(standup: standup)) { 89 | StandupDetail() 90 | } withDependencies: { 91 | $0.uuid = .incrementing 92 | } 93 | 94 | await store.send(.editButtonTapped) { 95 | $0.destination = .edit(StandupForm.State(standup: standup)) 96 | } 97 | 98 | standup.title = "Blob's Meeting" 99 | await store.send(.destination(.presented(.edit(.set(\.$standup, standup))))) { 100 | try (/StandupDetail.Destination.State.edit).modify(&$0.destination) { 101 | $0.standup.title = "Blob's Meeting" 102 | } 103 | } 104 | 105 | await store.send(.doneEditingButtonTapped) { 106 | $0.destination = nil 107 | $0.standup.title = "Blob's Meeting" 108 | } 109 | 110 | await store.receive(.delegate(.standupUpdated(standup))) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsTests/StandupFormTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import XCTest 3 | 4 | @testable import Standups 5 | 6 | @MainActor 7 | final class StandupFormTests: XCTestCase { 8 | func testAddAttendee() async { 9 | let store = TestStore( 10 | initialState: StandupForm.State( 11 | standup: Standup( 12 | id: Standup.ID(), 13 | attendees: [], 14 | title: "Engineering" 15 | ) 16 | ) 17 | ) { 18 | StandupForm() 19 | } withDependencies: { 20 | $0.uuid = .incrementing 21 | } 22 | 23 | XCTAssertNoDifference( 24 | store.state.standup.attendees, 25 | [ 26 | Attendee(id: Attendee.ID(UUID(0))) 27 | ] 28 | ) 29 | 30 | await store.send(.addAttendeeButtonTapped) { 31 | $0.focus = .attendee(Attendee.ID(UUID(1))) 32 | $0.standup.attendees = [ 33 | Attendee(id: Attendee.ID(UUID(0))), 34 | Attendee(id: Attendee.ID(UUID(1))), 35 | ] 36 | } 37 | } 38 | 39 | func testFocus_RemoveAttendee() async { 40 | let store = TestStore( 41 | initialState: StandupForm.State( 42 | standup: Standup( 43 | id: Standup.ID(), 44 | attendees: [ 45 | Attendee(id: Attendee.ID()), 46 | Attendee(id: Attendee.ID()), 47 | Attendee(id: Attendee.ID()), 48 | Attendee(id: Attendee.ID()), 49 | ], 50 | title: "Engineering" 51 | ) 52 | ) 53 | ) { 54 | StandupForm() 55 | } withDependencies: { 56 | $0.uuid = .incrementing 57 | } 58 | 59 | await store.send(.deleteAttendees(atOffsets: [0])) { 60 | $0.focus = .attendee($0.standup.attendees[1].id) 61 | $0.standup.attendees = [ 62 | $0.standup.attendees[1], 63 | $0.standup.attendees[2], 64 | $0.standup.attendees[3], 65 | ] 66 | } 67 | 68 | await store.send(.deleteAttendees(atOffsets: [1])) { 69 | $0.focus = .attendee($0.standup.attendees[2].id) 70 | $0.standup.attendees = [ 71 | $0.standup.attendees[0], 72 | $0.standup.attendees[2], 73 | ] 74 | } 75 | 76 | await store.send(.deleteAttendees(atOffsets: [1])) { 77 | $0.focus = .attendee($0.standup.attendees[0].id) 78 | $0.standup.attendees = [ 79 | $0.standup.attendees[0] 80 | ] 81 | } 82 | 83 | await store.send(.deleteAttendees(atOffsets: [0])) { 84 | $0.focus = .attendee(Attendee.ID(UUID(0))) 85 | $0.standup.attendees = [ 86 | Attendee(id: Attendee.ID(UUID(0))) 87 | ] 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsTests/StandupsListTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import XCTest 3 | 4 | @testable import Standups 5 | 6 | @MainActor 7 | final class StandupsListTests: XCTestCase { 8 | func testAdd() async throws { 9 | let store = TestStore(initialState: StandupsList.State()) { 10 | StandupsList() 11 | } withDependencies: { 12 | $0.continuousClock = ImmediateClock() 13 | $0.dataManager = .mock() 14 | $0.uuid = .incrementing 15 | } 16 | 17 | var standup = Standup( 18 | id: Standup.ID(UUID(0)), 19 | attendees: [ 20 | Attendee(id: Attendee.ID(UUID(1))) 21 | ] 22 | ) 23 | await store.send(.addStandupButtonTapped) { 24 | $0.destination = .add(StandupForm.State(standup: standup)) 25 | } 26 | 27 | standup.title = "Engineering" 28 | await store.send(.destination(.presented(.add(.set(\.$standup, standup))))) { 29 | $0.$destination[case: /StandupsList.Destination.State.add]?.standup.title = "Engineering" 30 | } 31 | 32 | await store.send(.confirmAddStandupButtonTapped) { 33 | $0.destination = nil 34 | $0.standups = [standup] 35 | } 36 | } 37 | 38 | func testAdd_ValidatedAttendees() async throws { 39 | @Dependency(\.uuid) var uuid 40 | 41 | let store = TestStore( 42 | initialState: StandupsList.State( 43 | destination: .add( 44 | StandupForm.State( 45 | standup: Standup( 46 | id: Standup.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, 47 | attendees: [ 48 | Attendee(id: Attendee.ID(uuid()), name: ""), 49 | Attendee(id: Attendee.ID(uuid()), name: " "), 50 | ], 51 | title: "Design" 52 | ) 53 | ) 54 | ) 55 | ) 56 | ) { 57 | StandupsList() 58 | } withDependencies: { 59 | $0.continuousClock = ImmediateClock() 60 | $0.dataManager = .mock() 61 | $0.uuid = .incrementing 62 | } 63 | 64 | await store.send(.confirmAddStandupButtonTapped) { 65 | $0.destination = nil 66 | $0.standups = [ 67 | Standup( 68 | id: Standup.ID(uuidString: "deadbeef-dead-beef-dead-beefdeadbeef")!, 69 | attendees: [ 70 | Attendee(id: Attendee.ID(UUID(0))) 71 | ], 72 | title: "Design" 73 | ) 74 | ] 75 | } 76 | } 77 | 78 | func testLoadingDataDecodingFailed() async throws { 79 | let store = TestStore(initialState: StandupsList.State()) { 80 | StandupsList() 81 | } withDependencies: { 82 | $0.continuousClock = ImmediateClock() 83 | $0.dataManager = .mock( 84 | initialData: Data("!@#$ BAD DATA %^&*()".utf8) 85 | ) 86 | } 87 | 88 | XCTAssertEqual(store.state.destination, .alert(.dataFailedToLoad)) 89 | 90 | await store.send(.destination(.presented(.alert(.confirmLoadMockData)))) { 91 | $0.destination = nil 92 | $0.standups = [ 93 | .mock, 94 | .designMock, 95 | .engineeringMock, 96 | ] 97 | } 98 | } 99 | 100 | func testLoadingDataFileNotFound() async throws { 101 | let store = TestStore(initialState: StandupsList.State()) { 102 | StandupsList() 103 | } withDependencies: { 104 | $0.continuousClock = ImmediateClock() 105 | $0.dataManager.load = { _ in 106 | struct FileNotFound: Error {} 107 | throw FileNotFound() 108 | } 109 | } 110 | 111 | XCTAssertEqual(store.state.destination, nil) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Examples/Standups/StandupsUITests/StandupsUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class StandupsUITests: XCTestCase { 4 | var app: XCUIApplication! 5 | 6 | override func setUpWithError() throws { 7 | self.continueAfterFailure = false 8 | self.app = XCUIApplication() 9 | app.launchEnvironment = [ 10 | "UITesting": "true" 11 | ] 12 | } 13 | 14 | // This test demonstrates the simple flow of tapping the "Add" button, filling in some fields in 15 | // the form, and then adding the standup to the list. It's a very simple test, but it takes 16 | // approximately 10 seconds to run, and it depends on a lot of internal implementation details to 17 | // get right, such as tapping a button with the literal label "Add". 18 | // 19 | // This test is also written in the simpler, "unit test" style in StandupsListTests.swift, where 20 | // it takes 0.025 seconds (400 times faster) and it even tests more. It further confirms that when 21 | // the standup is added to the list its data will be persisted to disk so that it will be 22 | // available on next launch. 23 | func testAdd() throws { 24 | app.launch() 25 | app.navigationBars["Daily Standups"].buttons["Add"].tap() 26 | 27 | let collectionViews = app.collectionViews 28 | let titleTextField = collectionViews.textFields["Title"] 29 | let nameTextField = collectionViews.textFields["Name"] 30 | 31 | titleTextField.typeText("Engineering") 32 | 33 | nameTextField.tap() 34 | nameTextField.typeText("Blob") 35 | 36 | collectionViews.buttons["New attendee"].tap() 37 | app.typeText("Blob Jr.") 38 | 39 | app.navigationBars["New standup"].buttons["Add"].tap() 40 | 41 | XCTAssertEqual(collectionViews.staticTexts["Engineering"].exists, true) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dariusz Rybicki Darrarski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "tca-swift-log", 6 | platforms: [ 7 | .iOS(.v13), 8 | .macOS(.v10_15), 9 | .tvOS(.v13), 10 | .watchOS(.v6), 11 | ], 12 | products: [ 13 | .library(name: "TCASwiftLog", targets: ["TCASwiftLog"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/apple/swift-log.git", from: "1.5.2"), 17 | .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.0.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "TCASwiftLog", 22 | dependencies: [ 23 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 24 | .product(name: "Logging", package: "swift-log"), 25 | ] 26 | ), 27 | .testTarget( 28 | name: "TCASwiftLogTests", 29 | dependencies: [ 30 | .target(name: "TCASwiftLog"), 31 | ] 32 | ), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComposableArchitecture + SwiftLog 2 | 3 | ![Swift v5.8](https://img.shields.io/badge/swift-v5.8-orange.svg) 4 | ![platforms iOS, macOS, tvOS, watchOS](https://img.shields.io/badge/platforms-iOS,_macOS,_tvOS,_watchOS-blue.svg) 5 | 6 | Log actions and state changes in [ComposableArchitecture](https://github.com/pointfreeco/swift-composable-architecture) applications using [SwiftLog](https://github.com/apple/swift-log) library. 7 | 8 | ## 📖 Usage 9 | 10 | Use [Swift Package Manager](https://swift.org/package-manager/) to add the `TCASwiftLog` library as a dependency to your project. 11 | 12 | Use `_ReducerPrinter.swiftLog` on your reducer to log actions and state mutations: 13 | 14 | ```swift 15 | import TCASwiftLog 16 | 17 | let store = Store(initialState: AppFeature.State()) { 18 | AppFeature()._printChanges(.swiftLog(label: "tca")) 19 | } 20 | ``` 21 | 22 | ### ▶️ Example 23 | 24 | This repository contains an example iOS application from ComposableArchitecture repository - [Standups](Examples/Standups). 25 | 26 | - Open `TCASwiftLog.xcworkspace` in Xcode. 27 | - Run the example app using `Standups` build scheme. 28 | - "Standups" tab contains UI of the example app. 29 | - "Logs Console" tab contains [PulseUI](https://kean-docs.github.io/pulseui/documentation/pulseui/) logs console. 30 | 31 | The example app uses [Pulse](https://github.com/kean/Pulse) as a logging system's log handler. It also integrates UI for logs console. Remote logging can be enabled in the console settings. 32 | 33 | ![Standups iOS app + Pulse macOS app](Examples/Screenshot001.png) 34 | ![Standups iOS app + PulseUI console](Examples/Screenshot002.png) 35 | 36 | ## 🏛 Project structure 37 | 38 | ``` 39 | TCASwiftLog (Xcode Workspace) 40 | ├─ tca-swift-log (Swift Package) 41 | | └─ TCASwiftLog (Library) 42 | └─ Standups (Xcode Project) 43 | └─ Standups (Example iOS Application) 44 | ``` 45 | 46 | ## 🛠 Develop 47 | 48 | - Use Xcode (version ≥ 14.3.1). 49 | - Clone the repository or create a fork & clone it. 50 | - Open `TCASwiftLog.xcworkspace` in Xcode. 51 | - Use the `TCASwiftLog` scheme for building the library and running unit tests. 52 | - If you want to contribute, create a pull request containing your changes or bug fixes. Make sure to include tests for new/updated code. 53 | 54 | ## ☕️ Do you like the project? 55 | 56 | Buy Me A Coffee 57 | 58 | ## 📄 License 59 | 60 | Copyright © 2023 Dariusz Rybicki Darrarski 61 | 62 | License: [MIT](LICENSE) 63 | -------------------------------------------------------------------------------- /Sources/TCASwiftLog/ReducerPrinter.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Logging 3 | 4 | extension _ReducerPrinter { 5 | /// Logs info about received actions and state changes to swift-log's Logger with provided label. 6 | /// 7 | /// Example usage: 8 | /// ``` 9 | /// let store = Store(initialState: AppFeature.State()) { 10 | /// AppFeature()._printChanges(.swiftLog(label: "tca")) 11 | /// } 12 | /// ``` 13 | /// 14 | /// - Parameter label: Logger's label 15 | public static func swiftLog(label: String) -> Self { 16 | var logger = Logger(label: label) 17 | logger.logLevel = .debug 18 | return swiftLog(logger) 19 | } 20 | 21 | /// Logs info about received actions and state changes to provided swift-log's Logger. 22 | /// 23 | /// Example usage: 24 | /// ``` 25 | /// var logger = Logger(label: "tca") 26 | /// logger.logLevel = .debug 27 | /// let store = Store(initialState: AppFeature.State()) { 28 | /// AppFeature()._printChanges(.swiftLog(logger)) 29 | /// } 30 | /// ``` 31 | /// 32 | /// - Parameter logger: Logger 33 | public static func swiftLog(_ logger: Logger) -> Self { 34 | Self { receivedAction, oldState, newState in 35 | var message = "received action:\n" 36 | CustomDump.customDump(receivedAction, to: &message, indent: 2) 37 | message.write("\n") 38 | message.write(diff(oldState, newState).map { "\($0)\n" } ?? " (No state changes)\n") 39 | logger.debug(.init(stringLiteral: message)) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /TCASwiftLog.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /TCASwiftLog.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TCASwiftLog.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TCASwiftLog.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", 9 | "version" : "1.0.0" 10 | } 11 | }, 12 | { 13 | "identity" : "pulse", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/kean/Pulse.git", 16 | "state" : { 17 | "revision" : "f41d4d9597cd6e4611bb87bab70e23013c251177", 18 | "version" : "4.0.1" 19 | } 20 | }, 21 | { 22 | "identity" : "pulseloghandler", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/kean/PulseLogHandler.git", 25 | "state" : { 26 | "revision" : "2564a27156fb07ae4916c9fe628577a03f6dc4f9", 27 | "version" : "4.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-case-paths", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/swift-case-paths", 34 | "state" : { 35 | "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81", 36 | "version" : "1.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-clocks", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/pointfreeco/swift-clocks", 43 | "state" : { 44 | "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", 45 | "version" : "1.0.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-collections", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-collections", 52 | "state" : { 53 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 54 | "version" : "1.0.4" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-composable-architecture", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", 61 | "state" : { 62 | "revision" : "195284b94b799b326729640453f547f08892293a", 63 | "version" : "1.0.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-concurrency-extras", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 70 | "state" : { 71 | "revision" : "ea631ce892687f5432a833312292b80db238186a", 72 | "version" : "1.0.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-custom-dump", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 79 | "state" : { 80 | "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", 81 | "version" : "1.0.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-dependencies", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swift-dependencies", 88 | "state" : { 89 | "revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5", 90 | "version" : "1.0.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-identified-collections", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 97 | "state" : { 98 | "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", 99 | "version" : "1.0.0" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-log", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-log.git", 106 | "state" : { 107 | "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", 108 | "version" : "1.5.2" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-tagged", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/pointfreeco/swift-tagged.git", 115 | "state" : { 116 | "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", 117 | "version" : "0.10.0" 118 | } 119 | }, 120 | { 121 | "identity" : "swiftui-navigation", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 124 | "state" : { 125 | "revision" : "f5bcdac5b6bb3f826916b14705f37a3937c2fd34", 126 | "version" : "1.0.0" 127 | } 128 | }, 129 | { 130 | "identity" : "xctest-dynamic-overlay", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 133 | "state" : { 134 | "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", 135 | "version" : "1.0.2" 136 | } 137 | } 138 | ], 139 | "version" : 2 140 | } 141 | -------------------------------------------------------------------------------- /Tests/TCASwiftLogTests/ReducerPrinterTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CustomDump 3 | import Logging 4 | import XCTest 5 | @testable import TCASwiftLog 6 | 7 | final class ReducerPrinterTests: XCTestCase { 8 | func testPrint() throws { 9 | enum Action { 10 | case test1 11 | case test2 12 | } 13 | struct State { 14 | var count: Int 15 | } 16 | let handler = TestLogHandler() 17 | let logger = Logger(label: "test", factory: { _ in handler }) 18 | let printer = _ReducerPrinter.swiftLog(logger) 19 | printer.printChange( 20 | receivedAction: .test1, 21 | oldState: .init(count: 0), 22 | newState: .init(count: 1) 23 | ) 24 | printer.printChange( 25 | receivedAction: .test2, 26 | oldState: .init(count: 1), 27 | newState: .init(count: 1) 28 | ) 29 | 30 | XCTAssertNoDifference(handler.logged, [ 31 | .init( 32 | level: .debug, 33 | message: """ 34 | received action: 35 | ReducerPrinterTests.Action.test1 36 | - ReducerPrinterTests.State(count: 0) 37 | + ReducerPrinterTests.State(count: 1) 38 | 39 | """ 40 | ), 41 | .init( 42 | level: .debug, 43 | message: """ 44 | received action: 45 | ReducerPrinterTests.Action.test2 46 | (No state changes) 47 | 48 | """ 49 | ) 50 | ]) 51 | } 52 | } 53 | 54 | private class TestLogHandler: LogHandler { 55 | struct Logged: Equatable { 56 | var level: Logger.Level 57 | var message: Logger.Message 58 | } 59 | 60 | init() {} 61 | 62 | var logged: [Logged] = [] 63 | 64 | @inlinable func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt) { 65 | logged.append(.init(level: level, message: message)) 66 | } 67 | 68 | @inlinable subscript(metadataKey _: String) -> Logger.Metadata.Value? { 69 | get { nil } 70 | set {} 71 | } 72 | 73 | @inlinable var metadata: Logger.Metadata { 74 | get { [:] } 75 | set {} 76 | } 77 | 78 | @inlinable var logLevel: Logger.Level { 79 | get { .debug } 80 | set {} 81 | } 82 | } 83 | --------------------------------------------------------------------------------