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(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user