Surfaced recording flow errors to the user through an alert in the Recording package target.
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
|
do {
|
||||||
transcription = try await transcribe(
|
transcription = try await transcribe(
|
||||||
audio,
|
audio,
|
||||||
locale: locale.wrappedValue
|
locale: locale.wrappedValue
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
transcription = nil
|
transcription = nil
|
||||||
|
self.error = .transcriptionFailed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user