Implemented the resumed interrupted recordings and allowed processing discards on the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-05 16:16:53 +02:00
parent 8576d6bac6
commit c2ac476257
14 changed files with 563 additions and 309 deletions
@@ -1,7 +1,7 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
import UIKit
#endif
/// A view that drives a recording session.
@@ -11,17 +11,20 @@ import UIKit
///
/// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses.
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
@Environment(\.openURL)
private var openURL
/// The font size of the timer's label, scaled relative to the current dynamic type size.
@ScaledMetric
private var fontSize = Constant.Size.timer
/// The model that owns the recording state and drives the view's controls.
@State private var model: Model
/// The font size of the timer's label, scaled relative to the current dynamic type size.
@ScaledMetric private var fontSize = Constant.Size.timer
@State
private var model: Model
/// The closure invoked with the transcription of every processed recording.
private let onTranscription: (Transcription) -> Void
@@ -69,10 +72,10 @@ public struct RecordingView: View {
.easeInOut,
value: model.textTimer
)
.accessibilityLabel(Constant.Text.labelTimer)
.accessibilityLabel(.viewRecordingTimerLabel)
.accessibilityValue(model.textTimerAccessible)
}
GlassEffectContainer {
HStack(
spacing: Constant.Spacing.stack
@@ -87,7 +90,7 @@ public struct RecordingView: View {
invertStyle: true
))
.disabled(model.shouldDisableMain)
.accessibilityLabel(labelMain)
.accessibilityLabel(model.labelMain)
if model.shouldShowActions {
Button {
@@ -104,19 +107,18 @@ public struct RecordingView: View {
invertStyle: !model.isProcessing
))
.disabled(model.isProcessing)
.accessibilityLabel(labelSend)
.accessibilityLabel(model.labelSend)
Button {
model.pressedDiscard()
} label: {
Image(systemName: Constant.Symbol.discard)
Image.Icon.discard
.resizable()
}
.buttonStyle(.recording(
invertStyle: true
))
.disabled(model.shouldDisableMain)
.accessibilityLabel(Constant.Text.labelDiscard)
.accessibilityLabel(.viewRecordingButtonDiscardLabel)
}
}
}
@@ -134,72 +136,32 @@ public struct RecordingView: View {
}
}
.alert(
Constant.Text.titleError,
isPresented: .init(
get: { model.error != nil },
set: { isPresented in
if !isPresented {
model.dismissedError()
}
.viewRecordingAlertErrorTitle,
isPresented: .init {
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) {
if error == .permissionDenied, let url = URL.settings {
Button(.viewRecordingAlertErrorButtonSettings) {
openURL(url)
}
}
Button(
Constant.Text.buttonOK,
.viewRecordingAlertErrorButtonOk,
role: .cancel
) {
// Dismissal is handled by the presentation binding.
}
} message: { error in
Text(message(for: error))
}
}
}
// MARK: - Helpers
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.
/// - Returns: The message describing the error.
func message(
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
} message: { _ in
if let message = model.textAlertMessage {
Text(message)
}
}
}
@@ -219,98 +181,29 @@ private enum Constant {
/// The spacing between the elements of a stack.
static let stack: CGFloat = 16
}
}
// MARK: - Image+Constants
private extension Image {
/// The symbol constants.
enum Symbol {
enum Icon {
/// The system symbol of the discard button's image.
static let discard = "trash"
static let discard = Image(systemName: "trash")
}
}
/// 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 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",
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
)
}
// MARK: - URL+Constants
/// 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
}()
}
private extension URL {
/// The location that opens the app's settings, where the microphone permission can be granted.
static let settings: URL? = {
#if os(macOS)
.init(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
#else
.init(string: UIApplication.openSettingsURLString)
#endif
}()
}
// MARK: - Previews