Implemented the (first version of the) Recording Live Activity in the Attendi widget target.

This commit is contained in:
2026-07-06 03:43:25 +02:00
parent ffa80d3b58
commit 874de398d2
15 changed files with 755 additions and 4 deletions
+10 -2
View File
@@ -80,12 +80,20 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Attendi/Catalogs/Assets.xcassets,
Attendi/Catalogs/Localizable.xcstrings,
Attendi/Sources/Bundle/AttendiWidgetBundle.swift,
"Attendi/Sources/Extensions/Image+Symbols.swift",
Attendi/Sources/Intents/AppIntent.swift,
Attendi/Sources/Intents/DiscardRecordingIntent.swift,
Attendi/Sources/Intents/ProcessRecordingIntent.swift,
Attendi/Sources/Intents/ToggleRecordingIntent.swift,
Attendi/Widgets/AttendiWidget.swift,
Attendi/Widgets/RecordingLiveActivity.swift,
Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift,
Attendi/Sources/Views/RecordingControls.swift,
Attendi/Sources/Views/RecordingLiveActivityView.swift,
Attendi/Sources/Views/RecordingStateLabel.swift,
Attendi/Sources/Views/RecordingTimerText.swift,
Attendi/Sources/Widgets/AttendiWidget.swift,
Attendi/Sources/Widgets/RecordingLiveActivity.swift,
);
target = 0296F7ED2FFAB1B600D2C5FC /* AttendiWidgetExtension */;
};
@@ -0,0 +1,75 @@
{
"sourceLanguage" : "en",
"strings" : {
"Configuration" : {
},
"Discard" : {
},
"Discard Recording" : {
},
"Favorite Emoji" : {
},
"Favorite Emoji:" : {
},
"Pause" : {
},
"Pause or Resume Recording" : {
},
"Pauses or resumes the ongoing recording." : {
},
"Resume" : {
},
"This is an example widget." : {
},
"Throws the paused recording away." : {
},
"Time:" : {
},
"view.recording-state-label.state.paused" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recording is **paused**"
}
}
}
},
"view.recording-state-label.state.processing" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recording is being **processed**"
}
}
}
},
"view.recording-state-label.state.recording" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recording is **underway**"
}
}
}
}
},
"version" : "1.2"
}
@@ -1,9 +1,15 @@
import WidgetKit
import SwiftUI
/// The bundle exposing the app's widgets and Live Activities to the system: the ``AttendiWidget`` Home Screen widget and the
/// ``RecordingLiveActivity`` that surfaces the recording flow.
@main
struct AttendiWidgetBundle: WidgetBundle {
/// The widgets and Live Activities the bundle exposes.
var body: some Widget {
AttendiWidget()
RecordingLiveActivity()
}
}
@@ -0,0 +1,15 @@
import SwiftUI
extension Image {
/// The system symbol constants.
enum Symbol {
/// The symbol of the discard buttons.
static let discard = Image(systemName: "trash.fill")
/// The symbol marking the recording flow: the icon of the state label while recording, and of the resume button.
static let microphone = Image(systemName: "mic.fill")
/// The symbol of the pause button, and the icon of the state label while paused.
static let pause = Image(systemName: "pause.fill")
/// The symbol of the send button, and the icon of the state label while processing.
static let send = Image(systemName: "paperplane.fill")
}
}
@@ -1,11 +1,18 @@
import WidgetKit
import AppIntents
/// The configuration intent of the ``AttendiWidget`` placeholder, as scaffolded by the Xcode widget template.
struct ConfigurationAppIntent: WidgetConfigurationIntent {
// MARK: Constants
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
// MARK: Properties
/// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}
@@ -0,0 +1,29 @@
import Commanding
import AppIntents
/// The intent behind the discard button of the recording Live Activity, which throws the recording away.
///
/// The intent is compiled into both the app and the widget extension: the widget references it from the activity's buttons, and the
/// system executes it in the app's process where it sends ``RecordingCommand/discard`` through the process-wide
/// ``RecordingCommander``, mirroring a press of the feature's discard button.
struct DiscardRecordingIntent: LiveActivityIntent {
// MARK: Constants
static let title: LocalizedStringResource = "Discard Recording"
static let description = IntentDescription("Throws the paused recording away.")
static let openAppWhenRun = false
// MARK: Methods
/// Sends the discard command to the recording flow.
///
/// - Returns: An empty result, since the discard ends the Live Activity itself.
@MainActor
func perform() async throws -> some IntentResult {
RecordingCommander.shared.send(.discard)
return .result()
}
}
@@ -0,0 +1,29 @@
import Commanding
import AppIntents
/// The intent behind the send button of the recording Live Activity, which sends the paused recording for processing.
///
/// The intent is compiled into both the app and the widget extension: the widget references it from the activity's buttons, and the
/// system executes it in the app's process where it sends ``RecordingCommand/process`` through the process-wide
/// ``RecordingCommander``, mirroring a press of the feature's send button.
struct ProcessRecordingIntent: LiveActivityIntent {
// MARK: Constants
static let title: LocalizedStringResource = "Process Recording"
static let description = IntentDescription("Sends the paused recording for processing.")
static let openAppWhenRun = false
// MARK: Methods
/// Sends the process command to the recording flow.
///
/// - Returns: An empty result, since the outcome surfaces through the Live Activity's own content update.
@MainActor
func perform() async throws -> some IntentResult {
RecordingCommander.shared.send(.process)
return .result()
}
}
@@ -0,0 +1,29 @@
import Commanding
import AppIntents
/// The intent behind the main button of the recording Live Activity, which pauses or resumes the recording.
///
/// The intent is compiled into both the app and the widget extension: the widget references it from the activity's buttons, and the
/// system executes it in the app's process where it sends ``RecordingCommand/toggle`` through the process-wide
/// ``RecordingCommander``, mirroring a press of the feature's main button.
struct ToggleRecordingIntent: LiveActivityIntent {
// MARK: Constants
static let title: LocalizedStringResource = "Pause or Resume Recording"
static let description = IntentDescription("Pauses or resumes the ongoing recording.")
static let openAppWhenRun = false
// MARK: Methods
/// Sends the toggle command to the recording flow.
///
/// - Returns: An empty result, since the outcome surfaces through the Live Activity's own content update.
@MainActor
func perform() async throws -> some IntentResult {
RecordingCommander.shared.send(.toggle)
return .result()
}
}
@@ -0,0 +1,115 @@
import Commanding
import SwiftUI
import WidgetKit
/// The style of the action buttons of the recording Live Activity: it renders a button's label as a fixed-size icon on a circle
/// colored by the ``ActionType`` given at initialization.
struct ActionButtonStyle: PrimitiveButtonStyle {
// MARK: Properties
/// The type of the action behind the styled button, deciding the color of the circular background.
private let action: ActionType
// MARK: Initializers
/// Creates a style for a button behind the given type of action.
///
/// - Parameter action: The type of the action behind the styled button.
fileprivate init(
_ action: ActionType
) {
self.action = action
}
// MARK: Methods
/// Builds the styled button: its label as a fixed-size, padded icon on a circle colored by the type of the action.
///
/// - Parameter configuration: The properties of the button to style.
/// - Returns: The styled button.
func makeBody(
configuration: Configuration
) -> some View {
configuration.label
.scaledToFit()
.frame(
width: Constant.Size.icon.width,
height: Constant.Size.icon.height
)
.padding(Constant.Padding.icon)
.foregroundStyle(.windowBackground)
.background {
Circle()
.foregroundStyle(backgroundColor)
}
}
}
// MARK: - Helpers
private extension ActionButtonStyle {
/// The color of the circular background, matching the type of the action behind the button.
var backgroundColor: Color {
switch action {
case .confirmative: .green
case let .custom(color): color
case .default: .yellow
case .destructive: .red
}
}
}
// MARK: - Enumerations
extension ActionButtonStyle {
/// The types of action a styled button can stand behind, each coloring the button's background differently.
enum ActionType {
/// An action that confirms or submits, colored green.
case confirmative
/// An action colored by the given custom color.
case custom(_ color: Color)
/// A regular action, colored yellow.
case `default`
/// An action that throws something away, colored red.
case destructive
}
}
// MARK: - Constants
/// The constant values used across the action button style.
private enum Constant {
/// The padding constants.
enum Padding {
/// The padding around the button's icon.
static let icon: CGFloat = 16
}
/// The size constants.
enum Size {
/// The size of the button's icon.
static let icon: CGSize = .init(
width: 24,
height: 24
)
}
}
// MARK: - Styles
extension PrimitiveButtonStyle where Self == ActionButtonStyle {
/// An action button style for a button behind the given type of action.
///
/// - Parameter actionType: The type of the action behind the styled button.
/// - Returns: The action button style colored for the given type.
static func action(
type actionType: ActionButtonStyle.ActionType
) -> ActionButtonStyle {
.init(actionType)
}
}
@@ -0,0 +1,108 @@
import AppIntents
import Commanding
import Recording
import SwiftUI
import WidgetKit
/// The buttons controlling the recording flow through the App Intents the app executes, matching the current state: a pause
/// button while recording; discard, resume, and send buttons while paused; and only a discard button while the recorded input
/// is being processed.
struct RecordingControls: View {
// MARK: Properties
/// The dynamic content of the Live Activity to render.
let state: RecordingActivityAttributes.ContentState
// MARK: Body
var body: some View {
HStack(
spacing: Constant.Spacing.stack
) {
switch state.state {
case .recording:
Button(intent: ToggleRecordingIntent()) {
Image.Symbol.pause
.resizable()
}
.buttonStyle(.action(
type: .default
))
case .paused:
Button(intent: DiscardRecordingIntent()) {
Image.Symbol.discard
.resizable()
}
.buttonStyle(.action(
type: .destructive
))
Button(intent: ToggleRecordingIntent()) {
Image.Symbol.microphone
.resizable()
}
.buttonStyle(.action(
type: .default
))
Button(intent: ProcessRecordingIntent()) {
Image.Symbol.send
.resizable()
}
.buttonStyle(.action(
type: .confirmative
))
case .processing:
Button(intent: DiscardRecordingIntent()) {
Image.Symbol.discard
.resizable()
}
.buttonStyle(.action(
type: .destructive
))
}
}
.buttonStyle(.borderedProminent)
}
}
// MARK: - Constants
/// The constant values used across the recording Live Activity.
private enum Constant {
/// The spacing constants.
enum Spacing {
/// The spacing between the elements of a stack.
static let stack: CGFloat = 16
}
}
// MARK: - Previews
#Preview(
"Recording Controls view",
as: .content,
using: RecordingActivityAttributes()
) {
RecordingLiveActivity()
} contentStates: {
RecordingActivityAttributes.ContentState(
state: .recording,
anchor: .now,
elapsed: 0
)
RecordingActivityAttributes.ContentState(
state: .paused,
anchor: nil,
elapsed: 42
)
RecordingActivityAttributes.ContentState(
state: .processing,
anchor: nil,
elapsed: 42
)
}
@@ -0,0 +1,93 @@
import Commanding
import SwiftUI
import WidgetKit
/// The Lock Screen banner of the recording Live Activity: a centered stack of the timer of the elapsed recording time, the label
/// naming the current state, and the control buttons.
struct RecordingLiveActivityView: View {
// MARK: Properties
/// The dynamic content of the Live Activity to render.
let state: RecordingActivityAttributes.ContentState
// MARK: Body
var body: some View {
VStack(
alignment: .center,
spacing: Constant.Spacing.stack
) {
RecordingTimerText(state: state)
.font(.largeTitle)
.fontWeight(.bold)
.monospacedDigit()
RecordingStateLabel(state: state)
RecordingControls(state: state)
}
}
}
// MARK: - Constants
/// The constant values used across the recording Live Activity.
private enum Constant {
/// The size constants.
enum Size {
/// The maximum width of the timer in the compact trailing presentation of the Dynamic Island.
static let compactTimer: CGFloat = 44
}
/// The spacing constants.
enum Spacing {
/// The spacing between the elements of a stack.
static let stack: CGFloat = 8
}
/// The system symbol constants.
enum Symbol {
/// The symbol of the discard button.
static let discard = "trash"
/// The symbol marking the recording flow.
static let microphone = "mic.fill"
/// The symbol of the main button while recording.
static let pause = "pause.fill"
/// The symbol of the main button while paused.
static let record = "record.circle"
}
/// The time constants.
enum Time {
/// The span of the running timer, comfortably beyond the maximum lifetime of a Live Activity.
static let span: TimeInterval = 24 * 60 * 60
}
}
// MARK: - Previews
#Preview(
"Recording Live Activity view",
as: .content,
using: RecordingActivityAttributes()
) {
RecordingLiveActivity()
} contentStates: {
RecordingActivityAttributes.ContentState(
state: .recording,
anchor: .now,
elapsed: 0
)
RecordingActivityAttributes.ContentState(
state: .paused,
anchor: nil,
elapsed: 42
)
RecordingActivityAttributes.ContentState(
state: .processing,
anchor: nil,
elapsed: 42
)
}
@@ -0,0 +1,68 @@
import Commanding
import SwiftUI
import WidgetKit
/// The label naming the current state of the recording flow, next to an icon matching that state: a microphone while recording,
/// a pause symbol while paused, and a send symbol while the recorded input is being processed.
struct RecordingStateLabel: View {
// MARK: Properties
/// The dynamic content of the Live Activity to render.
let state: RecordingActivityAttributes.ContentState
// MARK: Body
var body: some View {
Label {
switch state.state {
case .recording:
Text(.viewRecordingStateLabelStateRecording)
case .paused:
Text(.viewRecordingStateLabelStatePaused)
case .processing:
Text(.viewRecordingStateLabelStateProcessing)
}
} icon: {
switch state.state {
case .recording:
Image.Symbol.microphone
.foregroundStyle(.yellow)
case .paused:
Image.Symbol.pause
.foregroundStyle(.yellow)
case .processing:
Image.Symbol.send
.foregroundStyle(.green)
}
}
.font(.body)
}
}
// MARK: - Previews
#Preview(
"Recording State label",
as: .dynamicIsland(.expanded),
using: RecordingActivityAttributes()
) {
RecordingLiveActivity()
} contentStates: {
RecordingActivityAttributes.ContentState(
state: .recording,
anchor: .now,
elapsed: 0
)
RecordingActivityAttributes.ContentState(
state: .paused,
anchor: nil,
elapsed: 325
)
RecordingActivityAttributes.ContentState(
state: .processing,
anchor: nil,
elapsed: 325
)
}
@@ -0,0 +1,72 @@
import Commanding
import SwiftUI
import WidgetKit
/// The timer of the elapsed recording time, formatted as `mm:ss`: counted up by the system from the content state's anchor when
/// one is given, and frozen at the reported elapsed time otherwise.
struct RecordingTimerText: View {
// MARK: Properties
/// The dynamic content of the Live Activity to render.
let state: RecordingActivityAttributes.ContentState
// MARK: Body
var body: some View {
if let anchor = state.anchor {
Text(
.durationOffset(to: anchor),
format: timerFormat
)
.multilineTextAlignment(.center)
}
else {
Text(
Duration
.seconds(state.elapsed)
.formatted(timerFormat)
)
}
}
}
// MARK: - Helpers
private extension RecordingTimerText {
// MARK: Properties
/// The format of the timer: minutes and seconds, as `mm:ss`.
var timerFormat: Duration.TimeFormatStyle {
.time(pattern: .minuteSecond(padMinuteToLength: 2))
}
}
// MARK: - Previews
#Preview(
"Recording Timer text",
as: .dynamicIsland(.compact),
using: RecordingActivityAttributes()
) {
RecordingLiveActivity()
} contentStates: {
RecordingActivityAttributes.ContentState(
state: .recording,
anchor: .now,
elapsed: 0
)
RecordingActivityAttributes.ContentState(
state: .paused,
anchor: nil,
elapsed: 325
)
RecordingActivityAttributes.ContentState(
state: .processing,
anchor: nil,
elapsed: 325
)
}
@@ -1,15 +1,20 @@
import WidgetKit
import SwiftUI
/// The timeline provider of the ``AttendiWidget`` placeholder, producing entries from the widget's configuration intent.
struct Provider: AppIntentTimelineProvider {
/// Returns a generic entry the system shows while the widget loads.
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
/// Returns a single entry for the given configuration, shown in transient contexts like the widget gallery.
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: Date(), configuration: configuration)
}
/// Returns a timeline of five entries an hour apart, starting from the current date.
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
@@ -29,12 +34,17 @@ struct Provider: AppIntentTimelineProvider {
// }
}
/// A timeline entry of the ``AttendiWidget`` placeholder.
struct SimpleEntry: TimelineEntry {
/// The instant the entry applies to.
let date: Date
/// The configuration of the widget at the time of the entry.
let configuration: ConfigurationAppIntent
}
/// The view rendering a timeline entry of the ``AttendiWidget`` placeholder: the entry's time and the configured emoji.
struct AttendiWidgetEntryView : View {
/// The timeline entry to render.
var entry: Provider.Entry
var body: some View {
@@ -46,7 +56,9 @@ struct AttendiWidgetEntryView : View {
}
}
/// The placeholder Home Screen widget scaffolded by the Xcode widget template, yet to be replaced by the app's own widget.
struct AttendiWidget: Widget {
/// The identifier of the widget's kind.
let kind: String = "AttendiWidget"
var body: some WidgetConfiguration {
@@ -0,0 +1,85 @@
import ActivityKit
import Commanding
import AppIntents
import SwiftUI
import WidgetKit
/// The Live Activity that surfaces the recording flow on the Lock Screen and in the Dynamic Island.
///
/// The activity shows the current state of the flow next to a timer of the elapsed recording time counted up by the system itself
/// from the anchor in the content state while recording, and frozen at the reported elapsed time while paused or processing along
/// with buttons that pause, resume, send, or discard the recording through the App Intents the app executes.
struct RecordingLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(
for: RecordingActivityAttributes.self
) { context in
RecordingLiveActivityView(state: context.state)
.padding()
// A nil tint keeps the system's default background material Liquid Glass instead of a flat color.
.activityBackgroundTint(nil)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
RecordingStateLabel(state: context.state)
}
DynamicIslandExpandedRegion(.trailing) {
RecordingTimerText(state: context.state)
.font(.title2)
.fontWeight(.bold)
.monospacedDigit()
}
DynamicIslandExpandedRegion(.bottom) {
RecordingControls(state: context.state)
}
} compactLeading: {
Image(systemName: Constant.Symbol.microphone)
.foregroundStyle(.red)
} compactTrailing: {
RecordingTimerText(state: context.state)
.monospacedDigit()
.frame(maxWidth: Constant.Size.compactTimer)
} minimal: {
Image(systemName: Constant.Symbol.microphone)
.foregroundStyle(.red)
}
}
}
}
// MARK: - Constants
/// The constant values used across the recording Live Activity.
private enum Constant {
/// The size constants.
enum Size {
/// The maximum width of the timer in the compact trailing presentation of the Dynamic Island.
static let compactTimer: CGFloat = 44
}
/// The spacing constants.
enum Spacing {
/// The spacing between the elements of a stack.
static let stack: CGFloat = 8
}
/// The system symbol constants.
enum Symbol {
/// The symbol of the discard button.
static let discard = "trash"
/// The symbol marking the recording flow.
static let microphone = "mic.fill"
/// The symbol of the main button while recording.
static let pause = "pause.fill"
/// The symbol of the main button while paused.
static let record = "record.circle"
}
/// The time constants.
enum Time {
/// The span of the running timer, comfortably beyond the maximum lifetime of a Live Activity.
static let span: TimeInterval = 24 * 60 * 60
}
}