diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index f25a7b0..ac55a75 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -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 */; }; diff --git a/Widgets/Attendi/Catalogs/Localizable.xcstrings b/Widgets/Attendi/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..17ce524 --- /dev/null +++ b/Widgets/Attendi/Catalogs/Localizable.xcstrings @@ -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" +} \ No newline at end of file diff --git a/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift b/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift index b31ab27..a6e3a73 100644 --- a/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift +++ b/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift @@ -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() } + } diff --git a/Widgets/Attendi/Sources/Extensions/Image+Symbols.swift b/Widgets/Attendi/Sources/Extensions/Image+Symbols.swift new file mode 100644 index 0000000..0069729 --- /dev/null +++ b/Widgets/Attendi/Sources/Extensions/Image+Symbols.swift @@ -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") + } +} diff --git a/Widgets/Attendi/Sources/Intents/AppIntent.swift b/Widgets/Attendi/Sources/Intents/AppIntent.swift index 35f1b88..35556f4 100644 --- a/Widgets/Attendi/Sources/Intents/AppIntent.swift +++ b/Widgets/Attendi/Sources/Intents/AppIntent.swift @@ -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 + } diff --git a/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift new file mode 100644 index 0000000..456da78 --- /dev/null +++ b/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift @@ -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() + } + +} diff --git a/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift new file mode 100644 index 0000000..aa6e0a6 --- /dev/null +++ b/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift @@ -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() + } + +} diff --git a/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift new file mode 100644 index 0000000..3df8caa --- /dev/null +++ b/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift @@ -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() + } + +} diff --git a/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift b/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift new file mode 100644 index 0000000..97f3d54 --- /dev/null +++ b/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift @@ -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) + } + +} diff --git a/Widgets/Attendi/Sources/Views/RecordingControls.swift b/Widgets/Attendi/Sources/Views/RecordingControls.swift new file mode 100644 index 0000000..417b97e --- /dev/null +++ b/Widgets/Attendi/Sources/Views/RecordingControls.swift @@ -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 + ) +} diff --git a/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift b/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift new file mode 100644 index 0000000..ac90e97 --- /dev/null +++ b/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift @@ -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 + ) +} diff --git a/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift b/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift new file mode 100644 index 0000000..6bb524a --- /dev/null +++ b/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift @@ -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 + ) +} diff --git a/Widgets/Attendi/Sources/Views/RecordingTimerText.swift b/Widgets/Attendi/Sources/Views/RecordingTimerText.swift new file mode 100644 index 0000000..9471420 --- /dev/null +++ b/Widgets/Attendi/Sources/Views/RecordingTimerText.swift @@ -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 + ) +} diff --git a/Widgets/Attendi/Widgets/AttendiWidget.swift b/Widgets/Attendi/Sources/Widgets/AttendiWidget.swift similarity index 73% rename from Widgets/Attendi/Widgets/AttendiWidget.swift rename to Widgets/Attendi/Sources/Widgets/AttendiWidget.swift index b2a9282..766ffb0 100644 --- a/Widgets/Attendi/Widgets/AttendiWidget.swift +++ b/Widgets/Attendi/Sources/Widgets/AttendiWidget.swift @@ -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 { 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 { diff --git a/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift b/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift new file mode 100644 index 0000000..b7c0121 --- /dev/null +++ b/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift @@ -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 + } +}