diff --git a/Packages/Features/Package.swift b/Packages/Features/Package.swift index c682b29..766905e 100644 --- a/Packages/Features/Package.swift +++ b/Packages/Features/Package.swift @@ -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"], diff --git a/Packages/Features/Sources/Commanding/Activities/RecordingActivity.swift b/Packages/Features/Sources/Commanding/Activities/RecordingActivity.swift new file mode 100644 index 0000000..2576bf0 --- /dev/null +++ b/Packages/Features/Sources/Commanding/Activities/RecordingActivity.swift @@ -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 diff --git a/Packages/Features/Sources/Commanding/Commands/RecordingCommand.swift b/Packages/Features/Sources/Commanding/Commands/RecordingCommand.swift new file mode 100644 index 0000000..052320b --- /dev/null +++ b/Packages/Features/Sources/Commanding/Commands/RecordingCommand.swift @@ -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 +} diff --git a/Packages/Features/Sources/Commanding/Services/RecordingCommander.swift b/Packages/Features/Sources/Commanding/Services/RecordingCommander.swift new file mode 100644 index 0000000..49b9f7b --- /dev/null +++ b/Packages/Features/Sources/Commanding/Services/RecordingCommander.swift @@ -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 + + /// The continuation that feeds ``commands``. + private let continuation: AsyncStream.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) + } + +} diff --git a/Packages/Features/Sources/Commanding/States/RecordingActivityState.swift b/Packages/Features/Sources/Commanding/States/RecordingActivityState.swift new file mode 100644 index 0000000..755edd0 --- /dev/null +++ b/Packages/Features/Sources/Commanding/States/RecordingActivityState.swift @@ -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 diff --git a/Packages/Features/Tests/Commanding/Services/RecordingCommanderTests.swift b/Packages/Features/Tests/Commanding/Services/RecordingCommanderTests.swift new file mode 100644 index 0000000..d635fdc --- /dev/null +++ b/Packages/Features/Tests/Commanding/Services/RecordingCommanderTests.swift @@ -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]) + } + +} diff --git a/Packages/Features/Tests/Features.xctestplan b/Packages/Features/Tests/Features.xctestplan index cfe4b62..c68cda3 100644 --- a/Packages/Features/Tests/Features.xctestplan +++ b/Packages/Features/Tests/Features.xctestplan @@ -12,6 +12,13 @@ }, "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "CommandingTests", + "name" : "CommandingTests" + } + }, { "target" : { "containerPath" : "container:",