refactor(flow): remove Live Activity and keep-alive mode picker

Keep voice sessions on silent low-profile PiP only, and fix custom polish style editor presentation via sheet(item:).
This commit is contained in:
Rocky
2026-08-07 11:32:31 +08:00
parent d237318bf3
commit 41b8db87b5
44 changed files with 276 additions and 1997 deletions
-2
View File
@@ -97,8 +97,6 @@
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -1,144 +0,0 @@
// 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
enum FlowLiveActivityController {
nonisolated(unsafe) private static var currentActivity: Activity<FlowActivityAttributes>?
/// Last phase pushed to the Live Activity so `keepAlive()` can refresh the
/// `staleDate` without changing what the user sees.
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
/// If the host app is force-quit its `endSession()` never runs, orphaning
/// the Live Activity. `staleDate` semantics (verified against ActivityKit
/// behaviour, not folklore): the *Dynamic Island* presentation is reliably
/// removed shortly after the stale date passes, but the *lock-screen*
/// banner may linger greyed-out depending on the iOS version it is NOT
/// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is
/// therefore wrong on its own; the full zombie defence is this short
/// window + launch-time reconciliation (`clearOrphanedActivities`) + the
/// widget rendering an explicit "disconnected" state via
/// `context.isStale`. While the host is alive the heartbeat calls
/// `keepAlive()` every ~10 s, well inside this window.
private static let staleWindow: TimeInterval = 30
private static func freshContent(
phase: FlowActivityAttributes.ContentState.Phase
) -> ActivityContent<FlowActivityAttributes.ContentState> {
ActivityContent(
state: FlowActivityAttributes.ContentState(phase: phase),
staleDate: Date().addingTimeInterval(staleWindow)
)
}
/// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
static func startSession() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
FlowDiagnostics.log("Live Activity disabled in Settings")
return
}
endStaleActivities()
guard currentActivity == nil else {
update(phase: .idle)
return
}
do {
currentPhase = .idle
currentActivity = try Activity.request(
attributes: FlowActivityAttributes(),
content: freshContent(phase: .idle),
pushType: nil
)
FlowDiagnostics.log("Live Activity started")
} catch {
FlowDiagnostics.log("Live Activity start failed: \(error.localizedDescription)")
}
}
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
guard let activity = currentActivity else { return }
currentPhase = phase
let content = freshContent(phase: phase)
Task {
await activity.update(content)
}
}
/// Push a fresh `staleDate` without changing the visible phase. The host
/// heartbeat calls this well inside `staleWindow` so an in-use session
/// never looks stale; once the process dies the refreshes stop and the
/// system reclaims the orphaned Live Activity on its own.
static func keepAlive() {
guard let activity = currentActivity else { return }
let content = freshContent(phase: currentPhase)
Task {
await activity.update(content)
}
}
/// Dismiss the island presentation when the Flow session ends.
static func endSession() {
currentPhase = .idle
guard let activity = currentActivity else {
endStaleActivities()
return
}
currentActivity = nil
Task {
await activity.end(nil, dismissalPolicy: .immediate)
FlowDiagnostics.log("Live Activity ended")
}
}
/// Clear Live Activities orphaned by a previous (force-quit) host process.
///
/// Safe to call on every app foreground: when this process already owns a
/// Live Activity (`currentActivity != nil`) we leave it alone so a healthy
/// running session is never torn down; we only sweep leftovers that belong
/// to a dead process. Call this *before* attempting to (re)start a session
/// so a failed start (e.g. mic timeout) still clears the stale island.
static func clearOrphanedActivities() {
guard currentActivity == nil else { return }
endStaleActivities()
}
/// Host relaunch can leave orphan activities; clear them before starting anew.
private static func endStaleActivities() {
let staleActivities = Activity<FlowActivityAttributes>.activities
currentActivity = nil
guard !staleActivities.isEmpty else { return }
Task {
for activity in staleActivities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
/// `applicationWillTerminate` `end` 退
/// ActivityKit `end` XPC watchdog
/// `wait()` ~5
nonisolated static func endAllSynchronouslyOnTerminate() {
let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) {
let activities = Activity<FlowActivityAttributes>.activities
let count = activities.count
for activity in activities {
await activity.end(activity.content, dismissalPolicy: .immediate)
}
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 2)
currentPhase = .idle
currentActivity = nil
}
}
@@ -22,11 +22,11 @@ enum FlowPiPStartFailure: Equatable, Sendable {
var localizationKey: String {
switch self {
case .unsupported: return "flow.pip.error.unsupported"
case .hostNotReady: return "flow.pip.error.hostNotReady"
case .notPossible: return "flow.pip.error.notPossible"
case .systemRejected: return "flow.pip.error.systemRejected"
case .timedOut: return "flow.pip.error.timedOut"
case .unsupported: return "flow.session.error.unsupported"
case .hostNotReady: return "flow.session.error.hostNotReady"
case .notPossible: return "flow.session.error.notPossible"
case .systemRejected: return "flow.session.error.systemRejected"
case .timedOut: return "flow.session.error.timedOut"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
//
// `UIApplicationDelegate.applicationWillTerminate` `FlowSessionManager`
// SwiftUI `FlowSessionManager` `@StateObject`AppDelegate
// 退 5 Live Activity
// 退 5
import Foundation
@@ -19,6 +19,5 @@ enum FlowTerminationCoordinator {
/// / 线`applicationWillTerminate`
static func performSynchronousTerminationCleanup() {
sessionManager?.prepareForProcessTermination()
FlowLiveActivityController.endAllSynchronouslyOnTerminate()
}
}
@@ -1,376 +0,0 @@
// FlowColdStartOverlay.swift
// OSGKeyboard · Main App
//
// Cold-start handoff hint: a bottom-anchored, full-width gradient that keeps
// the current app visible while Flow proves that voice input is actually
// ready. Failure states reuse the same minimal layout and only change the
// text permission issues are handled with a single "open Settings" link,
// never a second in-app permission flow.
//
// The overlay ignores the keyboard safe area (full-bleed over Home), so it
// manually tracks keyboard overlap and lifts the gradient + copy together
// to stay glued to the keyboard's top edge. MainTabView also ignores the
// keyboard inset, so system safe-area push cannot be relied on here.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct FlowColdStartContext: Equatable {
let hostEntry: HostAppEntry?
var state: FlowColdStartState
/// Drives preparing / PiP-specific copy (Live Activity vs picture-in-picture).
var keepAliveMode: FlowKeepAliveMode
}
enum FlowColdStartState: Equatable {
case preparing
case ready
case failed(FlowColdStartFailure)
}
enum FlowColdStartFailure: Equatable {
case permission(message: String)
case audio(message: String)
/// Picture-in-picture keep-alive could not be proven active.
case pip(message: String)
}
struct FlowColdStartOverlay: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
let context: FlowColdStartContext
let onReturnToHost: () -> Void
let onDismiss: () -> Void
let onRetry: () -> Void
let onOpenSettings: () -> Void
/// Fraction of the *visible* height (above the keyboard) the gradient occupies.
private let gradientHeightFraction: CGFloat = 0.50
/// Distance from the screen bottom to the keyboard's top edge.
@State private var keyboardOverlap: CGFloat = 0
/// Ready and failure states dismiss on blank tap; preparing stays
/// informational only (no accidental dismiss while proving audio).
private var allowsBlankTapDismiss: Bool {
switch context.state {
case .ready, .failed:
return true
case .preparing:
return false
}
}
var body: some View {
GeometryReader { geo in
// Keep gradient proportions relative to the canvas above the keyboard
// so the near-opaque band stays behind the title when the keyboard is up.
let visibleHeight = max(geo.size.height - keyboardOverlap, 1)
let gradientHeight = visibleHeight * gradientHeightFraction
// Above the keyboard the home-indicator inset is already consumed by
// `keyboardOverlap`; only apply it when the keyboard is hidden.
let contentBottomPad = keyboardOverlap > 0
? Spacing.sm
: max(geo.safeAreaInsets.bottom, Spacing.sm)
ZStack(alignment: .bottom) {
// Full-screen hit sink: must expand explicitly a bare Color.clear
// in a bottom-aligned ZStack can collapse and let taps reach Home
// (e.g. focusing the preview field while this overlay is visible).
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture {
if allowsBlankTapDismiss {
onDismiss()
}
}
.allowsHitTesting(true)
// Full-width bottom gradient: transparent at the top of the
// band, nearly opaque at the bottom so hint text stays readable.
LinearGradient(
colors: [
palette.background.opacity(0.35),
palette.background.opacity(0.72),
palette.background.opacity(0.97)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: gradientHeight)
.frame(maxWidth: .infinity, alignment: .bottom)
.allowsHitTesting(false)
VStack(spacing: Spacing.lg) {
content
.padding(.horizontal, Spacing.xl)
homeIndicator
.padding(.bottom, contentBottomPad)
}
}
// Lift gradient + copy as one unit so the opaque band stays glued
// to the keyboard top edge (or the home indicator when idle).
.padding(.bottom, keyboardOverlap)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
}
.onAppear {
// Cold-start copy asks the user to swipe back dismiss any in-app
// keyboard first so Home's preview field cannot steal the scene.
Self.resignEditingFocus()
syncKeyboardOverlap(animated: false)
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
syncKeyboardOverlap(animated: false)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
applyKeyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification)) { notification in
// Catches frames missed between mount and the first WillChange
// (keyboard already visible when the overlay appears).
applyKeyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { notification in
applyKeyboardOverlap(0, from: notification)
}
.animation(.easeInOut(duration: 0.2), value: context.state)
.accessibilityElement(children: .contain)
}
/// Reads the keyboard end frame and animates `keyboardOverlap` with the
/// system keyboard curve so the gradient rides the same motion.
private func applyKeyboardOverlap(from notification: Notification) {
let overlap = Self.keyboardOverlap(from: notification)
applyKeyboardOverlap(overlap, from: notification)
}
private func applyKeyboardOverlap(_ overlap: CGFloat, from notification: Notification) {
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?
.doubleValue ?? 0.25
withAnimation(.easeOut(duration: duration)) {
keyboardOverlap = overlap
}
}
private func syncKeyboardOverlap(animated: Bool) {
let overlap = Self.probedKeyboardOverlap()
if animated {
withAnimation(.easeOut(duration: 0.2)) {
keyboardOverlap = overlap
}
} else {
keyboardOverlap = overlap
}
}
/// Screen-bottom keyboard-top distance in the key window.
private static func keyboardOverlap(from notification: Notification) -> CGFloat {
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
return 0
}
guard let window = keyWindow else {
let bounds = UIScreen.main.bounds
return max(0, bounds.maxY - frame.minY)
}
let frameInWindow = window.convert(frame, from: nil)
return max(0, window.bounds.maxY - frameInWindow.minY)
}
/// Best-effort read when we may have missed keyboard notifications
/// (overlay mounted while the keyboard was already up).
private static func probedKeyboardOverlap() -> CGFloat {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
else {
return 0
}
let reference = keyWindow ?? scene.windows.first
let bounds = reference?.bounds ?? scene.screen.bounds
// UITextEffectsWindow / UIRemoteKeyboardWindow host the keyboard chrome.
for window in scene.windows {
let name = String(describing: type(of: window))
guard name.contains("Keyboard") || name.contains("TextEffects") else { continue }
let overlap = max(0, bounds.maxY - window.frame.minY)
// Full-screen effects windows are not themselves the keyboard
// walk for a bottom-docked subview that looks like the input host.
if overlap >= bounds.height - 1 {
if let docked = deepestBottomDockedSubview(in: window, referenceBounds: bounds) {
return max(0, bounds.maxY - docked.minY)
}
continue
}
if overlap > 0 {
return overlap
}
}
return 0
}
private static func deepestBottomDockedSubview(
in window: UIWindow,
referenceBounds: CGRect
) -> CGRect? {
var best: CGRect?
func visit(_ view: UIView) {
let frame = view.convert(view.bounds, to: nil)
let touchesBottom = abs(frame.maxY - referenceBounds.maxY) < 1.5
let tallEnough = frame.height > 120
let notFullScreen = frame.height < referenceBounds.height * 0.92
if touchesBottom, tallEnough, notFullScreen {
if best == nil || frame.minY < best!.minY {
best = frame
}
}
for child in view.subviews {
visit(child)
}
}
visit(window)
return best
}
private static var keyWindow: UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow)
}
private static func resignEditingFocus() {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
@ViewBuilder
private var content: some View {
VStack(spacing: Spacing.md) {
statusIcon
Text(title)
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(message)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
actionLink
}
}
@ViewBuilder
private var statusIcon: some View {
switch context.state {
case .preparing:
ProgressView()
.tint(palette.accent)
.scaleEffect(1.1)
.accessibilityLabel(preparingTitle)
case .ready:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(palette.accent)
.accessibilityHidden(true)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(palette.warning)
.accessibilityHidden(true)
}
}
@ViewBuilder
private var actionLink: some View {
switch context.state {
case .preparing, .ready:
EmptyView()
case .failed(let failure):
switch failure {
case .permission:
linkButton(AppL10n.string("flow.coldStart.action.settings"), action: onOpenSettings)
case .audio, .pip:
linkButton(AppL10n.string("flow.coldStart.action.retry"), action: onRetry)
}
}
}
private func linkButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
private var preparingTitle: String {
switch context.keepAliveMode {
case .pictureInPicture:
return AppL10n.string("flow.coldStart.preparing.pip")
case .liveActivity:
return AppL10n.string("flow.coldStart.preparing")
}
}
private var title: String {
switch context.state {
case .preparing:
return preparingTitle
case .ready:
return AppL10n.string("flow.coldStart.title")
case .failed(let failure):
switch failure {
case .permission:
return AppL10n.string("flow.coldStart.permission.title")
case .audio:
return AppL10n.string("flow.coldStart.audio.title")
case .pip:
return AppL10n.string("flow.coldStart.pip.title")
}
}
}
private var message: String {
switch context.state {
case .preparing:
switch context.keepAliveMode {
case .pictureInPicture:
return AppL10n.string("flow.coldStart.preparingHint.pip")
case .liveActivity:
return AppL10n.string("flow.coldStart.preparingHint")
}
case .ready:
return AppL10n.string("flow.coldStart.swipeHint")
case .failed(let failure):
switch failure {
case .permission(let message), .audio(let message), .pip(let message):
return message
}
}
}
/// System-style home indicator anchors the swipe-to-return gesture.
private var homeIndicator: some View {
Capsule()
.fill(palette.textTertiary.opacity(context.state == .ready ? 0.55 : 0.35))
.frame(width: 134, height: 5)
.accessibilityHidden(true)
}
}
-11
View File
@@ -76,18 +76,8 @@ struct HomeView: View {
}
.onChange(of: previewFocused) { _, focused in
guard focused else { return }
// Cold-start overlay owns the scene (swipe-back hint); don't let
// the preview field summon the keyboard underneath it.
if flowManager.coldStartContext != nil {
previewFocused = false
return
}
Task { await flowManager.refreshForInlineKeyboardFocus() }
}
.onChange(of: flowManager.coldStartContext != nil) { _, showingOverlay in
guard showingOverlay else { return }
previewFocused = false
}
}
// MARK: - Phone layout
@@ -495,7 +485,6 @@ struct HomeView: View {
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture {
guard flowManager.coldStartContext == nil else { return }
previewFocused = true
}
}
+30 -16
View File
@@ -14,9 +14,10 @@ struct PolishStylesView: View {
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
/// Drives the editor sheet via `sheet(item:)` so create/edit always
/// receives a concrete pack (avoids `isPresented` + nil race showing defaults).
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var showEditor = false
@State private var errorMessage: String?
private let store = AppGroupStore()
@@ -53,8 +54,7 @@ struct PolishStylesView: View {
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingPack = nil
showEditor = true
editingPack = Self.makeDraftPack()
} label: {
Image(systemName: "plus")
}
@@ -63,9 +63,12 @@ struct PolishStylesView: View {
}
}
}
.sheet(isPresented: $showEditor) {
PolishStyleEditorSheet(pack: editingPack) { pack in
save(pack)
.sheet(item: $editingPack) { pack in
PolishStyleEditorSheet(
pack: pack,
isNew: !catalog.entries.contains(where: { $0.id == pack.id })
) { saved in
save(saved)
}
}
.sheet(item: $viewingPack) { pack in
@@ -137,7 +140,6 @@ struct PolishStylesView: View {
viewingPack = pack
} else {
editingPack = pack
showEditor = true
}
} label: {
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
@@ -242,7 +244,13 @@ struct PolishStylesView: View {
prompt: pack.prompt,
allowsAddedEmoji: pack.allowsAddedEmoji
)
showEditor = true
}
private static func makeDraftPack() -> PolishStylePack {
PolishStylePack(
name: "",
prompt: PolishStylePackCatalog.newUserPromptTemplate
)
}
private func delete(_ pack: PolishStylePack) {
@@ -304,7 +312,8 @@ private struct PolishStylePromptDetailSheet: View {
}
private struct PolishStyleEditorSheet: View {
let pack: PolishStylePack?
let pack: PolishStylePack
let isNew: Bool
let onSave: (PolishStylePack) -> Void
@Environment(\.dismiss) private var dismiss
@@ -313,12 +322,17 @@ private struct PolishStyleEditorSheet: View {
@State private var prompt: String
@State private var allowsAddedEmoji: Bool
init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
init(
pack: PolishStylePack,
isNew: Bool,
onSave: @escaping (PolishStylePack) -> Void
) {
self.pack = pack
self.isNew = isNew
self.onSave = onSave
_name = State(initialValue: pack?.name ?? "")
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
_allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
_name = State(initialValue: pack.name)
_prompt = State(initialValue: pack.prompt)
_allowsAddedEmoji = State(initialValue: pack.allowsAddedEmoji)
}
var body: some View {
@@ -359,7 +373,7 @@ private struct PolishStyleEditorSheet: View {
Text("polishStyles.editor.hint")
}
}
.navigationTitle(pack == nil ? "polishStyles.add" : "polishStyles.edit")
.navigationTitle(isNew ? "polishStyles.add" : "polishStyles.edit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -368,13 +382,13 @@ private struct PolishStyleEditorSheet: View {
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
let result = PolishStylePack(
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
id: pack.id,
name: name,
prompt: prompt,
allowsAddedEmoji: allowsAddedEmoji
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
createdAt: pack?.createdAt ?? Date()
createdAt: pack.createdAt
)
onSave(result)
dismiss()
@@ -54,56 +54,6 @@ struct AppearancePickerRow: View {
}
}
// MARK: - Flow keep-alive mode picker row
struct FlowKeepAliveModePickerRow: View {
@Binding var selection: FlowKeepAliveMode
private var options: [(id: String, label: String)] {
FlowKeepAliveMode.allCases.map { mode in
(mode.rawValue, AppL10n.string(mode.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.flow.keepAlive.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowKeepAliveMode(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Flow inactivity picker row
struct FlowInactivityPickerRow: View {
@Binding var selection: FlowInactivityDuration
private var options: [(id: String, label: String)] {
FlowInactivityDuration.allCases.map { duration in
(duration.rawValue, AppL10n.string(duration.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.flow.inactivity.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Handedness picker row
struct HandednessPickerRow: View {
@@ -164,78 +164,6 @@ struct TextPolishSettingsView: View {
}
}
// MARK: - Voice session rows (embedded in Daily)
struct VoiceSessionSettingsRows: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showActiveFlowSessionAlert = false
var body: some View {
VStack(spacing: 0) {
FlowKeepAliveModePickerRow(
selection: Binding(
get: { config.flowKeepAliveMode },
set: { applyKeepAliveModeChange($0) }
)
)
if config.flowKeepAliveMode == .liveActivity {
Divider().background(palette.divider)
FlowInactivityPickerRow(
selection: Binding(
get: { config.flowInactivityDuration },
set: { config.flowInactivityDuration = $0 }
)
)
Divider().background(palette.divider)
Toggle(isOn: $config.flowSkipAppSwitch) {
flowSkipAppSwitchLabel
}
.tint(palette.accent)
.settingsListRow()
} else {
Divider().background(palette.divider)
Text("settings.flow.keepAlive.pictureInPicture.note")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.settingsListRow()
}
}
.alert("settings.flow.keepAlive.activeSession.title", isPresented: $showActiveFlowSessionAlert) {
Button("common.done", role: .cancel) {}
} message: {
Text("settings.flow.keepAlive.activeSession.message")
}
}
private var flowSkipAppSwitchLabel: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.flow.skipAppSwitch.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.flow.skipAppSwitch.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
private func applyKeepAliveModeChange(_ newMode: FlowKeepAliveMode) {
guard newMode != config.flowKeepAliveMode else { return }
if FlowSessionBridge.isSessionActive() {
showActiveFlowSessionAlert = true
return
}
config.flowKeepAliveMode = newMode
}
}
// MARK: - General (appearance, keyboard, sync)
struct GeneralSettingsView: View {
-4
View File
@@ -136,10 +136,6 @@ struct SettingsView: View {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
VoiceSessionSettingsRows(config: config)
}
.surfaceCard()
}
+7 -42
View File
@@ -481,52 +481,17 @@
/* Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
/* Flow session policy */
"settings.flow.title" = "Voice session";
"settings.flow.keepAlive.title" = "Keep-alive mode";
"settings.flow.keepAlive.liveActivity" = "Dynamic Island";
"settings.flow.keepAlive.liveActivity.subtitle" = "Continuous mic session with inactivity timeout.";
"settings.flow.keepAlive.pictureInPicture" = "Picture in Picture";
"settings.flow.keepAlive.pictureInPicture.subtitle" = "Waveform PiP keeps the app alive; mic is released between utterances.";
"settings.flow.keepAlive.pictureInPicture.note" = "Picture in Picture stays active until you close it. Skip app switch is always on in this mode.";
"settings.flow.keepAlive.activeSession.title" = "End the current session first";
"settings.flow.keepAlive.activeSession.message" = "Stop the active voice session before changing keep-alive mode.";
"settings.flow.skipAppSwitch.title" = "Skip app switch";
"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from.";
"settings.flow.inactivity.title" = "End session after inactivity";
"settings.flow.inactivity.1m" = "1 minute";
"settings.flow.inactivity.5m" = "5 minutes";
"settings.flow.inactivity.10m" = "10 minutes";
"settings.flow.inactivity.30m" = "30 minutes";
"settings.flow.inactivity.3h" = "3 hours";
"settings.flow.inactivity.12h" = "12 hours";
"settings.flow.inactivity.24h" = "24 hours";
/* Flow session (policy fields may still sync; keep-alive is no longer shown in Settings) */
"settings.localModels.customLM.title" = "Use custom language model";
"settings.localModels.customLM.subtitle" = "Diagnostic switch. Turn off to test pure Apple on-device recognition if local ASR gets stuck or returns no speech.";
/* Cold-start handoff (scheme B) */
"flow.coldStart.title" = "Voice is ready";
"flow.coldStart.preparing" = "Getting voice ready";
"flow.coldStart.preparing.pip" = "Starting Picture in Picture";
"flow.coldStart.preparingHint" = "Keep OSGKeyboard open for a moment while we start the microphone session.";
"flow.coldStart.preparingHint.pip" = "Keep OSGKeyboard open while Picture in Picture starts. Then return to the keyboard to speak.";
"flow.coldStart.permission.title" = "Permission required";
"flow.coldStart.audio.title" = "Voice could not start";
"flow.coldStart.pip.title" = "Picture in Picture could not start";
/* Cold-start / session errors (never say Picture in Picture to the user) */
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
"flow.pip.error.unavailable" = "Picture in Picture could not start. Stay in the app and try again.";
"flow.pip.error.unsupported" = "This device does not support Picture in Picture.";
"flow.pip.error.hostNotReady" = "The Picture in Picture surface is not ready yet. Stay in the app and try again.";
"flow.pip.error.notPossible" = "The system cannot start Picture in Picture right now. Keep the app in the foreground and try again.";
"flow.pip.error.systemRejected" = "Picture in Picture was rejected by the system. Please try again shortly.";
"flow.pip.error.timedOut" = "Picture in Picture did not appear in time. Stay in the app and try again.";
"flow.coldStart.action.settings" = "Open Settings";
"flow.coldStart.action.retry" = "Try Again";
"flow.coldStart.swipeHint" = "Swipe right along the bottom bar\nto return to your previous app.";
"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return";
"flow.coldStart.tapToDismiss" = "Tap anywhere to close";
"flow.coldStart.return.named" = "Return to %@";
"flow.coldStart.return.generic" = "Return to app";
"flow.session.error.unsupported" = "This device cannot keep a voice session alive.";
"flow.session.error.hostNotReady" = "The voice session is not ready yet. Stay in the app and try again.";
"flow.session.error.notPossible" = "The system cannot start the voice session right now. Keep the app in the foreground and try again.";
"flow.session.error.systemRejected" = "The voice session was rejected by the system. Please try again shortly.";
"flow.session.error.timedOut" = "The voice session did not start in time. Stay in the app and try again.";
/* Host app display names (scheme C whitelist) */
"hostApp.wechat" = "WeChat";
+7 -42
View File
@@ -480,52 +480,17 @@
/* 润色强度 */
"settings.polishIntensity.title" = "润色强度";
/* Flow 会话策略 */
"settings.flow.title" = "语音会话";
"settings.flow.keepAlive.title" = "保活方式";
"settings.flow.keepAlive.liveActivity" = "灵动岛";
"settings.flow.keepAlive.liveActivity.subtitle" = "麦克风常驻,可按无活动时长结束会话。";
"settings.flow.keepAlive.pictureInPicture" = "画中画";
"settings.flow.keepAlive.pictureInPicture.subtitle" = "波形画中画保活;句间释放麦克风。";
"settings.flow.keepAlive.pictureInPicture.note" = "画中画将持续保活,直到你关闭小窗。此模式下始终跳过应用切换。";
"settings.flow.keepAlive.activeSession.title" = "请先结束当前会话";
"settings.flow.keepAlive.activeSession.message" = "更改保活方式前,请先结束正在进行的语音会话。";
"settings.flow.skipAppSwitch.title" = "跳过应用切换";
"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。";
"settings.flow.inactivity.title" = "无活动后结束会话";
"settings.flow.inactivity.1m" = "1 分钟";
"settings.flow.inactivity.5m" = "5 分钟";
"settings.flow.inactivity.10m" = "10 分钟";
"settings.flow.inactivity.30m" = "30 分钟";
"settings.flow.inactivity.3h" = "3 小时";
"settings.flow.inactivity.12h" = "12 小时";
"settings.flow.inactivity.24h" = "24 小时";
/* Flow 会话(内部策略字段仍可能同步;设置页已不再展示保活选项) */
"settings.localModels.customLM.title" = "使用自定义语言模型";
"settings.localModels.customLM.subtitle" = "诊断开关。本地识别卡住或提示未识别时,可关闭它测试纯 Apple 端侧识别。";
/* 冷启动兜底(方案 B */
"flow.coldStart.title" = "语音已就绪";
"flow.coldStart.preparing" = "正在就绪";
"flow.coldStart.preparing.pip" = "正在启动画中画";
"flow.coldStart.preparingHint" = "请先停留片刻,我们正在启动麦克风会话。";
"flow.coldStart.preparingHint.pip" = "请先停留片刻,我们正在启动画中画保活。就绪后可返回键盘直接说话。";
"flow.coldStart.permission.title" = "需要权限";
"flow.coldStart.audio.title" = "语音暂时无法启动";
"flow.coldStart.pip.title" = "画中画暂时无法启动";
/* 冷启动 / 会话错误(用户可见文案不出现「画中画」 */
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
"flow.pip.error.unavailable" = "无法启动画中画,请留在 App 内重试。";
"flow.pip.error.unsupported" = "此设备不支持画中画。";
"flow.pip.error.hostNotReady" = "画中画界面尚未就绪,请留在 App 内稍后重试。";
"flow.pip.error.notPossible" = "系统暂时无法开启画中画,请保持 App 在前台后重试。";
"flow.pip.error.systemRejected" = "画中画启动被系统拒绝,请稍后重试。";
"flow.pip.error.timedOut" = "画中画未能及时出现,请留在 App 内重试。";
"flow.coldStart.action.settings" = "前往设置";
"flow.coldStart.action.retry" = "重试";
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动\n返回上一个 App。";
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
"flow.coldStart.return.named" = "返回%@";
"flow.coldStart.return.generic" = "返回 App";
"flow.session.error.unsupported" = "此设备无法维持语音会话。";
"flow.session.error.hostNotReady" = "语音会话尚未就绪,请留在 App 内稍后重试。";
"flow.session.error.notPossible" = "系统暂时无法启动语音会话,请保持 App 在前台后重试。";
"flow.session.error.systemRejected" = "语音会话启动被系统拒绝,请稍后重试。";
"flow.session.error.timedOut" = "语音会话未能及时启动,请留在 App 内重试。";
/* 宿主 App 显示名(方案 C 白名单) */
"hostApp.wechat" = "微信";