feat(flow): Live Activity Dynamic Island branding and mis PrimaryLanguage
- Add OSGKeyboardLiveActivity widget extension (ActivityKit) with branded compactLeading mark in the Dynamic Island during Flow sessions - Wire FlowLiveActivityController into FlowSessionManager lifecycle - Set keyboard PrimaryLanguage to mis in project.yml, Info.plist, and at runtime - Enable NSSupportsLiveActivities on main app and widget extension targets Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
@@ -58,6 +58,8 @@
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// FlowLiveActivityController.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Starts and updates the Flow Live Activity so the Dynamic Island shows the
|
||||
// OSGKeyboard brand mark while a voice session is active.
|
||||
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
enum FlowLiveActivityController {
|
||||
private static var currentActivity: Activity<FlowActivityAttributes>?
|
||||
|
||||
/// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
|
||||
static func startSession() {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
debug("Live Activity disabled in Settings")
|
||||
return
|
||||
}
|
||||
|
||||
endStaleActivities()
|
||||
|
||||
guard currentActivity == nil else {
|
||||
update(phase: .idle)
|
||||
return
|
||||
}
|
||||
|
||||
let state = FlowActivityAttributes.ContentState(phase: .idle)
|
||||
let content = ActivityContent(state: state, staleDate: nil)
|
||||
|
||||
do {
|
||||
currentActivity = try Activity.request(
|
||||
attributes: FlowActivityAttributes(),
|
||||
content: content,
|
||||
pushType: nil
|
||||
)
|
||||
debug("Live Activity started")
|
||||
} catch {
|
||||
debug("Live Activity start failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
|
||||
guard let activity = currentActivity else { return }
|
||||
let state = FlowActivityAttributes.ContentState(phase: phase)
|
||||
let content = ActivityContent(state: state, staleDate: nil)
|
||||
Task {
|
||||
await activity.update(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss the island presentation when the Flow session ends.
|
||||
static func endSession() {
|
||||
guard let activity = currentActivity else {
|
||||
endStaleActivities()
|
||||
return
|
||||
}
|
||||
|
||||
currentActivity = nil
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
debug("Live Activity ended")
|
||||
}
|
||||
}
|
||||
|
||||
/// Host relaunch can leave orphan activities; clear them before starting anew.
|
||||
private static func endStaleActivities() {
|
||||
for activity in Activity<FlowActivityAttributes>.activities {
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
currentActivity = nil
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
// (e.g. switching from cloud to local) is honoured.
|
||||
bindSessionASRIfNeeded()
|
||||
scheduleASRWarmup()
|
||||
FlowLiveActivityController.startSession()
|
||||
|
||||
debug("Flow session restored (\(Int(remaining))s remaining)")
|
||||
}
|
||||
@@ -196,6 +197,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
sessionASRWarmedLocaleID = nil
|
||||
FlowSessionBridge.markSessionInactive()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
FlowLiveActivityController.endSession()
|
||||
isActive = false
|
||||
sessionExpiresAt = nil
|
||||
sessionWarning = nil
|
||||
@@ -339,6 +341,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
// while the session was idle.
|
||||
bindSessionASRIfNeeded()
|
||||
scheduleASRWarmup()
|
||||
FlowLiveActivityController.startSession()
|
||||
|
||||
debug("Flow session started (\(Int(duration))s), continuous capture running")
|
||||
}
|
||||
@@ -437,6 +440,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
isUtteranceRecording = true
|
||||
utteranceRecordingStartedAt = Date()
|
||||
FlowLiveActivityController.update(phase: .recording)
|
||||
FlowDiagnostics.log(
|
||||
"beginUtterance engine=\(store.engineMode) " +
|
||||
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||
@@ -489,6 +493,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.setRecordingState(.processing)
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = true
|
||||
FlowLiveActivityController.update(phase: .processing)
|
||||
|
||||
// Do NOT cancel `asrTask` or `asr` — the preview pipeline relies on
|
||||
// the consumer staying alive until `.final` lands (see
|
||||
@@ -517,6 +522,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
chunkWarnings = []
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
debug("utterance aborted")
|
||||
}
|
||||
|
||||
@@ -540,6 +546,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
debug("utterance failed: \(message)")
|
||||
}
|
||||
|
||||
@@ -558,6 +565,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
debug("utterance processing failed: \(message)")
|
||||
}
|
||||
|
||||
@@ -566,6 +574,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
defer {
|
||||
isUtteranceProcessing = false
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
}
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
|
||||
Reference in New Issue
Block a user