diff --git a/Packages/Features/Package.swift b/Packages/Features/Package.swift index d6514ab..af8387a 100644 --- a/Packages/Features/Package.swift +++ b/Packages/Features/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "Features", + defaultLocalization: "en", platforms: [ .iOS(.v26), .macOS(.v26), diff --git a/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings b/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..5bd711d --- /dev/null +++ b/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings @@ -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" +} diff --git a/Packages/Features/Sources/Recording/Models/RecordingError.swift b/Packages/Features/Sources/Recording/Models/RecordingError.swift new file mode 100644 index 0000000..c548f89 --- /dev/null +++ b/Packages/Features/Sources/Recording/Models/RecordingError.swift @@ -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 +} diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index bdfc0bf..76622ae 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -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 } diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index 29e422a..6bb6fbb 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -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 diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index 011e42e..daa1a65 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -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