Video #247: Tour of the Composable Architecture: Domain Modeling
Episode: Video #247 Date: Aug 28, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep247-tour-of-the-composable-architecture-1-0-correctness

Description
We’ll learn how to precisely model navigation in the Composable Architecture using an enum to eliminate impossible runtime states at compile time. And we’ll begin to implement the app’s most complex screen and most complex dependency: the record meeting view and the speech client.
Video
Cloudflare Stream video ID: 31415d09089f116e044e9bb39a52a9b1 Local file: video_247_tour-of-the-composable-architecture-1-0-correctness.mp4 *(download with --video 247)*
References
- Discussions
- Composable Architecture
- Getting started with Scrumdinger
- 0247-tca-tour-pt5
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
And as more types of navigation are added, the number of invalid states really explodes. Four navigation destinations have only 5 valid states, all nil or exactly one non- nil , yet for optionals have 16 possible states. So 70% of the states are completely invalid. And if there were 5 navigation destinations, then over 90% of the states would be invalid! Stephen
— 0:33
What we are seeing here is that representation multiple navigation destinations with multiple optionals is just not the right way to handle this. Luckily Swift has an amazing tool for dealing with this situation, and it is enums! Enums are the perfect tool for representing the mutually exclusive choice of one thing from many, which is exactly what we want here.
— 0:52
With just a little bit of upfront work we can refactor our domain to use enums instead of many optionals, and the code will become clearly, more succinct and safer.
— 1:01
So let’s give it a shot. Enum presentation state
— 1:04
Interestingly, we can actually take a lot of inspiration from what we did for our navigation stack at the root of the application. Recall that we modeled all the different features that can be presented in a stack using a dedicated reducer with a state enum. We are going to do the same here, but we will call it Destination this time instead of Path : struct Destination: Reducer { }
— 1:38
The State type in the reducer will be an enum, and it will hold a case for each type of feature that can be navigated to from the detail, which for now is the alert and edit standup sheet: struct Destination: Reducer { enum State: Equatable { case alert(AlertState<Action.Alert>) case editStandup(StandupFormFeature.State) } }
— 2:08
And the Action type will also be an enum with a case for each kind of destination: enum Action: Equatable { case alert(Alert) case editStandup(StandupFormFeature.Action) enum Alert { case confirmDeletion } }
— 2:34
And the body of the reducer will be implemented in a similar fashion to the Path reducer, where we just scope on the state and actions to fit in each child feature: var body: some ReducerOf<Self> { Scope( state: /State.editStandup, action: /Action.editStandup ) { StandupFormFeature() } } And there’s nothing to do for the alert, because as we mentioned before, it has no internal logic.
— 3:06
With the destination reducer implemented, we can now trade two pieces of optional state for one: struct State: Equatable { // @PresentationState // var alert: AlertState<Action.Alert>? // // @PresentationState var editStandup: StandupForm.State? @PresentationState var destination: Destination.State? … }
— 3:23
And in the future if we have more destinations to add, we will just add cases to the destination’s state and actions.
— 3:29
We also get to trade out two presentation actions for one: enum Action: Equatable { // case alert(PresentationAction<Alert>) // // case editStandup( // PresentationAction<StandupForm.Action> // ) case destination(PresentationAction<Destination.Action>) … }
— 3:45
And we get to trade out two ifLet operations for a single one: // .ifLet(\.$alert, action: /Action.alert) // .ifLet(\.$editStandup, action: /Action.editStandup) { // StandupForm() // } .ifLet(\.$destination, action: /Action.destination) { Destination() }
— 4:11
With that done we do have some compilation errors where we were previously trying to pattern match directly on alert or edit actions. Now those actions are bundled up inside the destination case: case .destination(.presented(.alert(.confirmDeletion))): return .none case .destination(.dismiss): return .none
— 4:38
And there are spots where we were individually referencing the editStandup or alert state, but now that can just deal with the single piece of destination state: case .cancelEditStandupButtonTapped: state.destination = nil return .none … case .deleteButtonTapped: state.destination = .alert( AlertState { TextState("Are you sure you want to delete?") } actions: { ButtonState( role: .destructive, action: .confirmDeletion ) { TextState("Delete") } } ) … case .editButtonTapped: state.destination = .editStandup( StandupFormFeature.State(standup: state.standup) ) return .none … case .destination: return .none … case .saveStandupButtonTapped: guard case let .editStandup(standupForm) = state.destination else { return .none } state.standup = standupForm.standup state.destination = nil return .send( .delegate(.standupUpdated(standupForm.standup)) ) And that’s all it takes for the reducer layer. Our domain is now modeled far more concisely using enums, and it is not impossible to put our feature into an inconsistent state. It’s just not possible to accidentally try to show an alert and edit sheet at the same time, and we now only have one single piece of state to check if we ever want to see if something is presented, or what exactly is presented.
— 5:57
The only compiler errors we have right now are in the view, and that’s because this use of the sheet modifier is no longer correct: .sheet( store: self.store.scope( state: \.$editStandup, action: { .editStandup($0) } ) ) { store in … }
— 6:04
First of all, alert and editStandup state no longer exists, so I guess at the very least that should be destination : .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ) ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ) ) { store in … }
— 6:06
But even that is not correct, because it’s not only the destination state that drives the alert and sheet. It’s when the destination enum is pointing at the alert and editStandup cases that we want to show either an alert or a sheet.
— 6:27
Well, there are two additional arguments that one can provide to both the alert and sheet view modifiers that lets you further single out a case in the destination domain: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: <#(State) -> AlertState<DestinationAction>?#>, action: <#(DestinationAction) -> Action#>, ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: <#(State) -> DestinationState?#>, action: <#(DestinationAction) -> Action#>, ) { store in … }
— 6:41
The first argument describes how one can extract the editStandup case from the destination enum, and we can simply use the case path to do that: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /StandupDetailFeature.Destination.State.alert, action: <#(DestinationAction) -> Action#>, ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /StandupDetailFeature.Destination.State.editStandup, action: <#(DestinationAction) -> Action#>, ) { store in … }
— 7:14
And the action argument describes how one takes an action in the child domain, and puts it back into the destination domain, so we can provide the case embed functions for that: .alert( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /StandupDetailFeature.Destination.State.alert, action: StandupDetailFeature.Destination.Action.alert, ) .sheet( store: self.store.scope( state: \.$destination, action: { .destination($0) } ), state: /StandupDetailFeature.Destination.State.editStandup, action: StandupDetailFeature.Destination.Action.editStandup ) { store in … }
— 7:39
Now things are compiling again, and the feature should work exactly as it did before, but now with a more concise domain.
— 7:47
And we can run the feature in the preview to see that when we tap the “Delete” button an alert does show. And we can be guaranteed that it is absolutely impossible for an alert and sheet to be displayed at the same time.
— 8:02
Of course tapping “Delete” in the alert doesn’t do anything, but that just because we haven’t implemented that integration point yet. Delete alert integration Brandon
— 8:08
And that integration point is surprisingly easy to implement. There are two parts to it. One is communicating to the parent that we want to delete the standup, and the other is to then pop the detail screen off the stack.
— 8:21
Let’s do that real quick.
— 8:24
Let’s start with popping the detail screen off the stack when deleting, because it is surprisingly easy and allows us to show off another super power of the navigation tools in the Composable Architecture.
— 8:44
The library ships a dependency known as dismiss : @Dependency(\.dismiss) var dismiss
— 8:53
It is very similar to the dismiss environment value, which allows a child view to dismiss itself from a presentation context. Whether the feature is presented in a sheet, popover, stack, or what have you, invoking the dismiss function causes SwiftUI to find the binding up the view hierarchy that is driving the presentation, and mutate it in order to dismiss the child feature.
— 9:24
That is really powerful, but also it is 100% relegated to the view layer. If you wanted to write nuanced logic around the dismissal, like say perform an API request, and then based on the response of that API request perform the dismissal, then you would be forced to cram all that logic into the view.
— 9:50
Well, the dismiss dependency serves a similar purpose as the environment value, except it is tuned specifically to work in the reducer of a Composable Architecture feature. It gives any child feature the ability to dismiss itself from a presentation, whether it is presented using the ifLet operator or the forEach operator.
— 10:10
The way you use it is to return an effect from an action, and invoke the dismiss function, which needs to be awaited since it is async: case .destination(.presented(.alert(.confirmDeletion))): return .run { _ in await self.dismiss() }
— 10:39
That right there will communicate to the parent presenting the feature, and either send the dismiss presentation action or the popFrom stack action in order to clear out the child feature’s state.
— 10:52
With that one little change we can already test it out. We can run the app in the simulator, drill down to a standup, tap delete, confirm deletion, and we will see that the detail feature is automatically popped off the stack. Of course, the standup is not deleted, but we just have implemented that part yet.
— 11:07
But already this is super impressive. The ability for the child to be able to dismiss itself is a great way to encapsulate child behavior. The parent does not need to worry about popping the detail screen off the stack when the standup is deleted. And best of all, it is of course all 100% testable, but we will look at that in a moment.
— 11:37
Let’s implement the last piece of integration between the detail feature and app feature, that of actually deleting the standup once confirmed. We will use the delegate actions pattern that we used before. In fact, the detail feature already uses delegate actions, so we just have to add a new one to the Delegate enum: enum Delegate: Equatable { case deleteStandup(id: Standup.ID) case standupUpdated(Standup) } We’ll even provide the ID of the standup to be deleted so that it is readily accessible to the parent.
— 12:18
And then before the detail feature dismisses itself it can send the delegate action: case .destination(.presented(.alert(.confirmDeletion))): return .run { [id = state.standup.id] send in await send(.delegate(.deleteStandup(id: id))) await self.dismiss() }
— 12:45
Now we have a single compiler error in the project, and that is where we are switching on the delegate action in the parent. We now have a new action to handle, and this is where we can delete the standup: switch delegate { … case let .deleteStandup(id: id) state.standupsList.standups.remove(id: id) return .none }
— 13:12
And that’s all it takes to facilitate the child-to-parent communication, and it works exactly as we would expect. We can drill down to the standup, delete, and we will be popped back to the root, and we see the standup was indeed deleted.
— 13:26
So, things are looking really incredible. The app is nearly full featured. All that is left is the functionality for recording a new meeting, seeing your past meetings, as well as persisting data so that next time we launch the app we don’t lose all of our data.
— 13:40
But before moving onto that, let’s write a test for this new delete functionality. We already have a pretty comprehensive test suite, but we’ve just added some new functionality, and we’ve even refactored things. So it will be nice to get tests back in good shape.
— 13:53
Right now our test suite has lots of compiler errors due to that refactor of the detail’s presentation state into a destination enum. For example, this line in the detail tests: await store.send( .editStandup( .presented(.set(\.$standup.title, "Engineering")) ) )
— 14:14
…is no longer correct because we no longer hold onto editStandup actions directly in the detail domain. Instead they are held in a dedicated Destination action enum. So, we just have to go through one more layer of actions: await store.send( .destination( .presented( .editStandup(.set(\.$standup.title, "Engineering")) ) ) )
— 14:29
And now this test is compiling. Sure, it’s a little more verbose, but this is just the cost of dealing with a very concisely modeled domain, and at least we have the compiler helping us every step of the way.
— 14:48
The next compile errors are over in the app feature tests. For example, when emulating the user tapping the “Edit” button in the detail feature we now need to assign the destination to the editStandup case rather than mutating the editStandup state directly: await store.send( .path( .element(id: 0, action: .detail(.editButtonTapped)) ) ) { $0.path[id: 0, case: /AppFeature.Path.State.detail]? .destination = .editStandup( StandupForm.State(standup: standup) ) }
— 15:27
And when emulating typing into the title field of the standup we need to go through the detail case of the path and the editStandup case of the destination: await store.send( .path( .element( id: 0, action: .detail( .destination( .presented( .editStandup( .set(\.$standup, editedStandup) ) ) ) ) ) ) ) { $0.path[id: 0, case: /AppFeature.Path.State.detail]? .$destination[case: /StandupDetailFeature.Destination.State.editStandup]? .standup.title = "Point-Free" }
— 17:19
It’s a little intense, but also remember we are asserting on how 3 entire, separate features integrate together. We have a navigation stack of many features, an element in that stack can present a sheet, and the feature in that sheet has its own logic and behavior. So we are doing something quite amazing in this one line of code, and it’s amazing that we are even able to make it as succinct as it is.
— 17:48
Let’s quickly fix the rest of the problems.
— 18:26
Tests are back to building, and the whole test suite passes. And thanks to non-exhaustive testing, many of the tests were very simple to update.
— 19:06
Now we can write a test to exercise the deletion behavior. We can even start the application in a specific state where a standup detail is already pushed onto the stack, that way we don’t have to futz around with pushing the feature onto the stack in the test.
— 0:00
But let’s make things even more interesting. Let’s also have another standup in the root collection so that we can make sure it is untouched during the deletion process: func testDelete_NonExhaustive() async { let standup = Standup.mock let store = TestStore( initialState: AppFeature.State( path: StackState([ .detail( StandupDetailFeature.State(standup: standup) ) ]), standupsList: StandupsListFeature.State( standups: [ standup ] ) ) ) { AppFeature() } }
— 19:55
Further, in the interest of time and keeping things moving along, we are going to do only a non-exhaustive test: store.exhaustivity = .off We still do recommend having a baseline of a few fully exhaustive tests so that you truly know how all of the features work together.
— 19:59
Then all we have to do is tap the “Delete” button, confirm deletion, and then assert that after all actions are received that the path and standups are both reset back to empty collections: await store.send( .path( .element(id: 0, action: .detail(.deleteButtonTapped)) ) ) await store.send( .path( .element( id: 0, action: .detail( .destination(.presented(.alert(.confirmDeletion))) ) ) ) ) await store.skipReceivedActions() store.assert { $0.path = StackState([]) $0.standupsList.standups = [] }
— 21:55
This test passes, and proves what we can see plain as day in the simulator. When the user goes through the full flow of deleting a standup, we are indeed popped back to the root of the application and that standup is removed from the collection. And further, the other standup that was in the collection still remains. Recording a meeting
— 22:20
OK, things are looking really fantastic. We’ve got multiple features built, we’ve got multiple navigation paths amongst those features, along with interesting child-parent communication patterns, and the whole thing even has a comprehensive test suite. Stephen
— 22:32
Let’s now move onto the last substantial feature of the application, and that is the record meeting feature. This will be the first time we come face-to-face with really complex logic and asynchronous side effects. This feature manages a timer and a speech recognizer for transcribing the meeting while it is taking place.
— 22:48
It’s a tough one, so let’s get started.
— 22:52
Let’s start by creating a new file.
— 22:58
I am going to paste in the scaffolding of a full Composable Architecture, along with its view: import ComposableArchitecture import Speech import SwiftUI struct RecordMeetingFeature: Reducer { struct State: Equatable { } enum Action: Equatable { } var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } } } struct RecordMeetingView: View { let store: StoreOf<RecordMeetingFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in ZStack { RoundedRectangle(cornerRadius: 16) .fill(<#Color.orange#>) VStack { MeetingHeaderView( secondsElapsed: <#0#>, durationRemaining: <#.seconds(10)#>, theme: <#.bubblegum#> ) MeetingTimerView( standup: <#.mock#>, speakerIndex: <#0#> ) MeetingFooterView( standup: <#.mock#>, nextButtonTapped: { <#Do something#> }, speakerIndex: <#0#> ) } } .padding() .foregroundColor(<#Color.black#>) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("End meeting") { <#Do something#> } } } .navigationBarBackButtonHidden(true) } } } struct MeetingHeaderView: View { let secondsElapsed: Int let durationRemaining: Duration let theme: Theme var body: some View { VStack { ProgressView(value: self.progress) .progressViewStyle( MeetingProgressViewStyle(theme: self.theme) ) HStack { VStack(alignment: .leading) { Text("Time Elapsed") .font(.caption) Label( Duration.seconds(self.secondsElapsed) .formatted(.units()), systemImage: "hourglass.bottomhalf.fill" ) } Spacer() VStack(alignment: .trailing) { Text("Time Remaining") .font(.caption) Label( self.durationRemaining.formatted(.units()), systemImage: "hourglass.tophalf.fill" ) .font(.body.monospacedDigit()) .labelStyle(.trailingIcon) } } } .padding([.top, .horizontal]) } private var totalDuration: Duration { .seconds(self.secondsElapsed) + self.durationRemaining } private var progress: Double { guard self.totalDuration > .seconds(0) else { return 0 } return Double(self.secondsElapsed) / Double(self.totalDuration.components.seconds) } } struct MeetingProgressViewStyle: ProgressViewStyle { var theme: Theme func makeBody( configuration: Configuration ) -> some View { ZStack { RoundedRectangle(cornerRadius: 10) .fill(self.theme.accentColor) .frame(height: 20) ProgressView(configuration) .tint(self.theme.mainColor) .frame(height: 12) .padding(.horizontal) } } } struct MeetingTimerView: View { let standup: Standup let speakerIndex: Int var body: some View { Circle() .strokeBorder(lineWidth: 24) .overlay { VStack { Group { if self.speakerIndex < self.standup.attendees.count { Text( self.standup.attendees[self.speakerIndex] .name ) } else { Text("Someone") } } .font(.title) Text("is speaking") Image(systemName: "mic.fill") .font(.largeTitle) .padding(.top) } .foregroundStyle(self.standup.theme.accentColor) } .overlay { ForEach( Array(self.standup.attendees.enumerated()), id: \.element.id ) { index, attendee in if index < self.speakerIndex + 1 { SpeakerArc( totalSpeakers: self.standup.attendees.count, speakerIndex: index ) .rotation(Angle(degrees: -90)) .stroke( self.standup.theme.mainColor, lineWidth: 12 ) } } } .padding(.horizontal) } } struct SpeakerArc: Shape { let totalSpeakers: Int let speakerIndex: Int func path(in rect: CGRect) -> Path { let diameter = min( rect.size.width, rect.size.height ) - 24 let radius = diameter / 2 let center = CGPoint(x: rect.midX, y: rect.midY) return Path { path in path.addArc( center: center, radius: radius, startAngle: self.startAngle, endAngle: self.endAngle, clockwise: false ) } } private var degreesPerSpeaker: Double { 360 / Double(self.totalSpeakers) } private var startAngle: Angle { Angle( degrees: self.degreesPerSpeaker * Double(self.speakerIndex) + 1 ) } private var endAngle: Angle { Angle( degrees: self.startAngle.degrees + self.degreesPerSpeaker - 1 ) } } struct MeetingFooterView: View { let standup: Standup var nextButtonTapped: () -> Void let speakerIndex: Int var body: some View { VStack { HStack { if self.speakerIndex < self.standup.attendees.count - 1 { Text( """ Speaker \(self.speakerIndex + 1) \ of \(self.standup.attendees.count) """ ) } else { Text("No more speakers.") } Spacer() Button(action: self.nextButtonTapped) { Image(systemName: "forward.fill") } } } .padding([.bottom, .horizontal]) } } #Preview { MainActor.assumeIsolated { NavigationStack { RecordMeetingView( store: Store(initialState: RecordMeeting.State()) { RecordMeeting() } ) } } }
— 23:11
And there are a whole bunch of placeholders in here that we will need to fix up as we implement more of the domain and logic.
— 23:16
The view was mostly taken straight from Apple’s Scrumdinger application, and there isn’t too many interesting things in it. There are a bunch of helper views for showing rendering things like the speaker arc, timer, header, etc. Almost all of this is identical to what Apple does in the Scrumdinger application.
— 23:30
We can run it in the preview we see something very reasonable. There’s a circular progress indicator that let’s us know which speaker is currently speaking, and there’s some metadata in the header and footer, and there’s an “End meeting” button in the top-left.
— 23:47
Before implementing any of the logic or behavior of this feature, let’s figure out what it takes to navigate to this feature. This will give us a chance to see all the steps necessary to add new features to a navigation.
— 23:57
We will first need to incorporate the RecordMeeting feature’s domain into the Path domain in the app feature: struct Path: Reducer { enum State: Equatable { case detail(StandupDetailFeature.State) case recordMeeting(RecordMeeting.State) } enum Action: Equatable { case detail(StandupDetailFeature.Action) case recordMeeting(RecordMeeting.Action) } … }
— 24:20
And we’ll need to incorporate the RecordMeeting reducer into the body of the Path reducer: struct Path: Reducer { … var body: some ReducerOf<Self> { Scope(state: /State.detail, action: /Action.detail) { StandupDetailFeature() } Scope( state: /State.recordMeeting, action: /Action.recordMeeting ) { RecordMeeting() } } }
— 24:26
Now we have a very helpful compiler error down in the app feature view letting us know that there is another path case to handle: switch state { case .detail: … case .recordMeeting: CaseLet( /AppFeature.Path.State.recordMeeting, action: AppFeature.Path.Action.recordMeeting, then: RecordMeetingView.init(store:) ) }
— 24:50
And that is all it takes. Just those 3 easy steps have fully incorporated the record meeting feature into the navigation stack at the root of the application.
— 24:59
Now we are in a position to actually perform the navigation. The easiest way is to simply use a NavigationLink right in the detail view and point it at the record meeting state: NavigationLink( state: AppFeature.Path.State.recordMeeting( RecordMeeting.State() ) ) { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) }
— 25:41
And incredibly that is all it takes to navigate to the record meeting screen from the detail. We can even take it for a spin in the simulator.
— 25:55
Of course nothing is functional in the record meeting feature yet, but we will be getting to that soon.
— 25:59
We can even deep link into a state where we are drilled down to the detail screen, and further drilled down to the record screen. We just need to update the entry point of the application so that the path holds to elements: initialState: AppFeature.State( path: StackState([ .detail(StandupDetailFeature.State(standup: .mock)), .recordMeeting(RecordMeeting.State()), ]), standupsList: StandupsListFeature.State( standups: [.mock] ) )
— 26:29
And just like when we launch the app in the simulator we are immediately drilled down into record screen. It’s incredible how easy this is thanks to us modeling all of navigation in state and integrating features together. This kind of deep linking simply is not possible in Apple’s Scrumdinger code entirely because of its use of the @State property wrapper. It makes it easy to get started with things, but that state exists locally in the view only and cannot be influenced from the outside.
— 26:56
Let’s quickly comment out the deep linking in the entry point for now.
— 27:00
We are finally starting to see that things start to get really easy once we do the upfront work to model our domains concisely. Sure it was a little bit of extra work at the beginning, back when our domains were simple. But as our domains get more and more complex, that little bit of foundational work really starts to pay dividends. And personally I think it’s far better for things to become easier as the app gets more complex rather than things being easy when the app is super simple.
— 27:24
So, now that we have basic navigation to the record meeting, we can start implementing the logic and behavior of the record meeting. But where to start?
— 27:31
Well, right now the State and Action types are completely empty, so perhaps can take some inspiration from the view to see what should be in these types. Looking through the todos in the view I do see that a lot of things will be taken straight from the standup data type, such as theme colors, attendees and more. So let’s start by adding a standup to the domain, and this time it can even be a let because we don’t expect to need to make any mutations to it: struct RecordMeeting: ReducerProtocol { struct State: Equatable { let standup: Standup } … }
— 27:59
That does cause a few compiler errors in the project. We now need to supply a standup in our record meeting preview: store: Store( initialState: RecordMeeting.State(standup: .mock) ) { RecordMeeting() }
— 28:08
And we need to provide a standup to the NavigationLink in the detail, which is easy enough to do: NavigationLink( state: AppFeature.Path.State.recordMeeting( RecordMeeting.State(standup: viewStore.standup) ) ) { … }
— 28:19
Next we will want to capture some state for the number of seconds that have elapsed in the meeting so far. Recall that a timer counts down in the feature, giving each attendee a set amount of time to speak: struct State: Equatable { var secondsElapsed = 0 … }
— 28:40
And it will be helpful to know how many seconds are left in the meeting, so we will add a computed property for that: var durationRemaining: Duration { self.standup.duration - .seconds(self.secondsElapsed) }
— 29:01
We’ll also want to know the current speaker is, so we can hold an index for that: struct State: Equatable { … var speakerIndex = 0 … }
— 29:09
There will probably be more state to add, but that should be enough to get us started.
— 29:13
We can now use this new state to fill in a bunch of things in the view, such as the background fill color based on the standup theme: RoundedRectangle(cornerRadius: 16) .fill(viewStore.standup.theme.mainColor)
— 29:19
And the header, timer and footer can all be filled in too: VStack { MeetingHeaderView( secondsElapsed: viewStore.secondsElapsed, durationRemaining: viewStore.durationRemaining, theme: viewStore.standup.theme ) MeetingTimerView( standup: viewStore.standup, speakerIndex: viewStore.speakerIndex ) MeetingFooterView( standup: viewStore.standup, nextButtonTapped: { <#Do something#> }, speakerIndex: viewStore.speakerIndex ) }
— 29:28
And I think the last placeholder we can handle right now is the foreground color: .foregroundColor(viewStore.standup.theme.accentColor)
— 29:32
The remaining two placeholders have to do with actions we need to send to the store, such as tapping the next button for skipping a speaker: nextButtonTapped: { viewStore.send(.nextButtonTapped) },
— 29:45
As well as the “End meeting” button: Button("End meeting") { viewStore.send(.endMeetingButtonTapped) }
— 29:51
Let’s add those actions to our domain: enum Action: Equatable { case endMeetingButtonTapped case nextButtonTapped }
— 29:55
…and stub out their implementation in the reducer: switch action { case .endMeetingButtonTapped: return .none case .nextButtonTapped: return .none }
— 30:03
We don’t yet have all the pieces in place to implement these actions yet, so let’s move onto something more exciting.
— 30:07
The record meeting feature is quite a bit different from the others in that as soon as the view starts it kicks of some side effects. First of all, right off the bat it asks you for permission to the speech recognizer so that it can transcribe the meeting while people talk. That is definitely a side effect since it involves reaching into some of Apple’s APIs, which in turn as the user for permission, and then their decision needs to feed back into the system so that we can react accordingly.
— 30:32
This is a perfect opportunity to start using effects in our reducers. We’ve already gotten very basic use from them, such as sending delegate actions and dismiss child features, but here is where we get a lot more mileage out of them.
— 30:44
Recall that SwiftUI has a fancy view modifier called task that allows one to execute asynchronous work when a view appears, and further when the view disappears the async work will automatically be cancelled. The Composable Architecture comes with the tools to tie the lifetime of effects to the lifetime of views. You can send an action, and then await all effects finishing: .task { await viewStore.send(.onTask).finish() }
— 31:20
So, let’s add that action to our domain: enum Action: Equatable { case onTask … }
— 31:25
And let’s handle the action in our reducer by opening up a new effect. The standard way to do this is to return a run effect, which takes a trailing closure where you can perform any async work you want, and that closure is handed a send function that allows you to send actions back into the system: switch action { … case .onTask: return .run { send in } }
— 31:39
So, maybe we can call out to SFSpeechRecognizer ’s requestAuthorization endpoint so that we can ask the user for permission: return .run { send in SFSpeechRecognizer.requestAuthorization { status in } }
— 31:49
However, this requestAuthorization endpoint is still done in the old, callback style. It is not an async function.
— 31:55
Well, we can bridge it to an async function quite easily by using a continuation: return .run { send in let status = await withUnsafeContinuation { continuation in SFSpeechRecognizer.requestAuthorization { status in continuation.resume(with: .success(status)) } } }
— 32:22
This is now pretty cool. We have created a suspension point that will suspend for as long as the system alert is up asking the user for permission. And once they tap a button to allow or decline, the suspension point will terminate, we will get the status back, and the effect will continue with whatever other work needs to be performed.
— 32:41
For now, let’s just print the authorization status after we get it back: return .run { send in … print(status.customDumpDescription) }
— 32:44
Let’s give it a spin. If we run the app and start a meeting, we get the system alert asking for permission, and if we grant it we see the following printed to the console: SFSpeechRecognizerAuthorizationStatus.authorized
— 32:58
OK, and we are off! We are now executing a somewhat complex effect right in our reducer.
— 33:04
After we get authorization back, the next complex effect we can try starting up and managing is the timer. We’ve already had some experience with timers when we built the counter feature way back at the beginning of this series, so let’s go ahead and do the right thing from the very beginning.
— 33:17
We will add a dependency to this feature on a clock: @Dependency(\.continuousClock) var clock
— 33:22
And then use the clock to start up an async sequence for a 1 second timer: case .onTask: return .run { send in let status = … for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTicked) } }
— 33:42
We need to add the timer tick action to our domain: enum Action: Equatable { case timerTicked … }
— 33:46
And we will handle that action by simply incrementing the seconds elapsed by 1: case .timerTick: state.secondsElapsed += 1 return .none
— 33:57
I might hope that this just works already. After all, we are sending an action when the view first appears, that action kicks off an effect, that effect starts a timer, and with each tick of the timer we send another action to increment the elapsed seconds count. I would hope that when we run the app in the simulator and starts a meeting, we actively see the countdown in the view.
— 34:16
And we do!
— 34:19
So we now have the beginnings of our first complex effect in place. However, I’m getting a little tired of having to launch the app over and over and click around to start a meeting just to see how the record feature is behaving. Let’s see how well the preview works…
— 34:34
Well, the timer is running at all. Next time: Dependencies
— 34:36
This unfortunately brings us face to face with another uncontrolled dependency. We came across these in our counter app way at the beginning, and then again in the Standups app when we needed to generate UUIDs.
— 34:47
The reason this is happening is because previews are incapable of showing the system alert to ask you for speech recognition permission. We don’t know if this is a bug in previews, or if this is how Apple intends for it to work, but regardless the await for fetching the status simply never un-suspends, and so the code after is never executed.
— 35:05
This has completely destroyed our ability to iterate on this feature in previews. We are now forced to run the app in the simulator if we want to iterate on the timer functionality, or really any of its dynamic behavior besides just its simple, static design. And this is all because we are reaching out to Apple’s APIs without regard, and so by accessing uncontrolled dependencies we are allowing them to control us. Brandon
— 35:26
Well, the path forward is to simply not reach out to uncontrolled dependencies. We should do a little bit of upfront work to put an interface in front of the dependency so that we can use different versions of the dependency in previews, tests and more. In this particular situation I would love if I could just tell the dependency that I don’t care about asking the user for permission. Let’s just pretend they granted us permission.
— 35:51
That would be great, and would completely unblock us to start using the preview again, but it’s going to take work…next time! References Composable Architecture Brandon Williams & Stephen Celis • May 4, 2020 The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing and ergonomics in mind. http://github.com/pointfreeco/swift-composable-architecture Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger Downloads Sample code 0247-tca-tour-pt5 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .