Created the Commanding package target in the Features package for controlling the recording flow from Live Activities.

This commit is contained in:
2026-07-05 21:58:20 +02:00
parent cbb5703751
commit d473504d75
7 changed files with 171 additions and 0 deletions
+17
View File
@@ -11,23 +11,40 @@ let package = Package(
.visionOS(.v26),
],
products: [
.library(
name: "Commanding",
targets: [
"Commanding",
]
),
.library(
name: "Features",
targets: [
"Commanding",
"Notifying",
"Recording",
]
),
],
targets: [
.target(
name: "Commanding",
path: "Sources/Commanding"
),
.target(
name: "Notifying",
path: "Sources/Notifying"
),
.target(
name: "Recording",
dependencies: ["Commanding"],
path: "Sources/Recording"
),
.testTarget(
name: "CommandingTests",
dependencies: ["Commanding"],
path: "Tests/Commanding"
),
.testTarget(
name: "NotifyingTests",
dependencies: ["Notifying"],
@@ -0,0 +1,56 @@
#if os(iOS)
import ActivityKit
import Foundation
/// The attributes of the Live Activity that surfaces the recording flow on the Lock Screen and in the Dynamic Island.
///
/// The attributes carry no static content: everything the activity shows lives in its ``ContentState``, which the app's reporting
/// service updates on every transition of the recording flow. The type is shared between the app which starts, updates, and
/// ends the activity and the widget extension which renders it.
public struct RecordingActivityAttributes: ActivityAttributes {
// MARK: Initializers
/// Creates the attributes of the Live Activity of a recording flow.
public init() {}
// MARK: Content state
/// The dynamic content of the Live Activity, updated on every transition of the recording flow.
public struct ContentState: Codable, Hashable, Sendable {
// MARK: Properties
/// The current state of the recording flow.
public var state: RecordingActivityState
/// The instant the elapsed recording time counts from moved back by the time already spent recording, so a timer
/// counting up from it shows the elapsed recording time or `nil` while the recording is not running.
public var anchor: Date?
/// The number of seconds spent recording up to the last transition, excluding any time spent paused, shown as a frozen
/// elapsed time while the recording is not running.
public var elapsed: TimeInterval
// MARK: Initializers
/// Creates the dynamic content of the Live Activity of a recording flow.
///
/// - Parameters:
/// - state: The current state of the recording flow.
/// - 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 last transition, excluding any time spent paused.
public init(
state: RecordingActivityState,
anchor: Date?,
elapsed: TimeInterval
) {
self.state = state
self.anchor = anchor
self.elapsed = elapsed
}
}
}
#endif
@@ -0,0 +1,7 @@
/// A command that controls the recording flow from outside the feature's own UI for example, from the buttons of a Live Activity.
public enum RecordingCommand: Equatable, Sendable {
/// Starts, pauses, or resumes the recording, like a press of the feature's main button.
case toggle
/// Discards the recording, like a press of the feature's discard button.
case discard
}
@@ -0,0 +1,43 @@
import Foundation
/// The bridge that carries ``RecordingCommand``s from outside the feature's UI into the recording flow.
///
/// The commander exists because the senders of the commands the App Intents behind a Live Activity's buttons are instantiated
/// by the system, out of reach of the feature's dependency injection: they send through the process-wide ``shared`` instance, and the
/// feature's model listens to ``commands``, translating every received command into the button press it mirrors. Unit tests create
/// commanders of their own and inject them into the model, leaving the shared instance untouched.
@MainActor
public final class RecordingCommander {
// MARK: Constants
/// The process-wide commander the recording flow listens to by default.
public static let shared = RecordingCommander()
// MARK: Properties
/// The stream of commands sent through the commander.
public let commands: AsyncStream<RecordingCommand>
/// The continuation that feeds ``commands``.
private let continuation: AsyncStream<RecordingCommand>.Continuation
// MARK: Initializers
/// Creates a commander with an empty stream of commands.
public init() {
(commands, continuation) = AsyncStream.makeStream(of: RecordingCommand.self)
}
// MARK: Methods
/// Sends a command to the recording flow listening to the commander.
///
/// - Parameter command: The command to send.
public func send(
_ command: RecordingCommand
) {
continuation.yield(command)
}
}
@@ -0,0 +1,11 @@
#if os(iOS)
/// The states of the recording flow a Live Activity can show.
public enum RecordingActivityState: String, Codable, Hashable, Sendable {
/// A recording is in progress and its timer is running.
case recording
/// The recording is paused.
case paused
/// The recorded input is being processed.
case processing
}
#endif
@@ -0,0 +1,30 @@
import Testing
@testable import Commanding
@Suite("Recording commander")
struct RecordingCommanderTests {
@MainActor
@Test
func `delivers the sent commands in order`() async throws {
let commander = RecordingCommander()
commander.send(.toggle)
commander.send(.discard)
commander.send(.toggle)
var received: [RecordingCommand] = []
for await command in commander.commands {
received.append(command)
if received.count == 3 {
break
}
}
#expect(received == [.toggle, .discard, .toggle])
}
}
@@ -12,6 +12,13 @@
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:",
"identifier" : "CommandingTests",
"name" : "CommandingTests"
}
},
{
"target" : {
"containerPath" : "container:",