Created the Commanding package target in the Features package for controlling the recording flow from Live Activities.
This commit is contained in:
@@ -11,23 +11,40 @@ let package = Package(
|
|||||||
.visionOS(.v26),
|
.visionOS(.v26),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "Commanding",
|
||||||
|
targets: [
|
||||||
|
"Commanding",
|
||||||
|
]
|
||||||
|
),
|
||||||
.library(
|
.library(
|
||||||
name: "Features",
|
name: "Features",
|
||||||
targets: [
|
targets: [
|
||||||
|
"Commanding",
|
||||||
"Notifying",
|
"Notifying",
|
||||||
"Recording",
|
"Recording",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Commanding",
|
||||||
|
path: "Sources/Commanding"
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "Notifying",
|
name: "Notifying",
|
||||||
path: "Sources/Notifying"
|
path: "Sources/Notifying"
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "Recording",
|
name: "Recording",
|
||||||
|
dependencies: ["Commanding"],
|
||||||
path: "Sources/Recording"
|
path: "Sources/Recording"
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CommandingTests",
|
||||||
|
dependencies: ["Commanding"],
|
||||||
|
path: "Tests/Commanding"
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "NotifyingTests",
|
name: "NotifyingTests",
|
||||||
dependencies: ["Notifying"],
|
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" : [
|
"testTargets" : [
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "CommandingTests",
|
||||||
|
"name" : "CommandingTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:",
|
"containerPath" : "container:",
|
||||||
|
|||||||
Reference in New Issue
Block a user