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 textTimer: 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 } }