Move the recording side effects into the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -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
|
||||||
@@ -30,6 +30,10 @@ extension RecordingView {
|
|||||||
/// The instant the current recording stretch started, or `nil` while not recording.
|
/// The instant the current recording stretch started, or `nil` while not recording.
|
||||||
@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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user