Added a discard button and error handling to the RecordingView view in the Recording package target.

This commit is contained in:
2026-07-04 15:59:30 +02:00
parent 7ec49264e6
commit b0c6e25639
7 changed files with 339 additions and 14 deletions
@@ -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.
@@ -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"
@@ -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.
@@ -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"
)
@@ -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
@@ -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",
@@ -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()