Files
attendi/Packages/Features/Sources/Recording/RecordingViewModel.swift
T

203 lines
6.0 KiB
Swift

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<Void, Never>?
// 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
}
}