From 1fb8ece7516481835c9819557153e041adad8d43 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 3 Jul 2026 18:49:30 +0200 Subject: [PATCH] Implemented the RecordingViewModel view model in the Recording package target. --- .../Recording/RecordingViewModel.swift | 202 +++++++++++++ .../Tests/Recording/RecordingTests.swift | 9 - .../Recording/RecordingViewModelTests.swift | 270 ++++++++++++++++++ 3 files changed, 472 insertions(+), 9 deletions(-) create mode 100644 Packages/Features/Sources/Recording/RecordingViewModel.swift delete mode 100644 Packages/Features/Tests/Recording/RecordingTests.swift create mode 100644 Packages/Features/Tests/Recording/RecordingViewModelTests.swift diff --git a/Packages/Features/Sources/Recording/RecordingViewModel.swift b/Packages/Features/Sources/Recording/RecordingViewModel.swift new file mode 100644 index 0000000..498022b --- /dev/null +++ b/Packages/Features/Sources/Recording/RecordingViewModel.swift @@ -0,0 +1,202 @@ +import Observation +import SwiftUI + +extension RecordingView { + + /// The observable model that drives ``RecordingView``. + /// + /// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the + /// elapsed recording time, and processes the recorded input once it is sent. + @MainActor + @Observable + final class Model { + + // MARK: Properties + + /// The current state of the recording flow. + var state: State = .notRecording + + /// The number of seconds spent recording, excluding any time spent paused. + private(set) var secondsElapsed: Int = 0 + + /// The task that increments ``secondsElapsed`` every second while recording. + @ObservationIgnored + private var timerTask: Task? + + // MARK: Computed + + /// The image resource for the main button: a pause icon while recording, a record icon otherwise. + var iconMain: ImageResource { + switch state { + case .notRecording, .paused, .processing: .Icon.record + case .recording: .Icon.pause + } + } + + /// The image resource for the send button while paused, or `nil` when the button should show a progress indicator instead. + var iconSend: ImageResource? { + switch state { + case .paused: .Icon.send + default: nil + } + } + + /// Whether the recorded input is currently being processed. + var isProcessing: Bool { + state == .processing + } + + /// Whether the main button should be disabled. + var shouldDisableMain: Bool { + state == .processing + } + + /// Whether the send button should be visible. + var shouldShowSend: Bool { + state != .notRecording + && state != .recording + } + + /// Whether the timer label should be visible. + var shouldShowTimer: Bool { + state != .notRecording + } + + /// The elapsed recording time, formatted as `mm:ss` for the timer label. + var timerText: String { + Duration + .seconds(secondsElapsed) + .formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) + } + + // MARK: Methods + + /// 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. + func pressedMain() { + guard state != .processing else { + return + } + + switch state { + case .notRecording: + state = .recording + case .recording: + state = .paused + case .paused: + state = .recording + case .processing: + break + } + } + + /// Handles a press of the send button. + /// + /// Moves a paused recording into processing. 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 when a recording begins or resumes, stops it in every other state, and kicks off ``processInput()`` when the input is sent. + /// + /// - Parameter shouldRestartTimer: Whether ``secondsElapsed`` 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) + default: + stopTimer() + + if state == .processing { + Task { + await processInput() + } + } + } + } + + } +} + +// MARK: - Helpers + +private extension RecordingView.Model { + + // MARK: Methods + + /// Processes the recorded input, returning the model to the not-recording state when finished. + /// + /// Currently a placeholder that simulates the work with a five-second delay. + func processInput() async { + guard state == .processing else { + return + } + + try? await Task.sleep(for: .seconds(2)) + + state = .notRecording + } + + /// Starts the timer task, which increments ``secondsElapsed`` once per second until it is cancelled. Any previously running timer task is cancelled first. + /// + /// - Parameter shouldRestartTimer: Whether ``secondsElapsed`` should be reset to zero before the timer starts. + func startTimer( + _ shouldRestartTimer: Bool + ) { + if shouldRestartTimer { + secondsElapsed = 0 + } + + timerTask?.cancel() + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + + guard let self, !Task.isCancelled else { + return + } + + self.secondsElapsed += 1 + } + } + } + + /// Stops the timer task, if any, keeping ``secondsElapsed`` at its current value. + func stopTimer() { + timerTask?.cancel() + timerTask = nil + } + +} + +// MARK: - States + +extension RecordingView.Model { + /// The states of the recording flow. + enum State { + /// No recording is in progress. + case notRecording + /// A recording is in progress and the timer is running. + case recording + /// The recording is paused, and can be either resumed or sent. + case paused + /// The recorded input is being processed. + case processing + } +} diff --git a/Packages/Features/Tests/Recording/RecordingTests.swift b/Packages/Features/Tests/Recording/RecordingTests.swift deleted file mode 100644 index e672e3e..0000000 --- a/Packages/Features/Tests/Recording/RecordingTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Testing - -@testable import Recording - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing -} diff --git a/Packages/Features/Tests/Recording/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/RecordingViewModelTests.swift new file mode 100644 index 0000000..df7b431 --- /dev/null +++ b/Packages/Features/Tests/Recording/RecordingViewModelTests.swift @@ -0,0 +1,270 @@ +import SwiftUI +import Testing + +@testable import Recording + +@Suite("Recording view model") +struct RecordingViewModelTests { + + typealias Model = RecordingView.Model + + // MARK: Initial state + + @MainActor + @Suite("Initial state") + struct InitialState { + + @Test func `initial state is not recording with a zeroed timer`() { + let model = Model() + + #expect(model.state == .notRecording) + #expect(model.secondsElapsed == 0) + #expect(model.timerText == "00:00") + } + + } + + // MARK: Button presses + + @MainActor + @Suite("Button presses") + struct ButtonPresses { + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [Model.State.recording, .paused, .recording, .processing] + )) + func `pressed main transitions to the expected state`( + from initial: Model.State, + to expected: Model.State + ) { + let model = Model() + + model.state = initial + + model.pressedMain() + + #expect(model.state == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [Model.State.notRecording, .recording, .processing, .processing] + )) + func `pressed send transitions to the expected state`( + from initial: Model.State, + to expected: Model.State + ) { + let model = Model() + + model.state = initial + + model.pressedSend() + + #expect(model.state == expected) + } + + } + + // MARK: Computed properties + + @MainActor + @Suite("Computed properties") + struct ComputedProperties { + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [ImageResource.Icon.record, .Icon.pause, .Icon.record, .Icon.record] + )) + func `main icon matches the state`( + for state: Model.State, + expected: ImageResource + ) { + let model = Model() + + model.state = state + + #expect(model.iconMain == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [nil, nil, ImageResource.Icon.send, nil] + )) + func `send icon matches the state`( + for state: Model.State, + expected: ImageResource? + ) { + let model = Model() + + model.state = state + + #expect(model.iconSend == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [false, false, false, true] + )) + func `is processing only while processing`( + for state: Model.State, + expected: Bool + ) { + let model = Model() + + model.state = state + + #expect(model.isProcessing == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [false, false, false, true] + )) + func `main button is disabled only while processing`( + for state: Model.State, + expected: Bool + ) { + let model = Model() + + model.state = state + + #expect(model.shouldDisableMain == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [false, false, true, true] + )) + func `send button is visible while paused or processing`( + for state: Model.State, + expected: Bool + ) { + let model = Model() + + model.state = state + + #expect(model.shouldShowSend == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [false, true, true, true] + )) + func `timer is visible unless not recording`( + for state: Model.State, + expected: Bool + ) { + let model = Model() + + model.state = state + + #expect(model.shouldShowTimer == expected) + } + + } + + // MARK: Timer + + @MainActor + @Suite("Timer") + struct Timer { + + @Test func `ticks while recording`() async throws { + let model = Model() + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(1.2)) + + #expect(model.secondsElapsed >= 1) + #expect(model.timerText == "00:01") + + model.state = .notRecording + model.updatedState(shouldRestartTimer: false) + } + + @Test func `stops while paused`() async throws { + let model = Model() + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(1.2)) + + model.state = .paused + model.updatedState(shouldRestartTimer: false) + + let secondsWhenPaused = model.secondsElapsed + + try await Task.sleep(for: .seconds(1.2)) + + #expect(secondsWhenPaused >= 1) + #expect(model.secondsElapsed == secondsWhenPaused) + } + + @Test func `keeps elapsed seconds when resumed`() async throws { + let model = Model() + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(1.2)) + + model.state = .paused + model.updatedState(shouldRestartTimer: false) + + let secondsWhenPaused = model.secondsElapsed + + model.state = .recording + model.updatedState(shouldRestartTimer: false) + + #expect(model.secondsElapsed == secondsWhenPaused) + + model.state = .notRecording + model.updatedState(shouldRestartTimer: false) + } + + @Test func `restarts for a new recording`() async throws { + let model = Model() + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(1.2)) + + model.state = .paused + model.updatedState(shouldRestartTimer: false) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + #expect(model.secondsElapsed == 0) + + model.state = .notRecording + model.updatedState(shouldRestartTimer: false) + } + + } + + // MARK: Processing + + @MainActor + @Suite("Processing") + struct Processing { + + @Test func `returns to not recording`() async throws { + let model = Model() + + model.state = .processing + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(2.5)) + + #expect(model.state == .notRecording) + } + + } + +}