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. The audio capture and its transcription are delegated to the /// ``RecordingService`` and ``TranscribingService`` attached at initialization; when either of them fails, the model falls back to the not-recording state. @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 elapsedSeconds: Int = 0 /// The transcription of the last processed recording, or `nil` when none has been processed yet. private(set) var textTranscription: String? /// The service that captures the audio from a microphone. @ObservationIgnored private let recorder: any RecordingService /// The task that increments ``elapsedSeconds`` every second while recording. @ObservationIgnored private var taskTimer: Task? /// The service that transcribes the recorded audio into text. @ObservationIgnored private let transcriber: any TranscribingService // MARK: Initializers /// Creates a model attached to the given recording and transcribing services. /// /// - Parameters: /// - recorder: The service that captures the audio from a microphone. Defaults to ``SimulatedRecordingService``. /// - transcriber: The service that transcribes the recorded audio into text. Defaults to ``SimulatedTranscribingService``. init( recorder: any RecordingService = SimulatedRecordingService(), transcriber: any TranscribingService = SimulatedTranscribingService() ) { self.recorder = recorder self.transcriber = transcriber } // 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 textTimer: String { Duration .seconds(elapsedSeconds) .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 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) startRecorder(shouldRestartTimer) case .paused: stopTimer() Task { try? await recorder.pause() } case .processing: stopTimer() Task { await processInput() } case .notRecording: stopTimer() } } } } // MARK: - Helpers private extension RecordingView.Model { // MARK: Methods /// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``textTranscription``; /// when finished — or when either service fails — it resets ``elapsedSeconds`` and returns the model to the not-recording state. func processInput() async { guard state == .processing else { return } do { let audio = try await recorder.stop() textTranscription = try await transcriber.transcribe(audio) } catch { textTranscription = nil } elapsedSeconds = 0 state = .notRecording } /// Starts the audio capture through the attached ``RecordingService``, falling back to the not-recording state when the service fails. /// /// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed. func startRecorder( _ isNewRecording: Bool ) { Task { do { isNewRecording ? try await recorder.start() : try await recorder.resume() } catch { stopTimer() state = .notRecording } } } /// Starts the timer task, which increments ``elapsedSeconds`` once per second until it is cancelled. Any previously running timer task is cancelled first. /// /// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` and ``textTranscription`` should be cleared before the timer starts, /// which is the case for a new recording as opposed to one resuming from a pause. func startTimer( _ shouldRestartTimer: Bool ) { if shouldRestartTimer { elapsedSeconds = 0 textTranscription = nil } taskTimer?.cancel() taskTimer = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) guard let self, !Task.isCancelled else { return } self.elapsedSeconds += 1 } } } /// Stops the timer task, if any, keeping ``elapsedSeconds`` at its current value. func stopTimer() { taskTimer?.cancel() taskTimer = 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 } }