Implemented the (first version of the) Recording Live Activity in the Attendi widget target.
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
+13
-1
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user