├── .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 | 
4 | 
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 | 
34 | 
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 |
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 |
--------------------------------------------------------------------------------