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
@@ -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
)
}