Added the recording flow reporting and command listening on the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-05 22:00:43 +02:00
parent d473504d75
commit cfe6b1b408
6 changed files with 426 additions and 8 deletions
@@ -0,0 +1,36 @@
import Foundation
/// A service that reports the lifecycle of the recording flow to a surface outside the Recording feature's own UI.
///
/// ``RecordingView`` attaches the service to its model at initialization, so the reporting destination can be swapped without touching
/// the feature's state machine for example, with a Live Activity backend in the app, or with a mock in unit tests. The model reports
/// every transition of the flow: the start of a recording, its pauses and resumptions, the processing of its input, and the end of the
/// whole flow.
public protocol Reporting: Sendable {
// MARK: Methods
/// Reports the start of a new recording.
///
/// - Parameter anchor: The instant the elapsed recording time counts from.
func started(at anchor: Date) async
/// Reports the pause of the ongoing recording.
///
/// - Parameter elapsed: The number of seconds spent recording so far, excluding any time spent paused.
func paused(elapsed: TimeInterval) async
/// Reports the resumption of a paused recording.
///
/// - Parameter anchor: The instant the elapsed recording time counts from, moved back by the time already spent recording.
func resumed(anchor: Date) async
/// Reports the processing of the recorded input.
///
/// - Parameter elapsed: The number of seconds spent recording, excluding any time spent paused.
func processing(elapsed: TimeInterval) async
/// Reports the end of the recording flow.
func ended() async
}
@@ -0,0 +1,26 @@
import Foundation
/// The reporting service used for development, which swallows the reported lifecycle of the recording flow.
///
/// The service is internal on purpose: it only backs the feature's previews and the default values of its model, and is not part of the
/// package's public interface.
struct DummyReporting: Reporting {
// MARK: Methods
/// Swallows the start of a new recording.
func started(at anchor: Date) async {}
/// Swallows the pause of the ongoing recording.
func paused(elapsed: TimeInterval) async {}
/// Swallows the resumption of a paused recording.
func resumed(anchor: Date) async {}
/// Swallows the processing of the recorded input.
func processing(elapsed: TimeInterval) async {}
/// Swallows the end of the recording flow.
func ended() async {}
}
@@ -1,3 +1,4 @@
import Commanding
import Observation
import SwiftUI
@@ -12,6 +13,11 @@ extension RecordingView {
///
/// A recording paused by a system interruption of the capture resumes by itself once the interruption ends with the system's
/// resume hint, and a recording already being processed can still be discarded, cancelling its in-flight transcription.
///
/// Every transition of the flow is reported through the ``Reporting`` attached at initialization surfacing the recording outside
/// the feature's UI, in a Live Activity for example and the flow can be controlled from outside that UI in return: the model
/// listens to the ``RecordingCommand``s of the attached ``RecordingCommander``, translating each one into the button press
/// it mirrors.
@MainActor
@Observable
final class Model {
@@ -46,6 +52,10 @@ extension RecordingView {
@ObservationIgnored
private let clock = ContinuousClock()
/// The commander whose commands control the recording flow from outside the feature's UI.
@ObservationIgnored
private let commander: RecordingCommander
/// Whether the recording was paused by a system interruption of the audio capture, as opposed to by the user.
@ObservationIgnored
private var isPausedByInterruption = false
@@ -54,14 +64,26 @@ extension RecordingView {
@ObservationIgnored
private let locale: Binding<Locale>
/// The service that reports the lifecycle of the recording flow outside the feature's UI.
@ObservationIgnored
private let reporter: any Reporting
/// The task that serializes the calls to the capturing service, so rapid state changes can never reach the service out of order.
@ObservationIgnored
private var taskCapturer: Task<Void, Never>?
/// The task that listens to the commands sent through the commander, or `nil` until the first recording starts.
@ObservationIgnored
private var taskCommands: Task<Void, Never>?
/// The task that listens to the events emitted by the capturing service, or `nil` until the first recording starts.
@ObservationIgnored
private var taskEvents: Task<Void, Never>?
/// The task that serializes the calls to the reporting service, so rapid state changes can never reach the service out of order.
@ObservationIgnored
private var taskReporter: Task<Void, Never>?
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
@ObservationIgnored
private var taskTimer: Task<Void, Never>?
@@ -72,18 +94,25 @@ extension RecordingView {
// MARK: Initializers
/// Creates a model attached to the given recording and transcribing services.
/// Creates a model attached to the given recording, transcribing, and reporting services.
///
/// - Parameters:
/// - capturer: The service that captures the audio from a microphone. Defaults to ``DummyCapturing``.
/// - transcribe: The callable service that transcribes the recorded audio into text. Defaults to ``DummyTranscribing``.
/// - reporter: The service that reports the lifecycle of the recording flow outside the feature's UI. Defaults to ``DummyReporting``.
/// - commander: The commander whose commands control the recording flow from outside the feature's UI. Defaults to the
/// process-wide ``RecordingCommander/shared`` instance.
/// - locale: The binding to the locale of the spoken language to transcribe. Defaults to a constant binding to the user's current locale.
init(
capturer: any Capturing = DummyCapturing(),
transcribe: any Transcribing = DummyTranscribing(),
reporter: any Reporting = DummyReporting(),
commander: RecordingCommander = .shared,
locale: Binding<Locale> = .constant(.current)
) {
self.capturer = capturer
self.commander = commander
self.reporter = reporter
self.transcribe = transcribe
self.locale = locale
}
@@ -176,8 +205,8 @@ extension RecordingView {
/// Handles a press of the discard button.
///
/// Throws away a paused recording or one already being processed, cancelling its in-flight transcription: it resets the
/// timer, returns to the not-recording state, and stops the audio capture, deleting the captured audio file. Does nothing in
/// any other state.
/// timer, returns to the not-recording state, stops the audio capture, deleting the captured audio file, and reports the end
/// of the flow. Does nothing in any other state.
func pressedDiscard() {
isPausedByInterruption = false
@@ -197,6 +226,9 @@ extension RecordingView {
try? FileManager.default.removeItem(at: audio)
}
enqueueReporter {
await self.reporter.ended()
}
case .processing:
taskCapturer?.cancel()
@@ -212,6 +244,9 @@ extension RecordingView {
try? FileManager.default.removeItem(at: audio)
}
enqueueReporter {
await self.reporter.ended()
}
default:
break
}
@@ -220,7 +255,7 @@ extension RecordingView {
/// Handles a press of the main button.
///
/// 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.
/// and the audio capture accordingly, and reporting the transition. Does nothing while the input is being processed.
func pressedMain() {
isPausedByInterruption = false
@@ -228,9 +263,14 @@ extension RecordingView {
case .notRecording:
state = .recording
listenToCommands()
listenToEvents()
startTimer(true)
startCapturer(true)
enqueueReporter { [anchor = Date.now] in
await self.reporter.started(at: anchor)
}
case .recording:
pauseRecording()
case .paused:
@@ -247,7 +287,8 @@ extension RecordingView {
/// Handles a press of the send button.
///
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
/// Moves a paused recording into processing, stopping the timer, kicking off ``processInput()``, and reporting the transition.
/// Does nothing in any other state.
func pressedSend() {
isPausedByInterruption = false
@@ -260,6 +301,9 @@ extension RecordingView {
enqueueCapturer {
await self.processInput()
}
enqueueReporter { [elapsed = elapsed.timeInterval] in
await self.reporter.processing(elapsed: elapsed)
}
default:
break
}
@@ -341,6 +385,10 @@ private extension RecordingView.Model {
accumulated = .zero
elapsedSeconds = 0
state = .notRecording
enqueueReporter {
await self.reporter.ended()
}
}
/// Enqueues an operation behind any previously enqueued ones, so the calls to the capturing service always reach it in the order
@@ -356,6 +404,43 @@ private extension RecordingView.Model {
}
}
/// Enqueues an operation behind any previously enqueued ones, so the reports to the reporting service always reach it in the
/// order the states changed, no matter how quickly the user presses the view's controls.
///
/// - Parameter operation: The operation on the reporting service to enqueue.
func enqueueReporter(
_ operation: @escaping @MainActor () async -> Void
) {
taskReporter = Task { [taskReporter] in
await taskReporter?.value
await operation()
}
}
/// Starts listening to the commands sent through the commander, unless already listening: every received command is translated
/// into the button press it mirrors ``RecordingCommand/toggle`` presses the main button, and ``RecordingCommand/discard``
/// presses the discard button.
func listenToCommands() {
guard taskCommands == nil else {
return
}
taskCommands = Task { [weak self, commands = commander.commands] in
for await command in commands {
guard let self else {
return
}
switch command {
case .toggle:
pressedMain()
case .discard:
pressedDiscard()
}
}
}
}
/// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses
/// an ongoing recording, exactly like a press of the main button would, and the end of an interruption resumes the recording it
/// paused but only when the system hints the capture may resume, and never one the user paused themselves.
@@ -388,7 +473,7 @@ private extension RecordingView.Model {
}
}
/// Pauses the ongoing recording: it stops the timer and pauses the audio capture.
/// Pauses the ongoing recording: it stops the timer, pauses the audio capture, and reports the pause.
func pauseRecording() {
state = .paused
@@ -397,12 +482,21 @@ private extension RecordingView.Model {
enqueueCapturer {
try? await self.capturer.pause()
}
enqueueReporter { [elapsed = elapsed.timeInterval] in
await self.reporter.paused(elapsed: elapsed)
}
}
/// Resumes the paused recording: it restarts the timer, without resetting it, and resumes the audio capture.
/// Resumes the paused recording: it restarts the timer, without resetting it, resumes the audio capture, and reports the
/// resumption anchored at the current instant moved back by the time already spent recording, so a timer counting up
/// from the anchor shows the elapsed recording time.
func resumeRecording() {
state = .recording
enqueueReporter { [anchor = Date(timeIntervalSinceNow: -accumulated.timeInterval)] in
await self.reporter.resumed(anchor: anchor)
}
startTimer(false)
startCapturer(false)
}
@@ -480,6 +574,17 @@ private extension RecordingView.Model {
}
// MARK: - Duration+TimeInterval
private extension Duration {
/// The duration as a number of seconds, for the reports of the recording flow's lifecycle.
var timeInterval: TimeInterval {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
}
// MARK: - States
extension RecordingView.Model {
@@ -31,22 +31,26 @@ public struct RecordingView: View {
// MARK: Initializers
/// Creates a recording view in the not-recording state, attached to the given recording and transcribing services.
/// Creates a recording view in the not-recording state, attached to the given recording, transcribing, and reporting services.
///
/// - Parameters:
/// - capturer: The service that captures the audio from a microphone.
/// - transcriber: The service that transcribes the recorded audio into text.
/// - reporter: The service that reports the lifecycle of the recording flow outside the feature's UI, or `nil` to report nowhere.
/// Defaults to `nil`.
/// - locale: The binding to the locale of the spoken language to transcribe.
/// - onTranscription: The closure invoked with the transcription of every processed recording. Defaults to a closure that does nothing.
public init(
capturer: any Capturing,
transcriber: any Transcribing,
reporter: (any Reporting)? = nil,
locale: Binding<Locale>,
onTranscription: @escaping (Transcription) -> Void
) {
self.model = .init(
capturer: capturer,
transcribe: transcriber,
reporter: reporter ?? DummyReporting(),
locale: locale
)
self.onTranscription = onTranscription
@@ -0,0 +1,53 @@
import Foundation
import Recording
/// A reporting service that records the lifecycle reports it receives, in call order, along with the values of the last report.
@MainActor
final class ReportingMock: Reporting {
// MARK: Properties
/// The names of the reports received by the service, in call order.
private(set) var reports: [String] = []
/// The anchor received with the last started or resumed report, or `nil` when none arrived yet.
private(set) var lastAnchor: Date?
/// The elapsed recording time received with the last paused or processing report, or `nil` when none arrived yet.
private(set) var lastElapsed: TimeInterval?
// MARK: Methods
func started(
at anchor: Date
) async {
reports.append("started")
lastAnchor = anchor
}
func paused(
elapsed: TimeInterval
) async {
reports.append("paused")
lastElapsed = elapsed
}
func resumed(
anchor: Date
) async {
reports.append("resumed")
lastAnchor = anchor
}
func processing(
elapsed: TimeInterval
) async {
reports.append("processing")
lastElapsed = elapsed
}
func ended() async {
reports.append("ended")
}
}
@@ -1,3 +1,4 @@
import Commanding
import SwiftUI
import Testing
@@ -764,6 +765,199 @@ struct RecordingViewModelTests {
}
// MARK: Reporter
@MainActor
@Suite("Reporter")
struct Reporter {
@Test
func `reports the start of a new recording with a current anchor`() async throws {
let reporter = ReportingMock()
let model = Model(reporter: reporter)
let before = Date.now
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(reporter.reports == ["started"])
#expect(reporter.lastAnchor ?? .distantPast >= before)
#expect(reporter.lastAnchor ?? .distantFuture <= .now)
model.pressedMain()
}
@Test
func `reports a pause with the elapsed recording time`() async throws {
let reporter = ReportingMock()
let model = Model(reporter: reporter)
model.pressedMain()
try await Task.sleep(for: .seconds(1.2))
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(reporter.reports == ["started", "paused"])
#expect(reporter.lastElapsed ?? 0 >= 1)
}
@Test
func `reports a resumption with an anchor moved back by the elapsed recording time`() async throws {
let reporter = ReportingMock()
let model = Model(reporter: reporter)
model.pressedMain()
try await Task.sleep(for: .seconds(1.2))
model.pressedMain()
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(reporter.reports == ["started", "paused", "resumed"])
#expect(reporter.lastAnchor ?? .distantFuture <= Date(timeIntervalSinceNow: -1))
model.pressedMain()
}
@Test
func `reports the processing and end of a sent recording`() async throws {
let reporter = ReportingMock()
let model = Model(
transcribe: TranscribingMock(),
reporter: reporter
)
model.drive(to: .processing)
try await Task.sleep(for: .seconds(0.5))
#expect(reporter.reports == ["started", "paused", "processing", "ended"])
}
@Test
func `reports the end of a discarded recording`() async throws {
let reporter = ReportingMock()
let model = Model(reporter: reporter)
model.drive(to: .paused)
model.pressedDiscard()
try await Task.sleep(for: .seconds(0.1))
#expect(reporter.reports == ["started", "paused", "ended"])
}
@Test
func `reports a pause when the capture is interrupted`() async throws {
let capturer = CapturingMock()
let reporter = ReportingMock()
let model = Model(
capturer: capturer,
reporter: reporter
)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
capturer.interrupt()
try await Task.sleep(for: .seconds(0.1))
#expect(reporter.reports == ["started", "paused"])
}
}
// MARK: Commands
@MainActor
@Suite("Commands")
struct Commands {
@Test
func `a toggle command pauses the ongoing recording`() async throws {
let capturer = CapturingMock()
let commander = RecordingCommander()
let model = Model(
capturer: capturer,
commander: commander
)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
commander.send(.toggle)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .paused)
#expect(capturer.calls == ["start", "pause"])
}
@Test
func `a toggle command resumes a paused recording`() async throws {
let capturer = CapturingMock()
let commander = RecordingCommander()
let model = Model(
capturer: capturer,
commander: commander
)
model.drive(to: .paused)
try await Task.sleep(for: .seconds(0.1))
commander.send(.toggle)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .recording)
#expect(capturer.calls == ["start", "pause", "resume"])
model.pressedMain()
}
@Test
func `a discard command throws a paused recording away`() async throws {
let commander = RecordingCommander()
let model = Model(commander: commander)
model.drive(to: .paused)
try await Task.sleep(for: .seconds(0.1))
commander.send(.discard)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .notRecording)
#expect(model.elapsedSeconds == 0)
}
@Test
func `commands are ignored before the first recording starts`() async throws {
let commander = RecordingCommander()
let model = Model(commander: commander)
commander.send(.toggle)
try await Task.sleep(for: .seconds(0.1))
#expect(model.state == .notRecording)
}
}
}
// MARK: - Helpers