Surfaced recording flow errors to the user through an alert in the Recording package target.

This commit is contained in:
2026-07-04 15:40:03 +02:00
parent 088c4c757e
commit 0ca948272b
6 changed files with 315 additions and 10 deletions
+1
View File
@@ -4,6 +4,7 @@ import PackageDescription
let package = Package(
name: "Features",
defaultLocalization: "en",
platforms: [
.iOS(.v26),
.macOS(.v26),
@@ -0,0 +1,78 @@
{
"sourceLanguage" : "en",
"strings" : {
"view.recording.alert.error.button.ok" : {
"comment" : "The title of the error alert's dismissal button.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "OK"
}
}
}
},
"view.recording.alert.error.button.settings" : {
"comment" : "The title of the error alert's button that opens the app's settings.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Settings"
}
}
}
},
"view.recording.alert.error.message.capture" : {
"comment" : "The message describing a failure of the audio capture.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The audio could not be recorded. Please try again."
}
}
}
},
"view.recording.alert.error.message.permission" : {
"comment" : "The message describing a denied microphone permission.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The app has no permission to use the microphone. You can grant it in the settings."
}
}
}
},
"view.recording.alert.error.message.transcription" : {
"comment" : "The message describing a failure of the transcription.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The recording could not be transcribed. Please try again."
}
}
}
},
"view.recording.alert.error.title" : {
"comment" : "The title of the error alert.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Something went wrong"
}
}
}
}
},
"version" : "1.0"
}
@@ -0,0 +1,11 @@
import Foundation
/// An error of the recording flow, surfaced to the user by ``RecordingView``.
public enum RecordingError: Error, Equatable, Sendable {
/// The audio capture failed to start, resume, or stop.
case captureFailed
/// The user denied the app permission to record audio from the microphone.
case permissionDenied
/// The recorded audio failed to transcribe.
case transcriptionFailed
}
@@ -19,7 +19,10 @@ extension RecordingView {
/// The number of seconds spent recording, excluding any time spent paused.
private(set) var elapsedSeconds: Int = 0
/// The error to surface to the user, or `nil` when none occurred. Cleared when it is dismissed or a new recording starts.
private(set) var error: RecordingError?
/// The transcription of the last processed recording, or `nil` when none has been processed yet.
private(set) var transcription: Transcription?
@@ -149,6 +152,11 @@ extension RecordingView {
}
}
/// Handles the dismissal of the surfaced ``error``, clearing it.
func dismissedError() {
error = nil
}
/// Handles a press of the send button.
///
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
@@ -188,7 +196,8 @@ private extension RecordingView.Model {
// MARK: Methods
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``;
/// when finished or when either service fails it resets ``elapsedSeconds`` and returns the model to the not-recording state.
/// when finished or when either service fails, in which case the failure is published in ``error`` it resets ``elapsedSeconds``
/// and returns the model to the not-recording state.
func processInput() async {
guard state == .processing else {
return
@@ -197,12 +206,18 @@ private extension RecordingView.Model {
do {
let audio = try await capturer.stop()
transcription = try await transcribe(
audio,
locale: locale.wrappedValue
)
do {
transcription = try await transcribe(
audio,
locale: locale.wrappedValue
)
} catch {
transcription = nil
self.error = .transcriptionFailed
}
} catch {
transcription = nil
self.error = .captureFailed
}
accumulated = .zero
@@ -257,7 +272,8 @@ private extension RecordingView.Model {
}
}
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state when the service fails.
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state and publishing the failure
/// in ``error`` when the service fails.
///
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
func startCapturer(
@@ -272,6 +288,9 @@ private extension RecordingView.Model {
self.stopTimer()
self.state = .notRecording
self.error = error as? AudioCapturingError == .permissionNotGranted
? .permissionDenied
: .captureFailed
}
}
}
@@ -279,14 +298,15 @@ private extension RecordingView.Model {
/// Starts the timer task, which updates ``elapsedSeconds`` from the time measured by the clock on every whole second until it is
/// cancelled, so scheduling latency never accumulates as drift. Any previously running timer task is cancelled first.
///
/// - Parameter shouldRestartTimer: Whether the measured recording time, ``elapsedSeconds``, and ``transcription`` should be
/// cleared before the timer starts, which is the case for a new recording as opposed to one resuming from a pause.
/// - Parameter shouldRestartTimer: Whether the measured recording time, ``elapsedSeconds``, ``error``, and ``transcription``
/// 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 {
accumulated = .zero
elapsedSeconds = 0
error = nil
transcription = nil
}
@@ -1,5 +1,9 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// 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.
@@ -10,6 +14,9 @@ public struct RecordingView: View {
// MARK: Properties
/// The action that opens a URL, used to open the app's settings from the error alert.
@Environment(\.openURL) private var openURL
/// The model that owns the recording state and drives the view's controls.
@State private var model: Model
@@ -45,7 +52,8 @@ public struct RecordingView: View {
// MARK: Body
/// The content of the view: the timer label above the main and send buttons, with the transcription of every processed recording
/// forwarded to the closure given at initialization.
/// forwarded to the closure given at initialization, and an alert surfacing any error of the recording flow offering to open the
/// app's settings when the microphone permission was denied.
public var body: some View {
VStack(
spacing: Constant.Spacing.stack
@@ -106,6 +114,53 @@ public struct RecordingView: View {
onTranscription(newValue)
}
}
.alert(
Constant.Text.titleError,
isPresented: .init(
get: { model.error != nil },
set: { isPresented in
if !isPresented {
model.dismissedError()
}
}
),
presenting: model.error
) { error in
if error == .permissionDenied, let url = Constant.URLs.settings {
Button(Constant.Text.buttonSettings) {
openURL(url)
}
}
Button(
Constant.Text.buttonOK,
role: .cancel
) {
// Dismissal is handled by the presentation binding.
}
} message: { error in
Text(message(for: error))
}
}
}
// MARK: - Helpers
private extension RecordingView {
/// Returns the message describing the given error for the alert.
///
/// - Parameter error: The error to describe.
/// - Returns: The message describing the error.
func message(
for error: RecordingError
) -> String {
switch error {
case .captureFailed: Constant.Text.messageCaptureFailed
case .permissionDenied: Constant.Text.messagePermissionDenied
case .transcriptionFailed: Constant.Text.messageTranscriptionFailed
}
}
}
@@ -124,6 +179,52 @@ private enum Constant {
/// The spacing between the elements of a stack.
static let stack: CGFloat = 8
}
/// The text constants, localized through the package's string catalog.
enum Text {
/// The title of the alert's dismissal button.
static let buttonOK = String(
localized: "view.recording.alert.error.button.ok",
bundle: .module
)
/// The title of the alert's button that opens the app's settings.
static let buttonSettings = String(
localized: "view.recording.alert.error.button.settings",
bundle: .module
)
/// The message describing a failure of the audio capture.
static let messageCaptureFailed = String(
localized: "view.recording.alert.error.message.capture",
bundle: .module
)
/// The message describing a denied microphone permission.
static let messagePermissionDenied = String(
localized: "view.recording.alert.error.message.permission",
bundle: .module
)
/// The message describing a failure of the transcription.
static let messageTranscriptionFailed = String(
localized: "view.recording.alert.error.message.transcription",
bundle: .module
)
/// The title of the error alert.
static let titleError = String(
localized: "view.recording.alert.error.title",
bundle: .module
)
}
/// The URL constants.
enum URLs {
/// The location that opens the app's settings, where the microphone permission can be granted.
static let settings: URL? = {
#if os(macOS)
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
#else
URL(string: UIApplication.openSettingsURLString)
#endif
}()
}
}
// MARK: - Previews
@@ -19,6 +19,7 @@ struct RecordingViewModelTests {
#expect(model.state == .notRecording)
#expect(model.elapsedSeconds == 0)
#expect(model.error == nil)
#expect(model.textTimer == "00:00")
#expect(model.transcription == nil)
}
@@ -342,6 +343,99 @@ struct RecordingViewModelTests {
}
// MARK: Errors
@MainActor
@Suite("Errors")
struct Errors {
@Test func `surfaces a capture failure`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(model.error == .captureFailed)
#expect(model.state == .notRecording)
}
@Test func `surfaces a denied microphone permission`() async throws {
let capturer = CapturingMock()
capturer.error = AudioCapturingError.permissionNotGranted
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(model.error == .permissionDenied)
}
@Test func `surfaces a transcription failure`() async throws {
let transcriber = TranscribingMock()
transcriber.error = ErrorMock()
let model = Model(
transcribe: transcriber
)
model.drive(to: .processing)
try await Task.sleep(for: .seconds(0.5))
#expect(model.error == .transcriptionFailed)
#expect(model.transcription == nil)
}
@Test func `clears the error when dismissed`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(model.error != nil)
model.dismissedError()
#expect(model.error == nil)
}
@Test func `clears the error when a new recording starts`() async throws {
let capturer = CapturingMock()
capturer.error = ErrorMock()
let model = Model(capturer: capturer)
model.pressedMain()
try await Task.sleep(for: .seconds(0.1))
#expect(model.error != nil)
capturer.error = nil
model.pressedMain()
#expect(model.error == nil)
model.pressedMain()
}
}
// MARK: Interruptions
@MainActor