Move the recording side effects into the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-04 14:50:41 +02:00
parent 5e102f21e1
commit 702333038c
3 changed files with 97 additions and 124 deletions
@@ -15,7 +15,7 @@ extension RecordingView {
// MARK: Properties // MARK: Properties
/// The current state of the recording flow. /// 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. /// The number of seconds spent recording, excluding any time spent paused.
private(set) var elapsedSeconds: Int = 0 private(set) var elapsedSeconds: Int = 0
@@ -31,6 +31,10 @@ extension RecordingView {
@ObservationIgnored @ObservationIgnored
private var anchor: ContinuousClock.Instant? 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. /// The clock that measures the recording time.
@ObservationIgnored @ObservationIgnored
private let clock = ContinuousClock() private let clock = ContinuousClock()
@@ -39,10 +43,6 @@ extension RecordingView {
@ObservationIgnored @ObservationIgnored
private let locale: Binding<Locale> private let locale: Binding<Locale>
/// 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. /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
@ObservationIgnored @ObservationIgnored
private var taskTimer: Task<Void, Never>? private var taskTimer: Task<Void, Never>?
@@ -119,20 +119,28 @@ extension RecordingView {
/// Handles a press of the main button. /// Handles a press of the main button.
/// ///
/// Starts a recording when idle, pauses an ongoing recording, or resumes /// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one starting and stopping the timer
/// a paused one. Does nothing while the input is being processed. /// and the audio capture accordingly. Does nothing while the input is being processed.
func pressedMain() { func pressedMain() {
guard state != .processing else {
return
}
switch state { switch state {
case .notRecording: case .notRecording:
state = .recording state = .recording
startTimer(true)
startCapturer(true)
case .recording: case .recording:
state = .paused state = .paused
stopTimer()
Task {
try? await capturer.pause()
}
case .paused: case .paused:
state = .recording state = .recording
startTimer(false)
startCapturer(false)
case .processing: case .processing:
break break
} }
@@ -140,48 +148,19 @@ extension RecordingView {
/// Handles a press of the send button. /// 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() { func pressedSend() {
guard state == .paused else {
return
}
switch state { switch state {
case .paused: case .paused:
state = .processing 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() stopTimer()
Task { Task {
await processInput() await processInput()
} }
case .notRecording: default:
stopTimer() break
} }
} }
@@ -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. /// 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. /// 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 { public struct RecordingView: View {
// MARK: Properties // MARK: Properties
@@ -44,8 +44,8 @@ public struct RecordingView: View {
// MARK: Body // 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 /// The content of the view: the timer label above the main and send buttons, with the transcription of every processed recording
/// to ``Model/updatedState(shouldRestartTimer:)``. /// forwarded to the closure given at initialization.
public var body: some View { public var body: some View {
VStack( VStack(
spacing: Constant.Spacing.stack 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( .onChange(
of: model.transcription, of: model.transcription,
initial: false initial: false
@@ -41,7 +41,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = initial model.drive(to: initial)
model.pressedMain() model.pressedMain()
@@ -58,7 +58,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = initial model.drive(to: initial)
model.pressedSend() model.pressedSend()
@@ -83,7 +83,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.iconMain == expected) #expect(model.iconMain == expected)
} }
@@ -98,7 +98,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.iconSend == expected) #expect(model.iconSend == expected)
} }
@@ -113,7 +113,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.isProcessing == expected) #expect(model.isProcessing == expected)
} }
@@ -128,7 +128,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.shouldDisableMain == expected) #expect(model.shouldDisableMain == expected)
} }
@@ -143,7 +143,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.shouldShowSend == expected) #expect(model.shouldShowSend == expected)
} }
@@ -158,7 +158,7 @@ struct RecordingViewModelTests {
) { ) {
let model = Model() let model = Model()
model.state = state model.drive(to: state)
#expect(model.shouldShowTimer == expected) #expect(model.shouldShowTimer == expected)
} }
@@ -174,28 +174,24 @@ struct RecordingViewModelTests {
@Test func `ticks while recording`() async throws { @Test func `ticks while recording`() async throws {
let model = Model() let model = Model()
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(1.2)) try await Task.sleep(for: .seconds(1.2))
#expect(model.elapsedSeconds >= 1) #expect(model.elapsedSeconds >= 1)
#expect(model.textTimer == "00:01") #expect(model.textTimer == "00:01")
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
@Test func `stops while paused`() async throws { @Test func `stops while paused`() async throws {
let model = Model() let model = Model()
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(1.2)) try await Task.sleep(for: .seconds(1.2))
model.state = .paused model.pressedMain()
model.updatedState(shouldRestartTimer: false)
let secondsWhenPaused = model.elapsedSeconds let secondsWhenPaused = model.elapsedSeconds
@@ -208,43 +204,42 @@ struct RecordingViewModelTests {
@Test func `keeps elapsed seconds when resumed`() async throws { @Test func `keeps elapsed seconds when resumed`() async throws {
let model = Model() let model = Model()
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(1.2)) try await Task.sleep(for: .seconds(1.2))
model.state = .paused model.pressedMain()
model.updatedState(shouldRestartTimer: false)
let secondsWhenPaused = model.elapsedSeconds let secondsWhenPaused = model.elapsedSeconds
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
#expect(model.elapsedSeconds == secondsWhenPaused) #expect(model.elapsedSeconds == secondsWhenPaused)
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
@Test func `restarts for a new recording`() async throws { @Test func `restarts for a new recording`() async throws {
let model = Model() let model = Model(
transcribe: TranscribingMock()
)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(1.2)) try await Task.sleep(for: .seconds(1.2))
model.state = .paused model.pressedMain()
model.updatedState(shouldRestartTimer: false) model.pressedSend()
model.state = .recording try await Task.sleep(for: .seconds(0.5))
model.updatedState(shouldRestartTimer: true)
#expect(model.state == .notRecording)
model.pressedMain()
#expect(model.elapsedSeconds == 0) #expect(model.elapsedSeconds == 0)
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
} }
@@ -259,27 +254,22 @@ struct RecordingViewModelTests {
let capturer = CapturingMock() let capturer = CapturingMock()
let model = Model(capturer: capturer) let model = Model(capturer: capturer)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(0.1)) try await Task.sleep(for: .seconds(0.1))
#expect(capturer.countStart == 1) #expect(capturer.countStart == 1)
#expect(capturer.countResume == 0) #expect(capturer.countResume == 0)
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
@Test func `pauses the capture while paused`() async throws { @Test func `pauses the capture while paused`() async throws {
let capturer = CapturingMock() let capturer = CapturingMock()
let model = Model(capturer: capturer) let model = Model(capturer: capturer)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true) model.pressedMain()
model.state = .paused
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.1)) try await Task.sleep(for: .seconds(0.1))
@@ -290,22 +280,16 @@ struct RecordingViewModelTests {
let capturer = CapturingMock() let capturer = CapturingMock()
let model = Model(capturer: capturer) let model = Model(capturer: capturer)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true) model.pressedMain()
model.pressedMain()
model.state = .paused
model.updatedState(shouldRestartTimer: false)
model.state = .recording
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.1)) try await Task.sleep(for: .seconds(0.1))
#expect(capturer.countStart == 1) #expect(capturer.countStart == 1)
#expect(capturer.countResume == 1) #expect(capturer.countResume == 1)
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
@Test func `stops the capture when the input is sent`() async throws { @Test func `stops the capture when the input is sent`() async throws {
@@ -315,8 +299,7 @@ struct RecordingViewModelTests {
transcribe: TranscribingMock() transcribe: TranscribingMock()
) )
model.state = .processing model.drive(to: .processing)
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
@@ -330,8 +313,7 @@ struct RecordingViewModelTests {
let model = Model(capturer: capturer) let model = Model(capturer: capturer)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(0.1)) try await Task.sleep(for: .seconds(0.1))
@@ -351,18 +333,15 @@ struct RecordingViewModelTests {
transcribe: TranscribingMock() transcribe: TranscribingMock()
) )
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
try await Task.sleep(for: .seconds(1.2)) try await Task.sleep(for: .seconds(1.2))
model.state = .paused model.pressedMain()
model.updatedState(shouldRestartTimer: false)
#expect(model.elapsedSeconds >= 1) #expect(model.elapsedSeconds >= 1)
model.state = .processing model.pressedSend()
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
@@ -380,8 +359,7 @@ struct RecordingViewModelTests {
transcribe: transcriber transcribe: transcriber
) )
model.state = .processing model.drive(to: .processing)
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
@@ -395,26 +373,50 @@ struct RecordingViewModelTests {
transcribe: TranscribingMock() transcribe: TranscribingMock()
) )
model.state = .processing model.drive(to: .processing)
model.updatedState(shouldRestartTimer: false)
try await Task.sleep(for: .seconds(0.5)) try await Task.sleep(for: .seconds(0.5))
#expect(model.transcription != nil) #expect(model.transcription != nil)
model.state = .recording model.pressedMain()
model.updatedState(shouldRestartTimer: true)
#expect(model.transcription == nil) #expect(model.transcription == nil)
model.state = .notRecording model.pressedMain()
model.updatedState(shouldRestartTimer: false)
} }
} }
} }
// 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 // MARK: - Mocks
/// A recording service that counts its invocations and can be configured to fail. /// A recording service that counts its invocations and can be configured to fail.