2026-07-03 18:49:30 +02:00
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
2026-07-03 21:33:46 +02:00
/// 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.
2026-07-03 18:49:30 +02:00
@ 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.
2026-07-03 20:30:37 +02:00
private ( set ) var elapsedSeconds : Int = 0
2026-07-03 21:33:46 +02:00
/// 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
2026-07-03 18:49:30 +02:00
2026-07-03 20:30:37 +02:00
/// The task that increments ``elapsedSeconds`` every second while recording.
2026-07-03 18:49:30 +02:00
@ ObservationIgnored
2026-07-03 20:30:37 +02:00
private var taskTimer : Task < Void , Never >?
2026-07-03 18:49:30 +02:00
2026-07-03 21:33:46 +02:00
/// 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
}
2026-07-03 18:49:30 +02:00
// 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.
2026-07-03 19:21:37 +02:00
var textTimer : String {
2026-07-03 18:49:30 +02:00
Duration
2026-07-03 20:30:37 +02:00
. seconds ( elapsedSeconds )
2026-07-03 18:49:30 +02:00
. 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.
///
2026-07-03 21:33:46 +02:00
/// 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.
2026-07-03 18:49:30 +02:00
///
2026-07-03 20:30:37 +02:00
/// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` should be reset to zero before the timer starts, which is the case for
2026-07-03 18:49:30 +02:00
/// a new recording as opposed to one resuming from a pause.
func updatedState (
shouldRestartTimer : Bool
) {
switch state {
case . recording :
startTimer ( shouldRestartTimer )
2026-07-03 21:33:46 +02:00
startRecorder ( shouldRestartTimer )
case . paused :
2026-07-03 18:49:30 +02:00
stopTimer ()
2026-07-03 21:33:46 +02:00
Task {
try ? await recorder . pause ()
2026-07-03 18:49:30 +02:00
}
2026-07-03 21:33:46 +02:00
case . processing :
stopTimer ()
Task {
await processInput ()
}
case . notRecording :
stopTimer ()
2026-07-03 18:49:30 +02:00
}
}
}
}
// MARK: - Helpers
private extension RecordingView . Model {
// MARK: Methods
2026-07-03 21:33:46 +02:00
/// 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.
2026-07-03 18:49:30 +02:00
func processInput () async {
guard state == . processing else {
return
}
2026-07-03 21:33:46 +02:00
do {
let audio = try await recorder . stop ()
textTranscription = try await transcriber . transcribe ( audio )
} catch {
textTranscription = nil
}
2026-07-03 18:49:30 +02:00
2026-07-03 20:30:37 +02:00
elapsedSeconds = 0
2026-07-03 18:49:30 +02:00
state = . notRecording
}
2026-07-03 21:33:46 +02:00
/// 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
}
}
}
2026-07-03 20:30:37 +02:00
/// Starts the timer task, which increments ``elapsedSeconds`` once per second until it is cancelled. Any previously running timer task is cancelled first.
2026-07-03 18:49:30 +02:00
///
2026-07-03 21:33:46 +02:00
/// - 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.
2026-07-03 18:49:30 +02:00
func startTimer (
_ shouldRestartTimer : Bool
) {
if shouldRestartTimer {
2026-07-03 20:30:37 +02:00
elapsedSeconds = 0
2026-07-03 21:33:46 +02:00
textTranscription = nil
2026-07-03 18:49:30 +02:00
}
2026-07-03 20:30:37 +02:00
taskTimer ?. cancel ()
taskTimer = Task { [ weak self ] in
2026-07-03 18:49:30 +02:00
while ! Task . isCancelled {
try ? await Task . sleep ( for : . seconds ( 1 ))
guard let self , ! Task . isCancelled else {
return
}
2026-07-03 20:30:37 +02:00
self . elapsedSeconds += 1
2026-07-03 18:49:30 +02:00
}
}
}
2026-07-03 20:30:37 +02:00
/// Stops the timer task, if any, keeping ``elapsedSeconds`` at its current value.
2026-07-03 18:49:30 +02:00
func stopTimer () {
2026-07-03 20:30:37 +02:00
taskTimer ?. cancel ()
taskTimer = nil
2026-07-03 18:49:30 +02:00
}
}
// 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
}
}