From b0c6e25639fe10d04d476097fd13b09ecb58c3c8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 4 Jul 2026 15:58:32 +0200 Subject: [PATCH] Added a discard button and error handling to the RecordingView view in the Recording package target. --- .../View Models/ContentViewModel.swift | 32 +++++-- .../Recording/Catalogs/Localizable.xcstrings | 96 +++++++++++++++++++ .../Recording/Models/RecordingError.swift | 2 + .../Services/AudioTranscribing.swift | 24 ++++- .../View Models/RecordingViewModel.swift | 44 ++++++++- .../Recording/Views/RecordingView.swift | 91 +++++++++++++++++- .../View Models/RecordingViewModelTests.swift | 64 +++++++++++++ 7 files changed, 339 insertions(+), 14 deletions(-) diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift index 69f5c22..45dab79 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import OSLog import Recording import Speech @@ -30,6 +31,13 @@ extension ContentView { @ObservationIgnored let capturer: AudioCapturing + /// The logger that records the failures of the speech model asset management. + @ObservationIgnored + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Attendi", + category: "ContentView.Model" + ) + /// The service that transcribes the recorded audio into text on device. @ObservationIgnored let transcriber: AudioTranscribing @@ -65,7 +73,9 @@ extension ContentView { /// Preinstalls the speech model assets for the supported equivalent of ``locale``, so the first transcription in that locale /// does not have to download them mid-processing. /// - /// Failures are ignored on purpose: the transcribing service installs any missing assets itself as a fallback when a + /// The locale reservations of any other locales are released beforehand: the app transcribes a single locale at a time, and + /// the system only permits a limited number of reservations — exceeding it would make the installation request throw. + /// Failures are logged but not surfaced: the transcribing service installs any missing assets itself as a fallback when a /// transcription starts. func preinstallAssets() async { guard let locale = await SpeechTranscriber.supportedLocale( @@ -74,18 +84,26 @@ extension ContentView { return } + for reserved in await AssetInventory.reservedLocales where reserved != locale { + _ = await AssetInventory.release(reservedLocale: reserved) + } + let transcriber = SpeechTranscriber( locale: locale, preset: .transcription ) - guard let request = try? await AssetInventory.assetInstallationRequest( - supporting: [transcriber] - ) else { - return - } + do { + guard let request = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) else { + return + } - try? await request.downloadAndInstall() + try await request.downloadAndInstall() + } catch { + logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)") + } } /// Returns the localized name of a locale for the locale picker. diff --git a/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings b/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings index 5bd711d..3df9c2c 100644 --- a/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings +++ b/Packages/Features/Sources/Recording/Catalogs/Localizable.xcstrings @@ -25,6 +25,18 @@ } } }, + "view.recording.alert.error.message.assets" : { + "comment" : "The message describing unavailable speech model assets.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The speech model for the selected language is not available. Please try again or pick another language." + } + } + } + }, "view.recording.alert.error.message.capture" : { "comment" : "The message describing a failure of the audio capture.", "extractionState" : "manual", @@ -72,6 +84,90 @@ } } } + }, + "view.recording.button.discard.label" : { + "comment" : "The accessibility label of the discard button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard recording" + } + } + } + }, + "view.recording.button.main.label.pause" : { + "comment" : "The accessibility label of the main button while recording.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause recording" + } + } + } + }, + "view.recording.button.main.label.record" : { + "comment" : "The accessibility label of the main button while not recording.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start recording" + } + } + } + }, + "view.recording.button.main.label.resume" : { + "comment" : "The accessibility label of the main button while paused.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume recording" + } + } + } + }, + "view.recording.button.send.label.processing" : { + "comment" : "The accessibility label of the send button while processing.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processing" + } + } + } + }, + "view.recording.button.send.label.send" : { + "comment" : "The accessibility label of the send button while paused.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send recording" + } + } + } + }, + "view.recording.timer.label" : { + "comment" : "The accessibility label of the timer.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording time" + } + } + } } }, "version" : "1.0" diff --git a/Packages/Features/Sources/Recording/Models/RecordingError.swift b/Packages/Features/Sources/Recording/Models/RecordingError.swift index c548f89..816338e 100644 --- a/Packages/Features/Sources/Recording/Models/RecordingError.swift +++ b/Packages/Features/Sources/Recording/Models/RecordingError.swift @@ -2,6 +2,8 @@ import Foundation /// An error of the recording flow, surfaced to the user by ``RecordingView``. public enum RecordingError: Error, Equatable, Sendable { + /// The speech model assets for the locale to transcribe could not be reserved or installed. + case assetsUnavailable /// The audio capture failed to start, resume, or stop. case captureFailed /// The user denied the app permission to record audio from the microphone. diff --git a/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift index 6887d1f..383a0d7 100644 --- a/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift +++ b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift @@ -1,4 +1,5 @@ import AVFoundation +import OSLog import Speech /// The transcribing service that transcribes recorded audio into text on device. @@ -23,7 +24,8 @@ public struct AudioTranscribing: Transcribing { /// - locale: The locale of the spoken language to transcribe. /// - Returns: The transcription of the recorded audio. /// - Throws: ``AudioTranscribingError/localeNotSupported`` when the transcriber supports no equivalent of the given locale, - /// or any error thrown while installing the speech model assets, reading the audio file, or analyzing its contents. + /// ``AudioTranscribingError/assetsNotInstalled`` when the speech model assets fail to reserve, download, or install, + /// or any error thrown while reading the audio file or analyzing its contents. public func callAsFunction( _ audio: URL, locale: Locale @@ -43,8 +45,14 @@ public struct AudioTranscribing: Transcribing { preset: .transcription ) - if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { - try await request.downloadAndInstall() + do { + if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { + try await request.downloadAndInstall() + } + } catch { + logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to install: \(String(describing: error), privacy: .public)") + + throw AudioTranscribingError.assetsNotInstalled } let analyzer = SpeechAnalyzer(modules: [transcriber]) @@ -70,6 +78,16 @@ public struct AudioTranscribing: Transcribing { /// The errors thrown by ``AudioTranscribing``. public enum AudioTranscribingError: Error { + /// The speech model assets for the locale failed to reserve, download, or install. + case assetsNotInstalled /// The transcriber supports no equivalent of the locale the transcription was requested with. case localeNotSupported } + +// MARK: - Constants + +/// The logger that records the failures of the audio transcribing service. +private let logger = Logger( + subsystem: "Features.Recording", + category: "AudioTranscribing" +) diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 76622ae..4298bf6 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -108,6 +108,11 @@ extension RecordingView { state == .processing } + /// Whether the discard button should be visible. + var shouldShowDiscard: Bool { + state == .paused + } + /// Whether the send button should be visible. var shouldShowSend: Bool { state != .notRecording @@ -126,8 +131,43 @@ extension RecordingView { .formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) } + /// The elapsed recording time, spelled out in full units for assistive technologies. + var textTimerAccessible: String { + Duration + .seconds(elapsedSeconds) + .formatted(.units( + allowed: [.minutes, .seconds], + width: .wide + )) + } + // MARK: Methods + /// Handles a press of the discard button. + /// + /// Throws away a paused recording: it resets the timer, returns to the not-recording state, and stops the audio capture, + /// deleting the captured audio file. Does nothing in any other state. + func pressedDiscard() { + guard state == .paused else { + return + } + + state = .notRecording + + stopTimer() + + accumulated = .zero + elapsedSeconds = 0 + + enqueueCapturer { + guard let audio = try? await self.capturer.stop() else { + return + } + + try? FileManager.default.removeItem(at: audio) + } + } + /// Handles a press of the main button. /// /// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer @@ -213,7 +253,9 @@ private extension RecordingView.Model { ) } catch { transcription = nil - self.error = .transcriptionFailed + self.error = error as? AudioTranscribingError == .assetsNotInstalled + ? .assetsUnavailable + : .transcriptionFailed } } catch { transcription = nil diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index 6bb6fbb..8040f11 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -51,9 +51,9 @@ 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, and an alert surfacing any error of the recording flow — offering to open the - /// app's settings when the microphone permission was denied. + /// The content of the view: the timer label above the main, discard, and send buttons, with the transcription of every processed + /// recording 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 @@ -69,6 +69,8 @@ public struct RecordingView: View { .easeInOut, value: model.textTimer ) + .accessibilityLabel(Constant.Text.labelTimer) + .accessibilityValue(model.textTimerAccessible) } GlassEffectContainer { @@ -86,6 +88,21 @@ public struct RecordingView: View { invertStyle: true )) .disabled(model.shouldDisableMain) + .accessibilityLabel(labelMain) + + if model.shouldShowDiscard { + Button { + model.pressedDiscard() + } label: { + Image(systemName: Constant.Symbol.discard) + .resizable() + .scaledToFit() + } + .buttonStyle(.recording( + invertStyle: true + )) + .accessibilityLabel(Constant.Text.labelDiscard) + } if model.shouldShowSend { Button { @@ -102,6 +119,7 @@ public struct RecordingView: View { invertStyle: !model.isProcessing )) .disabled(model.isProcessing) + .accessibilityLabel(labelSend) } } } @@ -149,6 +167,26 @@ public struct RecordingView: View { private extension RecordingView { + // MARK: Computed + + /// The accessibility label for the main button, matching its action in the current state. + var labelMain: String { + switch model.state { + case .notRecording, .processing: Constant.Text.labelRecord + case .recording: Constant.Text.labelPause + case .paused: Constant.Text.labelResume + } + } + + /// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise. + var labelSend: String { + model.isProcessing + ? Constant.Text.labelProcessing + : Constant.Text.labelSend + } + + // MARK: Methods + /// Returns the message describing the given error for the alert. /// /// - Parameter error: The error to describe. @@ -157,6 +195,7 @@ private extension RecordingView { for error: RecordingError ) -> String { switch error { + case .assetsUnavailable: Constant.Text.messageAssetsUnavailable case .captureFailed: Constant.Text.messageCaptureFailed case .permissionDenied: Constant.Text.messagePermissionDenied case .transcriptionFailed: Constant.Text.messageTranscriptionFailed @@ -180,6 +219,12 @@ private enum Constant { static let stack: CGFloat = 8 } + /// The symbol constants. + enum Symbol { + /// The system symbol of the discard button's image. + static let discard = "trash" + } + /// The text constants, localized through the package's string catalog. enum Text { /// The title of the alert's dismissal button. @@ -187,11 +232,51 @@ private enum Constant { localized: "view.recording.alert.error.button.ok", bundle: .module ) + /// The accessibility label of the discard button. + static let labelDiscard = String( + localized: "view.recording.button.discard.label", + bundle: .module + ) + /// The accessibility label of the main button while recording. + static let labelPause = String( + localized: "view.recording.button.main.label.pause", + bundle: .module + ) + /// The accessibility label of the send button while processing. + static let labelProcessing = String( + localized: "view.recording.button.send.label.processing", + bundle: .module + ) + /// The accessibility label of the main button while not recording. + static let labelRecord = String( + localized: "view.recording.button.main.label.record", + bundle: .module + ) + /// The accessibility label of the main button while paused. + static let labelResume = String( + localized: "view.recording.button.main.label.resume", + bundle: .module + ) + /// The accessibility label of the send button while paused. + static let labelSend = String( + localized: "view.recording.button.send.label.send", + bundle: .module + ) + /// The accessibility label of the timer. + static let labelTimer = String( + localized: "view.recording.timer.label", + 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 unavailable speech model assets. + static let messageAssetsUnavailable = String( + localized: "view.recording.alert.error.message.assets", + bundle: .module + ) /// The message describing a failure of the audio capture. static let messageCaptureFailed = String( localized: "view.recording.alert.error.message.capture", diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index daa1a65..cdc200b 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -66,6 +66,23 @@ struct RecordingViewModelTests { #expect(model.state == expected) } + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [Model.State.notRecording, .recording, .notRecording, .processing] + )) + func `pressed discard transitions to the expected state`( + from initial: Model.State, + to expected: Model.State + ) { + let model = Model() + + model.drive(to: initial) + + model.pressedDiscard() + + #expect(model.state == expected) + } + } // MARK: Computed properties @@ -134,6 +151,21 @@ struct RecordingViewModelTests { #expect(model.shouldDisableMain == expected) } + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [false, false, true, false] + )) + func `discard button is visible only while paused`( + for state: Model.State, + expected: Bool + ) { + let model = Model() + + model.drive(to: state) + + #expect(model.shouldShowDiscard == expected) + } + @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, false, true, true] @@ -305,6 +337,21 @@ struct RecordingViewModelTests { #expect(capturer.calls == ["start", "pause", "stop"]) } + @Test func `stops the capture when a paused recording is discarded`() async throws { + let capturer = CapturingMock() + let model = Model(capturer: capturer) + + model.drive(to: .paused) + + model.pressedDiscard() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(capturer.calls == ["start", "pause", "stop"]) + #expect(model.state == .notRecording) + #expect(model.elapsedSeconds == 0) + } + @Test func `serializes the calls when states change rapidly`() async throws { let capturer = CapturingMock() @@ -378,6 +425,23 @@ struct RecordingViewModelTests { #expect(model.error == .permissionDenied) } + @Test func `surfaces unavailable speech model assets`() async throws { + let transcriber = TranscribingMock() + + transcriber.error = AudioTranscribingError.assetsNotInstalled + + let model = Model( + transcribe: transcriber + ) + + model.drive(to: .processing) + + try await Task.sleep(for: .seconds(0.5)) + + #expect(model.error == .assetsUnavailable) + #expect(model.transcription == nil) + } + @Test func `surfaces a transcription failure`() async throws { let transcriber = TranscribingMock()