Refined the recording Live Activity presentation in the Widget target.

This commit is contained in:
2026-07-07 00:11:39 +02:00
parent 9e431c8e7a
commit dfd4829980
13 changed files with 281 additions and 163 deletions
+1
View File
@@ -92,6 +92,7 @@
Attendi/Catalogs/Localizable.xcstrings, Attendi/Catalogs/Localizable.xcstrings,
Attendi/Sources/Bundle/AttendiWidgetBundle.swift, Attendi/Sources/Bundle/AttendiWidgetBundle.swift,
"Attendi/Sources/Extensions/Image+Symbols.swift", "Attendi/Sources/Extensions/Image+Symbols.swift",
"Attendi/Sources/Extensions/RecordingActivityState+Presentation.swift",
Attendi/Sources/Intents/DiscardRecordingIntent.swift, Attendi/Sources/Intents/DiscardRecordingIntent.swift,
Attendi/Sources/Intents/ProcessRecordingIntent.swift, Attendi/Sources/Intents/ProcessRecordingIntent.swift,
Attendi/Sources/Intents/ToggleRecordingIntent.swift, Attendi/Sources/Intents/ToggleRecordingIntent.swift,
+109 -26
View File
@@ -1,43 +1,124 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "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." : { "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",
"Throws the paused recording away." : { "localizations" : {
"en" : {
}, "stringUnit" : {
"Time:" : { "state" : "translated",
"value" : "Send recording for processing"
}
}
}
}, },
"view.recording-state-label.state.paused" : { "view.recording-state-label.state.paused" : {
"comment" : "Status shown in the recording Live Activity while recording is paused.",
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -49,6 +130,7 @@
} }
}, },
"view.recording-state-label.state.processing" : { "view.recording-state-label.state.processing" : {
"comment" : "Status shown in the recording Live Activity while the recorded audio is being processed.",
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -60,6 +142,7 @@
} }
}, },
"view.recording-state-label.state.recording" : { "view.recording-state-label.state.recording" : {
"comment" : "Status shown in the recording Live Activity while recording is underway. ",
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -1,12 +1,12 @@
import WidgetKit
import SwiftUI import SwiftUI
import WidgetKit
/// The bundle exposing the app's widgets and Live Activities to the system: the ``AttendiWidget`` Home Screen widget and the /// The bundle exposing the app's Live Activities to the system: the ``RecordingLiveActivity`` that surfaces the recording flow on the
/// ``RecordingLiveActivity`` that surfaces the recording flow. /// Lock Screen and in the Dynamic Island.
@main @main
struct AttendiWidgetBundle: WidgetBundle { struct AttendiWidgetBundle: WidgetBundle {
/// The widgets and Live Activities the bundle exposes. /// The Live Activities the bundle exposes.
var body: some Widget { var body: some Widget {
RecordingLiveActivity() RecordingLiveActivity()
} }
@@ -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
}
}
}
@@ -1,5 +1,5 @@
import Commanding
import AppIntents import AppIntents
import Commanding
/// The intent behind the discard button of the recording Live Activity, which throws the recording away. /// 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 // MARK: Constants
static let title: LocalizedStringResource = "Discard Recording" /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces
static let description = IntentDescription("Throws the paused recording away.") /// the intent.
static let openAppWhenRun = false 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 // MARK: Methods
@@ -1,5 +1,5 @@
import Commanding
import AppIntents import AppIntents
import Commanding
/// The intent behind the send button of the recording Live Activity, which sends the paused recording for processing. /// 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 // MARK: Constants
static let title: LocalizedStringResource = "Process Recording" /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces
static let description = IntentDescription("Sends the paused recording for processing.") /// the intent.
static let openAppWhenRun = false 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 // MARK: Methods
@@ -1,5 +1,5 @@
import Commanding
import AppIntents import AppIntents
import Commanding
/// The intent behind the main button of the recording Live Activity, which pauses or resumes the recording. /// 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 // MARK: Constants
static let title: LocalizedStringResource = "Pause or Resume Recording" /// The localized title of the intent, resolved from the widget target's string catalog and shown wherever the system surfaces
static let description = IntentDescription("Pauses or resumes the ongoing recording.") /// 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 static let openAppWhenRun = false
// MARK: Methods // MARK: Methods
@@ -59,7 +59,7 @@ private extension ActionButtonStyle {
var backgroundColor: Color { var backgroundColor: Color {
switch action { switch action {
case .confirmative: .green case .confirmative: .green
case let .custom(color): color case .custom(let color): color
case .default: .yellow case .default: .yellow
case .destructive: .red case .destructive: .red
} }
@@ -22,6 +22,41 @@ struct RecordingControls: View {
) { ) {
switch state.state { switch state.state {
case .recording: case .recording:
pauseButton
case .paused:
discardButton
recordButton
processButton
case .processing:
discardButton
}
}
}
}
// 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()) { Button(intent: ToggleRecordingIntent()) {
Image.Symbol.pause Image.Symbol.pause
.resizable() .resizable()
@@ -29,24 +64,11 @@ struct RecordingControls: View {
.buttonStyle(.action( .buttonStyle(.action(
type: .default type: .default
)) ))
.accessibilityLabel(Text("view.recording-controls.button.pause"))
case .paused:
Button(intent: DiscardRecordingIntent()) {
Image.Symbol.discard
.resizable()
} }
.buttonStyle(.action(
type: .destructive
))
Button(intent: ToggleRecordingIntent()) {
Image.Symbol.microphone
.resizable()
}
.buttonStyle(.action(
type: .default
))
/// The send button, shown while the recording is paused, that sends it for processing.
var processButton: some View {
Button(intent: ProcessRecordingIntent()) { Button(intent: ProcessRecordingIntent()) {
Image.Symbol.send Image.Symbol.send
.resizable() .resizable()
@@ -54,18 +76,19 @@ struct RecordingControls: View {
.buttonStyle(.action( .buttonStyle(.action(
type: .confirmative type: .confirmative
)) ))
.accessibilityLabel(Text("view.recording-controls.button.send"))
}
case .processing: /// The resume button, shown while the recording is paused, that resumes it.
Button(intent: DiscardRecordingIntent()) { var recordButton: some View {
Image.Symbol.discard Button(intent: ToggleRecordingIntent()) {
Image.Symbol.microphone
.resizable() .resizable()
} }
.buttonStyle(.action( .buttonStyle(.action(
type: .destructive type: .default
)) ))
} .accessibilityLabel(Text("view.recording-controls.button.resume"))
}
.buttonStyle(.borderedProminent)
} }
} }
@@ -24,17 +24,8 @@ struct RecordingStateLabel: View {
Text(.viewRecordingStateLabelStateProcessing) Text(.viewRecordingStateLabelStateProcessing)
} }
} icon: { } icon: {
switch state.state { state.state.symbol
case .recording: .foregroundStyle(state.state.tint)
Image.Symbol.microphone
.foregroundStyle(.yellow)
case .paused:
Image.Symbol.pause
.foregroundStyle(.yellow)
case .processing:
Image.Symbol.send
.foregroundStyle(.green)
}
} }
.font(.body) .font(.body)
} }
@@ -1,6 +1,6 @@
import ActivityKit import ActivityKit
import Commanding
import AppIntents import AppIntents
import Commanding
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
@@ -37,36 +37,38 @@ struct RecordingLiveActivity: Widget {
) )
} }
} compactLeading: { } compactLeading: {
switch context.state.state { image(
case .paused: for: context.state.state
Image.Symbol.pause )
.foregroundStyle(.yellow)
case .processing:
Image.Symbol.send
.foregroundStyle(.green)
case .recording:
Image.Symbol.microphone
.foregroundStyle(.yellow)
}
} compactTrailing: { } compactTrailing: {
RecordingTimerText( RecordingTimerText(
state: context.state state: context.state
) )
.frame(maxWidth: Constant.Size.compactTimer) .frame(
maxWidth: Constant.Size.compactTimer
)
} minimal: { } minimal: {
switch context.state.state { image(
case .paused: for: context.state.state
Image.Symbol.pause )
.foregroundStyle(.yellow)
case .processing:
Image.Symbol.send
.foregroundStyle(.green)
case .recording:
Image.Symbol.microphone
.foregroundStyle(.yellow)
} }
} }
} }
}
// 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)
} }
} }
@@ -80,28 +82,4 @@ private enum Constant {
/// The maximum width of the timer in the compact trailing presentation of the Dynamic Island. /// The maximum width of the timer in the compact trailing presentation of the Dynamic Island.
static let compactTimer: CGFloat = 44 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
}
} }