From cfe6b1b40854d93f5528aa355c733160daff9906 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 5 Jul 2026 22:00:43 +0200 Subject: [PATCH] Added the recording flow reporting and command listening on the RecordingViewModel view model in the Recording package target. --- .../Recording/Protocols/Reporting.swift | 36 ++++ .../Recording/Services/DummyReporting.swift | 26 +++ .../View Models/RecordingViewModel.swift | 119 ++++++++++- .../Recording/Views/RecordingView.swift | 6 +- .../Tests/Recording/Mocks/ReportingMock.swift | 53 +++++ .../View Models/RecordingViewModelTests.swift | 194 ++++++++++++++++++ 6 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 Packages/Features/Sources/Recording/Protocols/Reporting.swift create mode 100644 Packages/Features/Sources/Recording/Services/DummyReporting.swift create mode 100644 Packages/Features/Tests/Recording/Mocks/ReportingMock.swift diff --git a/Packages/Features/Sources/Recording/Protocols/Reporting.swift b/Packages/Features/Sources/Recording/Protocols/Reporting.swift new file mode 100644 index 0000000..36e38f2 --- /dev/null +++ b/Packages/Features/Sources/Recording/Protocols/Reporting.swift @@ -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 + +} diff --git a/Packages/Features/Sources/Recording/Services/DummyReporting.swift b/Packages/Features/Sources/Recording/Services/DummyReporting.swift new file mode 100644 index 0000000..15eeac0 --- /dev/null +++ b/Packages/Features/Sources/Recording/Services/DummyReporting.swift @@ -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 {} + +} diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 308bce7..1b629bd 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -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 + /// 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? + /// The task that listens to the commands sent through the commander, or `nil` until the first recording starts. + @ObservationIgnored + private var taskCommands: Task? + /// The task that listens to the events emitted by the capturing service, or `nil` until the first recording starts. @ObservationIgnored private var taskEvents: Task? + /// 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? + /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording. @ObservationIgnored private var taskTimer: Task? @@ -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 = .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 { diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index e105020..1e96185 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -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, onTranscription: @escaping (Transcription) -> Void ) { self.model = .init( capturer: capturer, transcribe: transcriber, + reporter: reporter ?? DummyReporting(), locale: locale ) self.onTranscription = onTranscription diff --git a/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift b/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift new file mode 100644 index 0000000..f6861e7 --- /dev/null +++ b/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift @@ -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") + } + +} diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index c50d4d0..74ae8c5 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -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