diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index 59210f0..84f6c45 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ Attendi/Catalogs/Localizable.xcstrings, Attendi/Sources/Bundle/AttendiWidgetBundle.swift, "Attendi/Sources/Extensions/Image+Symbols.swift", + "Attendi/Sources/Extensions/RecordingActivityState+Presentation.swift", Attendi/Sources/Intents/DiscardRecordingIntent.swift, Attendi/Sources/Intents/ProcessRecordingIntent.swift, Attendi/Sources/Intents/ToggleRecordingIntent.swift, diff --git a/Widgets/Attendi/Catalogs/Localizable.xcstrings b/Widgets/Attendi/Catalogs/Localizable.xcstrings index 17ce524..7728947 100644 --- a/Widgets/Attendi/Catalogs/Localizable.xcstrings +++ b/Widgets/Attendi/Catalogs/Localizable.xcstrings @@ -1,43 +1,124 @@ { "sourceLanguage" : "en", "strings" : { - "Configuration" : { - + "intent.discard-recording.description" : { + "comment" : "Description of the Discard Recording App Intent, behind the recording Live Activity's discard button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Throws the paused recording away." + } + } + } }, - "Discard" : { - + "intent.discard-recording.title" : { + "comment" : "Title of the Discard Recording App Intent, behind the recording Live Activity's discard button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard Recording" + } + } + } }, - "Discard Recording" : { - + "intent.processing-recording.description" : { + "comment" : "Description of the Process Recording App Intent, behind the recording Live Activity's send button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sends the paused recording for processing." + } + } + } }, - "Favorite Emoji" : { - + "intent.processing-recording.title" : { + "comment" : "Title of the Process Recording App Intent, behind the recording Live Activity's send button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Process Recording" + } + } + } }, - "Favorite Emoji:" : { - + "intent.toggle-recording.description" : { + "comment" : "Description of the Pause or Resume Recording App Intent, behind the recording Live Activity's main button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pauses or resumes the ongoing recording." + } + } + } }, - "Pause" : { - + "intent.toggle-recording.title" : { + "comment" : "Title of the Pause or Resume Recording App Intent, behind the recording Live Activity's main button. Shown wherever the system lists the intent, such as Shortcuts and Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause or Resume Recording" + } + } + } }, - "Pause or Resume Recording" : { - + "view.recording-controls.button.discard" : { + "comment" : "Accessibility label for the recording Live Activity's discard button, announced by VoiceOver. The button shows only a trash icon.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard recording" + } + } + } }, - "Pauses or resumes the ongoing recording." : { - + "view.recording-controls.button.pause" : { + "comment" : "Accessibility label for the recording Live Activity's pause button, announced by VoiceOver. The button shows only a pause icon.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause recording" + } + } + } }, - "Resume" : { - + "view.recording-controls.button.resume" : { + "comment" : "Accessibility label for the recording Live Activity's resume button, announced by VoiceOver. The button shows only a microphone icon.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume recording" + } + } + } }, - "This is an example widget." : { - - }, - "Throws the paused recording away." : { - - }, - "Time:" : { - + "view.recording-controls.button.send" : { + "comment" : "Accessibility label for the recording Live Activity's send button, which sends the recording for processing. Announced by VoiceOver; the button shows only a send icon.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send recording for processing" + } + } + } }, "view.recording-state-label.state.paused" : { + "comment" : "Status shown in the recording Live Activity while recording is paused.", "extractionState" : "manual", "localizations" : { "en" : { @@ -49,6 +130,7 @@ } }, "view.recording-state-label.state.processing" : { + "comment" : "Status shown in the recording Live Activity while the recorded audio is being processed.", "extractionState" : "manual", "localizations" : { "en" : { @@ -60,6 +142,7 @@ } }, "view.recording-state-label.state.recording" : { + "comment" : "Status shown in the recording Live Activity while recording is underway. ", "extractionState" : "manual", "localizations" : { "en" : { diff --git a/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift b/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift index 34dc8a7..9bf86e7 100644 --- a/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift +++ b/Widgets/Attendi/Sources/Bundle/AttendiWidgetBundle.swift @@ -1,12 +1,12 @@ -import WidgetKit import SwiftUI +import WidgetKit -/// 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. +/// The bundle exposing the app's Live Activities to the system: the ``RecordingLiveActivity`` that surfaces the recording flow on the +/// Lock Screen and in the Dynamic Island. @main struct AttendiWidgetBundle: WidgetBundle { - /// The widgets and Live Activities the bundle exposes. + /// The Live Activities the bundle exposes. var body: some Widget { RecordingLiveActivity() } diff --git a/Widgets/Attendi/Sources/Extensions/RecordingActivityState+Presentation.swift b/Widgets/Attendi/Sources/Extensions/RecordingActivityState+Presentation.swift new file mode 100644 index 0000000..ec9702c --- /dev/null +++ b/Widgets/Attendi/Sources/Extensions/RecordingActivityState+Presentation.swift @@ -0,0 +1,27 @@ +import Commanding +import SwiftUI + +extension RecordingActivityState { + + // MARK: Computed + + /// The system symbol marking the state in the Live Activity: a microphone while recording, a pause symbol while paused, and + /// a send symbol while the recorded input is being processed. + var symbol: Image { + switch self { + case .recording: Image.Symbol.microphone + case .paused: Image.Symbol.pause + case .processing: Image.Symbol.send + } + } + + /// The tint coloring the state's ``symbol``: yellow while recording or paused, and green while the recorded input is being + /// processed. + var tint: Color { + switch self { + case .recording, .paused: .yellow + case .processing: .green + } + } + +} diff --git a/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift index 456da78..ab2848f 100644 --- a/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift +++ b/Widgets/Attendi/Sources/Intents/DiscardRecordingIntent.swift @@ -1,5 +1,5 @@ -import Commanding import AppIntents +import Commanding /// The intent behind the discard button of the recording Live Activity, which throws the recording away. /// @@ -10,9 +10,14 @@ struct DiscardRecordingIntent: LiveActivityIntent { // MARK: Constants - static let title: LocalizedStringResource = "Discard Recording" - static let description = IntentDescription("Throws the paused recording away.") - static let openAppWhenRun = false + /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces + /// the intent. + static let title: LocalizedStringResource = "intent.discard-recording.title" + /// The localized description of the intent, resolved from the widget target's string catalog. + static let description = IntentDescription("intent.discard-recording.description") + /// Whether running the intent brings the app to the foreground; `true`, so discarding the recording opens the app from the + /// Live Activity. + static let openAppWhenRun = true // MARK: Methods diff --git a/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift index aa6e0a6..c7a7b44 100644 --- a/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift +++ b/Widgets/Attendi/Sources/Intents/ProcessRecordingIntent.swift @@ -1,5 +1,5 @@ -import Commanding import AppIntents +import Commanding /// The intent behind the send button of the recording Live Activity, which sends the paused recording for processing. /// @@ -10,9 +10,14 @@ 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 + /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces + /// the intent. + static let title: LocalizedStringResource = "intent.processing-recording.title" + /// The localized description of the intent, resolved from the widget target's string catalog. + static let description = IntentDescription("intent.processing-recording.description") + /// Whether running the intent brings the app to the foreground; `true`, so sending the recording for processing opens the app + /// from the Live Activity. + static let openAppWhenRun = true // MARK: Methods diff --git a/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift b/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift index 3df8caa..6186fe3 100644 --- a/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift +++ b/Widgets/Attendi/Sources/Intents/ToggleRecordingIntent.swift @@ -1,5 +1,5 @@ -import Commanding import AppIntents +import Commanding /// The intent behind the main button of the recording Live Activity, which pauses or resumes the recording. /// @@ -10,8 +10,13 @@ struct ToggleRecordingIntent: LiveActivityIntent { // MARK: Constants - static let title: LocalizedStringResource = "Pause or Resume Recording" - static let description = IntentDescription("Pauses or resumes the ongoing recording.") + /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces + /// the intent. + static let title: LocalizedStringResource = "intent.toggle-recording.title" + /// The localized description of the intent, resolved from the widget target's string catalog. + static let description = IntentDescription("intent.toggle-recording.description") + /// Whether running the intent brings the app to the foreground; kept `false` so the button acts straight from the Live Activity, + /// in the app's background process, without opening the app. static let openAppWhenRun = false // MARK: Methods diff --git a/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift b/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift index 5ea8577..a1b5bd5 100644 --- a/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift +++ b/Widgets/Attendi/Sources/Styles/Buttons/ActionButtonStyle.swift @@ -59,7 +59,7 @@ private extension ActionButtonStyle { var backgroundColor: Color { switch action { case .confirmative: .green - case let .custom(color): color + case .custom(let color): color case .default: .yellow case .destructive: .red } diff --git a/Widgets/Attendi/Sources/Views/RecordingControls.swift b/Widgets/Attendi/Sources/Views/RecordingControls.swift index 417b97e..598caba 100644 --- a/Widgets/Attendi/Sources/Views/RecordingControls.swift +++ b/Widgets/Attendi/Sources/Views/RecordingControls.swift @@ -8,66 +8,89 @@ import WidgetKit /// 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 - )) + pauseButton 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 - )) + discardButton + recordButton + processButton case .processing: - Button(intent: DiscardRecordingIntent()) { - Image.Symbol.discard - .resizable() - } - .buttonStyle(.action( - type: .destructive - )) + discardButton } } - .buttonStyle(.borderedProminent) } - + +} + +// MARK: - Subviews + +private extension RecordingControls { + + // MARK: Computed + + /// The discard button, shown while the recording is paused or being processed, that throws the recording away. + var discardButton: some View { + Button(intent: DiscardRecordingIntent()) { + Image.Symbol.discard + .resizable() + } + .buttonStyle(.action( + type: .destructive + )) + .accessibilityLabel(Text("view.recording-controls.button.discard")) + } + + /// The pause button, shown while recording, that pauses the ongoing recording. + var pauseButton: some View { + Button(intent: ToggleRecordingIntent()) { + Image.Symbol.pause + .resizable() + } + .buttonStyle(.action( + type: .default + )) + .accessibilityLabel(Text("view.recording-controls.button.pause")) + } + + /// The send button, shown while the recording is paused, that sends it for processing. + var processButton: some View { + Button(intent: ProcessRecordingIntent()) { + Image.Symbol.send + .resizable() + } + .buttonStyle(.action( + type: .confirmative + )) + .accessibilityLabel(Text("view.recording-controls.button.send")) + } + + /// The resume button, shown while the recording is paused, that resumes it. + var recordButton: some View { + Button(intent: ToggleRecordingIntent()) { + Image.Symbol.microphone + .resizable() + } + .buttonStyle(.action( + type: .default + )) + .accessibilityLabel(Text("view.recording-controls.button.resume")) + } + } // MARK: - Constants diff --git a/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift b/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift index f6bb9df..6871666 100644 --- a/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift +++ b/Widgets/Attendi/Sources/Views/RecordingLiveActivityView.swift @@ -5,21 +5,21 @@ 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 ) { RecordingStateLabel(state: state) - + RecordingTimerText(state: state) .font(.largeTitle) .fontWeight(.bold) @@ -27,7 +27,7 @@ struct RecordingLiveActivityView: View { RecordingControls(state: state) } } - + } // MARK: - Constants diff --git a/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift b/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift index 6bb524a..8a8720b 100644 --- a/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift +++ b/Widgets/Attendi/Sources/Views/RecordingStateLabel.swift @@ -5,14 +5,14 @@ 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 { @@ -24,21 +24,12 @@ struct RecordingStateLabel: View { 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) - } + state.state.symbol + .foregroundStyle(state.state.tint) } .font(.body) } - + } // MARK: - Previews diff --git a/Widgets/Attendi/Sources/Views/RecordingTimerText.swift b/Widgets/Attendi/Sources/Views/RecordingTimerText.swift index 9471420..fdb0c6d 100644 --- a/Widgets/Attendi/Sources/Views/RecordingTimerText.swift +++ b/Widgets/Attendi/Sources/Views/RecordingTimerText.swift @@ -5,14 +5,14 @@ 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( @@ -29,20 +29,20 @@ struct RecordingTimerText: View { ) } } - + } // 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 diff --git a/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift b/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift index b05ba0a..f35327b 100644 --- a/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift +++ b/Widgets/Attendi/Sources/Widgets/RecordingLiveActivity.swift @@ -1,6 +1,6 @@ import ActivityKit -import Commanding import AppIntents +import Commanding import SwiftUI import WidgetKit @@ -37,40 +37,42 @@ struct RecordingLiveActivity: Widget { ) } } compactLeading: { - switch context.state.state { - case .paused: - Image.Symbol.pause - .foregroundStyle(.yellow) - case .processing: - Image.Symbol.send - .foregroundStyle(.green) - case .recording: - Image.Symbol.microphone - .foregroundStyle(.yellow) - } + image( + for: context.state.state + ) } compactTrailing: { RecordingTimerText( state: context.state ) - .frame(maxWidth: Constant.Size.compactTimer) + .frame( + maxWidth: Constant.Size.compactTimer + ) } minimal: { - switch context.state.state { - case .paused: - Image.Symbol.pause - .foregroundStyle(.yellow) - case .processing: - Image.Symbol.send - .foregroundStyle(.green) - case .recording: - Image.Symbol.microphone - .foregroundStyle(.yellow) - } + image( + for: context.state.state + ) } } } } +// MARK: - Helpers + +private extension RecordingLiveActivity { + + // MARK: Methods + + /// The state's symbol, tinted to match, shown in the compact and minimal presentations of the Dynamic Island. + func image( + for state: RecordingActivityState + ) -> some View { + state.symbol + .foregroundStyle(state.tint) + } + +} + // MARK: - Constants /// The constant values used across the recording Live Activity. @@ -80,28 +82,4 @@ private enum Constant { /// 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 - } }