import SwiftUI /// A view that drives a recording session. /// /// The view shows a main button that starts, pauses, and resumes a recording, with a label above it displaying the elapsed recording time. /// While paused, a send button lets the user submit the recording for processing, showing a progress indicator until the processing finishes. /// /// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses and state changes. public struct RecordingView: View { // MARK: Properties /// The model that owns the recording state and drives the view's controls. @State private var model: Model /// The closure invoked with the transcribed text of every processed recording. private let onTranscription: (String) -> Void // MARK: Initializers /// Creates a recording view in the not-recording state, attached to the given recording and transcribing services. /// /// - Parameters: /// - recorder: The service that captures the audio from a microphone. /// - transcriber: The service that transcribes the recorded audio into text. /// - onTranscription: The closure invoked with the transcribed text of every processed recording. Defaults to a closure that does nothing. public init( recorder: any RecordingService, transcriber: any TranscribingService, onTranscription: @escaping (String) -> Void = { _ in } ) { self.model = .init( recorder: recorder, transcriber: transcriber ) self.onTranscription = onTranscription } // MARK: Body /// The content of the view: the timer label above the main and send buttons, with every change of the model's state forwarded back /// to ``Model/updatedState(shouldRestartTimer:)``. public var body: some View { VStack( spacing: Constant.Spacing.stack ) { if model.shouldShowTimer { Text(model.textTimer) .font(.largeTitle) .fontWeight(.bold) .monospacedDigit() .contentTransition(.numericText()) .animation( .easeInOut, value: model.textTimer ) } HStack( spacing: Constant.Spacing.stack ) { Button { model.pressedMain() } label: { Image(model.iconMain) .resizable() .scaledToFit() } .disabled(model.shouldDisableMain) if model.shouldShowSend { Button { model.pressedSend() } label: { if let icon = model.iconSend { Image(icon) .resizable() } else { ProgressView() } } .disabled(model.isProcessing) } } .buttonStyle(.recording( invertStyle: !model.isProcessing )) } .onChange( of: model.state, initial: false ) { oldValue, _ in model.updatedState( shouldRestartTimer: oldValue == .notRecording ) } .onChange( of: model.textTranscription, initial: false ) { _, newValue in if let newValue { onTranscription(newValue) } } } } // MARK: - Constants /// The constant values used across the view. private enum Constant { /// The spacing constants. enum Spacing { /// The spacing between the elements of a stack. static let stack: CGFloat = 8 } } // MARK: - Previews #Preview( "Recording view" ) { RecordingView( recorder: SimulatedRecordingService(), transcriber: SimulatedTranscribingService() ) }