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:
Cursor Agent
2026-07-06 05:53:24 +00:00
parent 537a68552a
commit 6489340b2e
11 changed files with 341 additions and 2 deletions
@@ -0,0 +1,22 @@
// FlowActivityAttributes.swift
// OSGKeyboard · Live Activity
//
// Shared ActivityKit model compiled into the widget extension and the
// main app so `FlowLiveActivityController` can start/update sessions.
import ActivityKit
import Foundation
/// Live Activity shown in the Dynamic Island while a Flow session is active.
struct FlowActivityAttributes: ActivityAttributes {
/// Dynamic content updated as the user records and processes speech.
struct ContentState: Codable, Hashable, Sendable {
var phase: Phase
enum Phase: String, Codable, Hashable, Sendable {
case idle
case recording
case processing
}
}
}
@@ -0,0 +1,146 @@
// FlowLiveActivityWidget.swift
// OSGKeyboard · Live Activity
//
// Dynamic Island compact leading shows the OSGKeyboard mark instead of the
// generic system microphone glyph that appears without a Live Activity.
import ActivityKit
import SwiftUI
import WidgetKit
struct FlowLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FlowActivityAttributes.self) { context in
FlowLiveActivityLockScreenView(phase: context.state.phase)
.activityBackgroundTint(Color.black.opacity(0.82))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
FlowLiveActivityBrandMark(size: 28)
}
DynamicIslandExpandedRegion(.trailing) {
FlowLiveActivityPhaseLabel(phase: context.state.phase)
}
DynamicIslandExpandedRegion(.center) {
Text("OSGKeyboard")
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
FlowLiveActivityPhaseCaption(phase: context.state.phase)
.font(.caption)
.foregroundStyle(.secondary)
}
} compactLeading: {
FlowLiveActivityBrandMark(size: 22)
} compactTrailing: {
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
} minimal: {
FlowLiveActivityBrandMark(size: 18)
}
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
}
}
}
// MARK: - Views
private struct FlowLiveActivityLockScreenView: View {
let phase: FlowActivityAttributes.ContentState.Phase
var body: some View {
HStack(spacing: 12) {
FlowLiveActivityBrandMark(size: 32)
VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard")
.font(.headline)
FlowLiveActivityPhaseCaption(phase: phase)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
FlowLiveActivityTrailingGlyph(phase: phase)
}
.padding(.horizontal, 4)
}
}
/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon.
private struct FlowLiveActivityBrandMark: View {
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.35, green: 0.55, blue: 1.0),
Color(red: 0.22, green: 0.38, blue: 0.92)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Image(systemName: "keyboard")
.font(.system(size: size * 0.48, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: size, height: size)
.accessibilityLabel("OSGKeyboard")
}
}
private struct FlowLiveActivityTrailingGlyph: View {
let phase: FlowActivityAttributes.ContentState.Phase
var body: some View {
switch phase {
case .recording:
Image(systemName: "waveform")
.foregroundStyle(.red)
.symbolEffect(.variableColor.iterative, options: .repeating)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
case .idle:
Image(systemName: "mic.fill")
.foregroundStyle(.secondary)
}
}
}
private struct FlowLiveActivityPhaseLabel: View {
let phase: FlowActivityAttributes.ContentState.Phase
var body: some View {
switch phase {
case .recording:
Text("REC")
.font(.caption.monospacedDigit().weight(.bold))
.foregroundStyle(.red)
case .processing:
Text("")
.font(.title3.weight(.semibold))
case .idle:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
private struct FlowLiveActivityPhaseCaption: View {
let phase: FlowActivityAttributes.ContentState.Phase
var body: some View {
switch phase {
case .idle:
Text("Voice session active")
case .recording:
Text("Listening…")
case .processing:
Text("Transcribing…")
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboardLiveActivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
// OSGKeyboardLiveActivityBundle.swift
// OSGKeyboard · Live Activity
import SwiftUI
import WidgetKit
@main
struct OSGKeyboardLiveActivityBundle: WidgetBundle {
var body: some Widget {
FlowLiveActivityWidget()
}
}