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( let package = Package(
name: "Features", name: "Features",
defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v26), .iOS(.v26),
.macOS(.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
}
@@ -20,6 +20,9 @@ extension RecordingView {
/// The number of seconds spent recording, excluding any time spent paused. /// The number of seconds spent recording, excluding any time spent paused.
private(set) var elapsedSeconds: Int = 0 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. /// The transcription of the last processed recording, or `nil` when none has been processed yet.
private(set) var transcription: Transcription? 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. /// 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. /// 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 // MARK: Methods
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``; /// 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 { func processInput() async {
guard state == .processing else { guard state == .processing else {
return return
@@ -197,12 +206,18 @@ private extension RecordingView.Model {
do { do {
let audio = try await capturer.stop() let audio = try await capturer.stop()
transcription = try await transcribe( do {
audio, transcription = try await transcribe(
locale: locale.wrappedValue audio,
) locale: locale.wrappedValue
)
} catch {
transcription = nil
self.error = .transcriptionFailed
}
} catch { } catch {
transcription = nil transcription = nil
self.error = .captureFailed
} }
accumulated = .zero 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. /// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
func startCapturer( func startCapturer(
@@ -272,6 +288,9 @@ private extension RecordingView.Model {
self.stopTimer() self.stopTimer()
self.state = .notRecording 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 /// 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. /// 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 /// - Parameter shouldRestartTimer: Whether the measured recording time, ``elapsedSeconds``, ``error``, and ``transcription``
/// cleared before the timer starts, which is the case for a new recording as opposed to one resuming from a pause. /// 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( func startTimer(
_ shouldRestartTimer: Bool _ shouldRestartTimer: Bool
) { ) {
if shouldRestartTimer { if shouldRestartTimer {
accumulated = .zero accumulated = .zero
elapsedSeconds = 0 elapsedSeconds = 0
error = nil
transcription = nil transcription = nil
} }
@@ -1,5 +1,9 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// A view that drives a recording session. /// 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. /// 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 // 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. /// The model that owns the recording state and drives the view's controls.
@State private var model: Model @State private var model: Model
@@ -45,7 +52,8 @@ public struct RecordingView: View {
// MARK: Body // MARK: Body
/// The content of the view: the timer label above the main and send buttons, with the transcription of every processed recording /// 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 { public var body: some View {
VStack( VStack(
spacing: Constant.Spacing.stack spacing: Constant.Spacing.stack
@@ -106,6 +114,53 @@ public struct RecordingView: View {
onTranscription(newValue) 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. /// The spacing between the elements of a stack.
static let stack: CGFloat = 8 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 // MARK: - Previews
@@ -19,6 +19,7 @@ struct RecordingViewModelTests {
#expect(model.state == .notRecording) #expect(model.state == .notRecording)
#expect(model.elapsedSeconds == 0) #expect(model.elapsedSeconds == 0)
#expect(model.error == nil)
#expect(model.textTimer == "00:00") #expect(model.textTimer == "00:00")
#expect(model.transcription == nil) #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 // MARK: Interruptions
@MainActor @MainActor