From 702333038c43edbda064081de9bc9285f09e86ca Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 4 Jul 2026 14:50:41 +0200 Subject: [PATCH] Move the recording side effects into the RecordingViewModel view model in the Recording package target. --- .../View Models/RecordingViewModel.swift | 65 +++----- .../Recording/Views/RecordingView.swift | 14 +- .../View Models/RecordingViewModelTests.swift | 142 +++++++++--------- 3 files changed, 97 insertions(+), 124 deletions(-) diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 18ec58a..b0e7ace 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -15,7 +15,7 @@ extension RecordingView { // MARK: Properties /// The current state of the recording flow. - var state: State = .notRecording + private(set) var state: State = .notRecording /// The number of seconds spent recording, excluding any time spent paused. private(set) var elapsedSeconds: Int = 0 @@ -30,6 +30,10 @@ extension RecordingView { /// The instant the current recording stretch started, or `nil` while not recording. @ObservationIgnored private var anchor: ContinuousClock.Instant? + + /// The service that captures the audio from a microphone. + @ObservationIgnored + private let capturer: any Capturing /// The clock that measures the recording time. @ObservationIgnored @@ -39,10 +43,6 @@ extension RecordingView { @ObservationIgnored private let locale: Binding - /// The service that captures the audio from a microphone. - @ObservationIgnored - private let capturer: any Capturing - /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording. @ObservationIgnored private var taskTimer: Task? @@ -119,20 +119,28 @@ extension RecordingView { /// Handles a press of the main button. /// - /// Starts a recording when idle, pauses an ongoing recording, or resumes - /// a paused one. Does nothing while the input is being processed. + /// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer + /// and the audio capture accordingly. Does nothing while the input is being processed. func pressedMain() { - guard state != .processing else { - return - } - switch state { case .notRecording: state = .recording + + startTimer(true) + startCapturer(true) case .recording: state = .paused + + stopTimer() + + Task { + try? await capturer.pause() + } case .paused: state = .recording + + startTimer(false) + startCapturer(false) case .processing: break } @@ -140,48 +148,19 @@ extension RecordingView { /// Handles a press of the send button. /// - /// Moves a paused recording into processing. Does nothing in any other state. + /// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state. func pressedSend() { - guard state == .paused else { - return - } - switch state { case .paused: state = .processing - default: - break - } - } - /// Reacts to a change of ``state``, expected to be called from the view whenever it observes one. - /// - /// Starts the timer and the audio capture when a recording begins or resumes, stops the timer in every other state — pausing the capture - /// while paused — and kicks off ``processInput()`` when the input is sent. - /// - /// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` should be reset to zero before the timer starts, which is the case for - /// a new recording as opposed to one resuming from a pause. - func updatedState( - shouldRestartTimer: Bool - ) { - switch state { - case .recording: - startTimer(shouldRestartTimer) - startCapturer(shouldRestartTimer) - case .paused: - stopTimer() - - Task { - try? await capturer.pause() - } - case .processing: stopTimer() Task { await processInput() } - case .notRecording: - stopTimer() + default: + break } } diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index ba1b8d5..29e422a 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -5,7 +5,7 @@ import SwiftUI /// The view shows a main button that starts, pauses, and resumes a recording, with a label above it displaying the elapsed recording time. /// While paused, a send button lets the user submit the recording for processing, showing a progress indicator until the processing finishes. /// -/// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses and state changes. +/// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses. public struct RecordingView: View { // MARK: Properties @@ -44,8 +44,8 @@ public struct RecordingView: View { // MARK: Body - /// The content of the view: the timer label above the main and send buttons, with every change of the model's state forwarded back - /// to ``Model/updatedState(shouldRestartTimer:)``. + /// The content of the view: the timer label above the main and send buttons, with the transcription of every processed recording + /// forwarded to the closure given at initialization. public var body: some View { VStack( spacing: Constant.Spacing.stack @@ -98,14 +98,6 @@ public struct RecordingView: View { } } } - .onChange( - of: model.state, - initial: false - ) { oldValue, _ in - model.updatedState( - shouldRestartTimer: oldValue == .notRecording - ) - } .onChange( of: model.transcription, initial: false diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index e446e2b..1fa23e5 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -41,7 +41,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = initial + model.drive(to: initial) model.pressedMain() @@ -58,7 +58,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = initial + model.drive(to: initial) model.pressedSend() @@ -83,7 +83,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.iconMain == expected) } @@ -98,7 +98,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.iconSend == expected) } @@ -113,7 +113,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.isProcessing == expected) } @@ -128,7 +128,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.shouldDisableMain == expected) } @@ -143,7 +143,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.shouldShowSend == expected) } @@ -158,7 +158,7 @@ struct RecordingViewModelTests { ) { let model = Model() - model.state = state + model.drive(to: state) #expect(model.shouldShowTimer == expected) } @@ -174,28 +174,24 @@ struct RecordingViewModelTests { @Test func `ticks while recording`() async throws { let model = Model() - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(1.2)) #expect(model.elapsedSeconds >= 1) #expect(model.textTimer == "00:01") - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } @Test func `stops while paused`() async throws { let model = Model() - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(1.2)) - model.state = .paused - model.updatedState(shouldRestartTimer: false) + model.pressedMain() let secondsWhenPaused = model.elapsedSeconds @@ -208,43 +204,42 @@ struct RecordingViewModelTests { @Test func `keeps elapsed seconds when resumed`() async throws { let model = Model() - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(1.2)) - model.state = .paused - model.updatedState(shouldRestartTimer: false) + model.pressedMain() let secondsWhenPaused = model.elapsedSeconds - model.state = .recording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() #expect(model.elapsedSeconds == secondsWhenPaused) - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } @Test func `restarts for a new recording`() async throws { - let model = Model() + let model = Model( + transcribe: TranscribingMock() + ) - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(1.2)) - model.state = .paused - model.updatedState(shouldRestartTimer: false) + model.pressedMain() + model.pressedSend() - model.state = .recording - model.updatedState(shouldRestartTimer: true) + try await Task.sleep(for: .seconds(0.5)) + + #expect(model.state == .notRecording) + + model.pressedMain() #expect(model.elapsedSeconds == 0) - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } } @@ -259,27 +254,22 @@ struct RecordingViewModelTests { let capturer = CapturingMock() let model = Model(capturer: capturer) - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(0.1)) #expect(capturer.countStart == 1) #expect(capturer.countResume == 0) - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } @Test func `pauses the capture while paused`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) - model.state = .recording - model.updatedState(shouldRestartTimer: true) - - model.state = .paused - model.updatedState(shouldRestartTimer: false) + model.pressedMain() + model.pressedMain() try await Task.sleep(for: .seconds(0.1)) @@ -290,22 +280,16 @@ struct RecordingViewModelTests { let capturer = CapturingMock() let model = Model(capturer: capturer) - model.state = .recording - model.updatedState(shouldRestartTimer: true) - - model.state = .paused - model.updatedState(shouldRestartTimer: false) - - model.state = .recording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() + model.pressedMain() + model.pressedMain() try await Task.sleep(for: .seconds(0.1)) #expect(capturer.countStart == 1) #expect(capturer.countResume == 1) - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } @Test func `stops the capture when the input is sent`() async throws { @@ -315,8 +299,7 @@ struct RecordingViewModelTests { transcribe: TranscribingMock() ) - model.state = .processing - model.updatedState(shouldRestartTimer: false) + model.drive(to: .processing) try await Task.sleep(for: .seconds(0.5)) @@ -330,8 +313,7 @@ struct RecordingViewModelTests { let model = Model(capturer: capturer) - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(0.1)) @@ -351,18 +333,15 @@ struct RecordingViewModelTests { transcribe: TranscribingMock() ) - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() try await Task.sleep(for: .seconds(1.2)) - model.state = .paused - model.updatedState(shouldRestartTimer: false) + model.pressedMain() #expect(model.elapsedSeconds >= 1) - model.state = .processing - model.updatedState(shouldRestartTimer: false) + model.pressedSend() try await Task.sleep(for: .seconds(0.5)) @@ -380,8 +359,7 @@ struct RecordingViewModelTests { transcribe: transcriber ) - model.state = .processing - model.updatedState(shouldRestartTimer: false) + model.drive(to: .processing) try await Task.sleep(for: .seconds(0.5)) @@ -395,26 +373,50 @@ struct RecordingViewModelTests { transcribe: TranscribingMock() ) - model.state = .processing - model.updatedState(shouldRestartTimer: false) + model.drive(to: .processing) try await Task.sleep(for: .seconds(0.5)) #expect(model.transcription != nil) - model.state = .recording - model.updatedState(shouldRestartTimer: true) + model.pressedMain() #expect(model.transcription == nil) - model.state = .notRecording - model.updatedState(shouldRestartTimer: false) + model.pressedMain() } } } +// MARK: - Helpers + +private extension RecordingView.Model { + + /// Drives the model from its initial state to the given state through its press handlers, as the view's buttons would. + /// + /// - Parameter state: The state to drive the model to. + func drive( + to state: State + ) { + switch state { + case .notRecording: + break + case .recording: + pressedMain() + case .paused: + pressedMain() + pressedMain() + case .processing: + pressedMain() + pressedMain() + pressedSend() + } + } + +} + // MARK: - Mocks /// A recording service that counts its invocations and can be configured to fail.