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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "微信";
|
||||
|
||||
Reference in New Issue
Block a user