diff --git a/Apps/Attendi/Resources/Attendi-Info.plist b/Apps/Attendi/Resources/Attendi-Info.plist index f753731..3b96ccc 100644 --- a/Apps/Attendi/Resources/Attendi-Info.plist +++ b/Apps/Attendi/Resources/Attendi-Info.plist @@ -2,6 +2,8 @@ + NSSupportsLiveActivities + UIBackgroundModes audio diff --git a/Apps/Attendi/Sources/Services/ActivityReporting.swift b/Apps/Attendi/Sources/Services/ActivityReporting.swift new file mode 100644 index 0000000..1e361e6 --- /dev/null +++ b/Apps/Attendi/Sources/Services/ActivityReporting.swift @@ -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? + #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 diff --git a/Apps/Attendi/Sources/View Models/ContentViewModel.swift b/Apps/Attendi/Sources/View Models/ContentViewModel.swift index 109655e..d3a0765 100644 --- a/Apps/Attendi/Sources/View Models/ContentViewModel.swift +++ b/Apps/Attendi/Sources/View Models/ContentViewModel.swift @@ -7,7 +7,7 @@ extension 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 /// 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 @@ -50,6 +50,10 @@ extension ContentView { @ObservationIgnored 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. @ObservationIgnored private var taskEvents: Task? @@ -70,11 +74,14 @@ extension ContentView { /// - Parameters: /// - 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``. + /// - 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``. /// - notifier: The notifier that owns the transient in-app notifications. Defaults to a notifier with its standard dismissal delay. init( capturer: any Capturing = AudioCapturing(), preinstaller: any Preinstalling = AssetPreinstalling(), + reporter: any Reporting = ActivityReporting(), transcriber: any Transcribing = AudioTranscribing(), notifier: Notifier = .init(), ) { @@ -83,6 +90,7 @@ extension ContentView { self.capturer = capturer self.notifier = notifier self.preinstaller = preinstaller + self.reporter = reporter self.transcriber = transcriber } diff --git a/Apps/Attendi/Sources/Views/ContentView.swift b/Apps/Attendi/Sources/Views/ContentView.swift index d9fa19d..d55af71 100644 --- a/Apps/Attendi/Sources/Views/ContentView.swift +++ b/Apps/Attendi/Sources/Views/ContentView.swift @@ -53,6 +53,7 @@ struct ContentView: View { RecordingView( capturer: model.capturer, transcriber: model.transcriber, + reporter: model.reporter, locale: $model.locale, ) { transcription in model.received(transcription) diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index 80f974e..f25a7b0 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0296F7EF2FFAB1B600D2C5FC /* WidgetKit.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, ); }; }; + 02D46AFE2FFAE77500266643 /* Features in Frameworks */ = {isa = PBXBuildFile; productRef = 02D46AFD2FFAE77500266643 /* Features */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +62,7 @@ Attendi/Catalogs/Localizable.xcstrings, Attendi/Previews/Services/PreviewPreinstalling.swift, Attendi/Sources/App/AttendiApp.swift, + Attendi/Sources/Services/ActivityReporting.swift, "Attendi/Sources/View Models/ContentViewModel.swift", Attendi/Sources/Views/ContentView.swift, ); @@ -80,7 +82,10 @@ Attendi/Catalogs/Assets.xcassets, Attendi/Sources/Bundle/AttendiWidgetBundle.swift, Attendi/Sources/Intents/AppIntent.swift, + Attendi/Sources/Intents/DiscardRecordingIntent.swift, + Attendi/Sources/Intents/ToggleRecordingIntent.swift, Attendi/Widgets/AttendiWidget.swift, + Attendi/Widgets/RecordingLiveActivity.swift, ); target = 0296F7ED2FFAB1B600D2C5FC /* AttendiWidgetExtension */; }; @@ -127,6 +132,7 @@ 0296F7EB2FFAB1B600D2C5FC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( + 02D46AFE2FFAE77500266643 /* Features in Frameworks */, 0296F7F22FFAB1B600D2C5FC /* SwiftUI.framework in Frameworks */, 0296F7F02FFAB1B600D2C5FC /* WidgetKit.framework in Frameworks */, ); @@ -221,6 +227,9 @@ buildRules = ( ); name = AttendiWidgetExtension; + packageProductDependencies = ( + 02D46AFD2FFAE77500266643 /* Features */, + ); productName = AttendiWidgetExtension; productReference = 0296F7EE2FFAB1B600D2C5FC /* AttendiWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -961,6 +970,10 @@ isa = XCSwiftPackageProductDependency; productName = Features; }; + 02D46AFD2FFAE77500266643 /* Features */ = { + isa = XCSwiftPackageProductDependency; + productName = Features; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 028709F22FF7E8E40079EA3A /* Project object */;