Implemented the ActivityReporting service in the Sample app target to surface the recording flow in Live Activities.
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
@preconcurrency import ActivityKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
import Commanding
|
||||||
|
import Foundation
|
||||||
|
import Recording
|
||||||
|
|
||||||
|
/// The reporting service that surfaces the recording flow in a Live Activity on the Lock Screen and in the Dynamic Island.
|
||||||
|
///
|
||||||
|
/// The service implements the `Recording` feature's `Reporting` port over the Live Activity vocabulary shared with the widget
|
||||||
|
/// extension through the `Commanding` target: it starts a Live Activity when a recording starts, updates its content on every pause,
|
||||||
|
/// resumption, and processing transition, and ends it — dismissing it right away — when the flow ends. The activity is an auxiliary
|
||||||
|
/// surface of the recording flow, never a required one: a start that fails, or that the user disallowed in the system settings, is
|
||||||
|
/// silently ignored, and the platforms without Live Activities reduce the whole service to a no-op.
|
||||||
|
@MainActor
|
||||||
|
final class ActivityReporting: Reporting {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// The Live Activity reflecting the ongoing recording flow, or `nil` while none is.
|
||||||
|
private var activity: Activity<RecordingActivityAttributes>?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a reporting service with no Live Activity started yet.
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Starts a Live Activity for a new recording, unless the user disallowed Live Activities or the system refuses to start one.
|
||||||
|
///
|
||||||
|
/// - Parameter anchor: The instant the elapsed recording time counts from.
|
||||||
|
func started(
|
||||||
|
at anchor: Date
|
||||||
|
) async {
|
||||||
|
#if os(iOS)
|
||||||
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = try? Activity.request(
|
||||||
|
attributes: RecordingActivityAttributes(),
|
||||||
|
content: .init(
|
||||||
|
state: .init(
|
||||||
|
state: .recording,
|
||||||
|
anchor: anchor,
|
||||||
|
elapsed: .zero
|
||||||
|
),
|
||||||
|
staleDate: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the Live Activity with the pause of the ongoing recording, freezing its timer at the elapsed recording time.
|
||||||
|
///
|
||||||
|
/// - Parameter elapsed: The number of seconds spent recording so far, excluding any time spent paused.
|
||||||
|
func paused(
|
||||||
|
elapsed: TimeInterval
|
||||||
|
) async {
|
||||||
|
#if os(iOS)
|
||||||
|
await update(
|
||||||
|
state: .paused,
|
||||||
|
anchor: nil,
|
||||||
|
elapsed: elapsed
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the Live Activity with the resumption of a paused recording, restarting its timer from the given anchor.
|
||||||
|
///
|
||||||
|
/// - Parameter anchor: The instant the elapsed recording time counts from, moved back by the time already spent recording.
|
||||||
|
func resumed(
|
||||||
|
anchor: Date
|
||||||
|
) async {
|
||||||
|
#if os(iOS)
|
||||||
|
await update(
|
||||||
|
state: .recording,
|
||||||
|
anchor: anchor,
|
||||||
|
elapsed: .zero
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the Live Activity with the processing of the recorded input, freezing its timer at the elapsed recording time.
|
||||||
|
///
|
||||||
|
/// - Parameter elapsed: The number of seconds spent recording, excluding any time spent paused.
|
||||||
|
func processing(
|
||||||
|
elapsed: TimeInterval
|
||||||
|
) async {
|
||||||
|
#if os(iOS)
|
||||||
|
await update(
|
||||||
|
state: .processing,
|
||||||
|
anchor: nil,
|
||||||
|
elapsed: elapsed
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the Live Activity of the recording flow, dismissing it right away.
|
||||||
|
func ended() async {
|
||||||
|
#if os(iOS)
|
||||||
|
await activity?.end(
|
||||||
|
activity?.content,
|
||||||
|
dismissalPolicy: .immediate
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension ActivityReporting {
|
||||||
|
|
||||||
|
/// Updates the dynamic content of the Live Activity, when one is running.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - state: The state of the recording flow to show.
|
||||||
|
/// - anchor: The instant the elapsed recording time counts from, or `nil` while the recording is not running.
|
||||||
|
/// - elapsed: The number of seconds spent recording up to the transition, excluding any time spent paused.
|
||||||
|
func update(
|
||||||
|
state: RecordingActivityState,
|
||||||
|
anchor: Date?,
|
||||||
|
elapsed: TimeInterval
|
||||||
|
) async {
|
||||||
|
await activity?.update(
|
||||||
|
.init(
|
||||||
|
state: .init(
|
||||||
|
state: state,
|
||||||
|
anchor: anchor,
|
||||||
|
elapsed: elapsed
|
||||||
|
),
|
||||||
|
staleDate: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -7,7 +7,7 @@ extension ContentView {
|
|||||||
|
|
||||||
/// The observable model that drives ``ContentView``.
|
/// The observable model that drives ``ContentView``.
|
||||||
///
|
///
|
||||||
/// The model owns the recording and transcribing services attached to the recording feature, along with the ``locale`` the feature
|
/// The model owns the recording, transcribing, and reporting services attached to the recording feature, along with the ``locale`` the feature
|
||||||
/// transcribes in, picked in the view's toolbar from the supported ``locales`` that ``load(identifier:)`` fetches — restoring the
|
/// transcribes in, picked in the view's toolbar from the supported ``locales`` that ``load(identifier:)`` fetches — restoring the
|
||||||
/// locale the view persisted across launches, when one exists. It also holds the transcription
|
/// locale the view persisted across launches, when one exists. It also holds the transcription
|
||||||
/// currently presented in the view's modal sheet: ``received(_:)`` presents the transcription of a processed recording, and
|
/// currently presented in the view's modal sheet: ``received(_:)`` presents the transcription of a processed recording, and
|
||||||
@@ -50,6 +50,10 @@ extension ContentView {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private let preinstaller: any Preinstalling
|
private let preinstaller: any Preinstalling
|
||||||
|
|
||||||
|
/// The service that reports the lifecycle of the recording flow outside the app's UI.
|
||||||
|
@ObservationIgnored
|
||||||
|
let reporter: any Reporting
|
||||||
|
|
||||||
/// The task that listens to the events emitted by the preinstalling service, or `nil` until the first preinstallation starts.
|
/// The task that listens to the events emitted by the preinstalling service, or `nil` until the first preinstallation starts.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var taskEvents: Task<Void, Never>?
|
private var taskEvents: Task<Void, Never>?
|
||||||
@@ -70,11 +74,14 @@ extension ContentView {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - capturer: The service that captures the audio from a microphone. Defaults to the on-device ``AudioCapturing``.
|
/// - capturer: The service that captures the audio from a microphone. Defaults to the on-device ``AudioCapturing``.
|
||||||
/// - preinstaller: The service that preinstalls the speech model assets of a picked locale. Defaults to ``AssetPreinstalling``.
|
/// - preinstaller: The service that preinstalls the speech model assets of a picked locale. Defaults to ``AssetPreinstalling``.
|
||||||
|
/// - reporter: The service that reports the lifecycle of the recording flow outside the app's UI. Defaults to
|
||||||
|
/// ``ActivityReporting``, which surfaces the flow in a Live Activity on the platforms that support it.
|
||||||
/// - transcriber: The service that transcribes the recorded audio into text. Defaults to the on-device ``AudioTranscribing``.
|
/// - transcriber: The service that transcribes the recorded audio into text. Defaults to the on-device ``AudioTranscribing``.
|
||||||
/// - notifier: The notifier that owns the transient in-app notifications. Defaults to a notifier with its standard dismissal delay.
|
/// - notifier: The notifier that owns the transient in-app notifications. Defaults to a notifier with its standard dismissal delay.
|
||||||
init(
|
init(
|
||||||
capturer: any Capturing = AudioCapturing(),
|
capturer: any Capturing = AudioCapturing(),
|
||||||
preinstaller: any Preinstalling = AssetPreinstalling(),
|
preinstaller: any Preinstalling = AssetPreinstalling(),
|
||||||
|
reporter: any Reporting = ActivityReporting(),
|
||||||
transcriber: any Transcribing = AudioTranscribing(),
|
transcriber: any Transcribing = AudioTranscribing(),
|
||||||
notifier: Notifier = .init(),
|
notifier: Notifier = .init(),
|
||||||
) {
|
) {
|
||||||
@@ -83,6 +90,7 @@ extension ContentView {
|
|||||||
self.capturer = capturer
|
self.capturer = capturer
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.preinstaller = preinstaller
|
self.preinstaller = preinstaller
|
||||||
|
self.reporter = reporter
|
||||||
self.transcriber = transcriber
|
self.transcriber = transcriber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ struct ContentView: View {
|
|||||||
RecordingView(
|
RecordingView(
|
||||||
capturer: model.capturer,
|
capturer: model.capturer,
|
||||||
transcriber: model.transcriber,
|
transcriber: model.transcriber,
|
||||||
|
reporter: model.reporter,
|
||||||
locale: $model.locale,
|
locale: $model.locale,
|
||||||
) { transcription in
|
) { transcription in
|
||||||
model.received(transcription)
|
model.received(transcription)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0296F7EF2FFAB1B600D2C5FC /* WidgetKit.framework */; };
|
0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0296F7EF2FFAB1B600D2C5FC /* WidgetKit.framework */; };
|
||||||
0296F7F22FFAB1B600D2C5FC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0296F7F12FFAB1B600D2C5FC /* SwiftUI.framework */; };
|
0296F7F22FFAB1B600D2C5FC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0296F7F12FFAB1B600D2C5FC /* SwiftUI.framework */; };
|
||||||
0296F7FF2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0296F7EE2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
0296F7FF2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0296F7EE2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
02D46AFE2FFAE77500266643 /* Features in Frameworks */ = {isa = PBXBuildFile; productRef = 02D46AFD2FFAE77500266643 /* Features */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
Attendi/Catalogs/Localizable.xcstrings,
|
Attendi/Catalogs/Localizable.xcstrings,
|
||||||
Attendi/Previews/Services/PreviewPreinstalling.swift,
|
Attendi/Previews/Services/PreviewPreinstalling.swift,
|
||||||
Attendi/Sources/App/AttendiApp.swift,
|
Attendi/Sources/App/AttendiApp.swift,
|
||||||
|
Attendi/Sources/Services/ActivityReporting.swift,
|
||||||
"Attendi/Sources/View Models/ContentViewModel.swift",
|
"Attendi/Sources/View Models/ContentViewModel.swift",
|
||||||
Attendi/Sources/Views/ContentView.swift,
|
Attendi/Sources/Views/ContentView.swift,
|
||||||
);
|
);
|
||||||
@@ -80,7 +82,10 @@
|
|||||||
Attendi/Catalogs/Assets.xcassets,
|
Attendi/Catalogs/Assets.xcassets,
|
||||||
Attendi/Sources/Bundle/AttendiWidgetBundle.swift,
|
Attendi/Sources/Bundle/AttendiWidgetBundle.swift,
|
||||||
Attendi/Sources/Intents/AppIntent.swift,
|
Attendi/Sources/Intents/AppIntent.swift,
|
||||||
|
Attendi/Sources/Intents/DiscardRecordingIntent.swift,
|
||||||
|
Attendi/Sources/Intents/ToggleRecordingIntent.swift,
|
||||||
Attendi/Widgets/AttendiWidget.swift,
|
Attendi/Widgets/AttendiWidget.swift,
|
||||||
|
Attendi/Widgets/RecordingLiveActivity.swift,
|
||||||
);
|
);
|
||||||
target = 0296F7ED2FFAB1B600D2C5FC /* AttendiWidgetExtension */;
|
target = 0296F7ED2FFAB1B600D2C5FC /* AttendiWidgetExtension */;
|
||||||
};
|
};
|
||||||
@@ -127,6 +132,7 @@
|
|||||||
0296F7EB2FFAB1B600D2C5FC /* Frameworks */ = {
|
0296F7EB2FFAB1B600D2C5FC /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
02D46AFE2FFAE77500266643 /* Features in Frameworks */,
|
||||||
0296F7F22FFAB1B600D2C5FC /* SwiftUI.framework in Frameworks */,
|
0296F7F22FFAB1B600D2C5FC /* SwiftUI.framework in Frameworks */,
|
||||||
0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */,
|
0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -221,6 +227,9 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
name = AttendiWidgetExtension;
|
name = AttendiWidgetExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
02D46AFD2FFAE77500266643 /* Features */,
|
||||||
|
);
|
||||||
productName = AttendiWidgetExtension;
|
productName = AttendiWidgetExtension;
|
||||||
productReference = 0296F7EE2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex */;
|
productReference = 0296F7EE2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
@@ -961,6 +970,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Features;
|
productName = Features;
|
||||||
};
|
};
|
||||||
|
02D46AFD2FFAE77500266643 /* Features */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Features;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 028709F22FF7E8E40079EA3A /* Project object */;
|
rootObject = 028709F22FF7E8E40079EA3A /* Project object */;
|
||||||
|
|||||||
Reference in New Issue
Block a user