feat: iCloud settings sync and cold-start return redesign
- Add iCloud key-value settings sync (engine/language/polish/Flow prefs); API keys stay on-device. New "Sync settings via iCloud" toggle. - Redesign cold-start handoff: bottom-bar left-to-right swipe guidance, auto-dismiss on app switch, tap-anywhere to close, retained return link. - Harden keyboard->app handoff with host-disconnected hint. - Include prior Unreleased ASR fixes (route-change crash, fallback warning, multi-utterance recognition, local ASR diagnostics). Release 0.5.0 (build 18).
This commit is contained in:
@@ -1,29 +1,100 @@
|
||||
// AppURLHandler.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Captures `sourceApplication` from UIKit open-URL options (scheme D).
|
||||
// iOS 26 URL handling via the UIScene lifecycle. `application(_:open:options:)`
|
||||
// and `UIApplication.OpenURLOptionsKey.sourceApplication` are deprecated in
|
||||
// iOS 26; the only supported way to read `sourceApplication` (scheme D
|
||||
// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a
|
||||
// scene delegate — SwiftUI's `.onOpenURL` does not expose it.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
extension Notification.Name {
|
||||
static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL")
|
||||
/// Buffers launch/open URLs until the SwiftUI root registers a handler.
|
||||
///
|
||||
/// On a cold launch the scene delivers the URL in `scene(_:willConnectTo:)`,
|
||||
/// which fires *before* the SwiftUI view hierarchy is on screen. Without
|
||||
/// buffering, that first `osgkeyboard://startflow` (the keyboard → app
|
||||
/// handoff) would be dropped.
|
||||
@MainActor
|
||||
final class AppOpenURLRouter {
|
||||
static let shared = AppOpenURLRouter()
|
||||
|
||||
private var handler: ((URL) -> Void)?
|
||||
private var pending: [URL] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Register the live handler and flush anything buffered before launch.
|
||||
func register(_ handler: @escaping (URL) -> Void) {
|
||||
self.handler = handler
|
||||
let buffered = pending
|
||||
pending.removeAll()
|
||||
buffered.forEach(handler)
|
||||
}
|
||||
|
||||
func route(_ url: URL) {
|
||||
if let handler {
|
||||
handler(url)
|
||||
} else {
|
||||
pending.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AppURLHandler: NSObject, UIApplicationDelegate {
|
||||
/// SwiftUI `@main` apps get no scene delegate by default. Attach ours so
|
||||
/// scene-based URL delivery — the only iOS 26 path to `sourceApplication` —
|
||||
/// reaches `AppSceneDelegate`. We deliberately do NOT create a window here;
|
||||
/// SwiftUI's `WindowGroup` still owns the UI.
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
if let source = options[.sourceApplication] as? String {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
name: .osgKeyboardOpenURL,
|
||||
object: nil,
|
||||
userInfo: ["url": url]
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let configuration = UISceneConfiguration(
|
||||
name: nil,
|
||||
sessionRole: connectingSceneSession.role
|
||||
)
|
||||
return true
|
||||
configuration.delegateClass = AppSceneDelegate.self
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
/// Cold launch: the URL arrives in the connection options.
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
handle(connectionOptions.urlContexts)
|
||||
}
|
||||
|
||||
/// Warm open while the app is already running or suspended in memory.
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
handle(URLContexts)
|
||||
}
|
||||
|
||||
private func handle(_ contexts: Set<UIOpenURLContext>) {
|
||||
// Extract Sendable primitives up front so we never hop a
|
||||
// non-Sendable `UIOpenURLContext` across the actor boundary.
|
||||
let items: [(url: URL, source: String?)] = contexts.map {
|
||||
($0.url, $0.options.sourceApplication)
|
||||
}
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
// Scene delegate callbacks are delivered on the main thread.
|
||||
MainActor.assumeIsolated {
|
||||
for item in items {
|
||||
// `sourceApplication` is only non-nil when the caller belongs to
|
||||
// the same Apple Developer Team (our own keyboard extension) —
|
||||
// exactly what the host-return whitelist relies on.
|
||||
if let source = item.source {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
AppOpenURLRouter.shared.route(item.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var expiryTask: Task<Void, Never>?
|
||||
private var levelTask: Task<Void, Never>?
|
||||
private var startTask: Task<Void, Never>?
|
||||
/// Last recording state the poll loop observed — logs only on transition.
|
||||
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
|
||||
private var isUtteranceRecording = false
|
||||
/// True from `stopped` until the result/error is written back to App Group.
|
||||
private var isUtteranceProcessing = false
|
||||
@@ -86,6 +88,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
isColdStartHandoff = true
|
||||
}
|
||||
|
||||
reconcilePersistedFlowStateBeforeStart()
|
||||
|
||||
if isActive {
|
||||
extendSession(duration: duration)
|
||||
if coldStart {
|
||||
@@ -105,6 +109,33 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears App Group Flow state left behind when the host process was killed
|
||||
/// or the device rebooted while the session flag was still set.
|
||||
private func reconcilePersistedFlowStateBeforeStart() {
|
||||
if FlowSessionBridge.isHostStale() {
|
||||
if isActive {
|
||||
endSession()
|
||||
} else {
|
||||
FlowSessionBridge.clearFlowState()
|
||||
FlowLiveActivityController.endSession()
|
||||
}
|
||||
debug("reconciled zombie persisted Flow state")
|
||||
return
|
||||
}
|
||||
|
||||
guard !isActive else { return }
|
||||
|
||||
let orphaned = FlowSessionBridge.recordingState()
|
||||
switch orphaned {
|
||||
case .recording, .stopped, .processing:
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowSessionBridge.clearPendingTranscription()
|
||||
debug("cleared orphaned keyboard recording state: \(orphaned.rawValue)")
|
||||
case .idle, .aborted:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-start (or renew) the Flow session on every app foreground when
|
||||
/// permissions allow — the "always auto-open, no off switch" policy. Also
|
||||
/// clears any orphaned Live Activity a previously force-quit process left
|
||||
@@ -196,6 +227,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
writeHeartbeatIfActive()
|
||||
case .background:
|
||||
setAppForeground(false)
|
||||
if coldStartContext != nil {
|
||||
dismissColdStartOverlay()
|
||||
}
|
||||
beginBackgroundKeepAlive()
|
||||
@unknown default:
|
||||
break
|
||||
@@ -328,16 +362,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
coldStartContext = FlowColdStartContext(
|
||||
hostEntry: hostEntry,
|
||||
showReturnAlert: hostEntry != nil
|
||||
)
|
||||
coldStartContext = FlowColdStartContext(hostEntry: hostEntry)
|
||||
}
|
||||
|
||||
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||
let engineMode = store.engineMode
|
||||
if !force,
|
||||
let sessionASR,
|
||||
sessionASR != nil,
|
||||
sessionASREngineMode == engineMode {
|
||||
return
|
||||
}
|
||||
@@ -374,6 +405,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private func startPolling() {
|
||||
pollingTask?.cancel()
|
||||
lastObservedRecordingState = FlowSessionBridge.recordingState()
|
||||
FlowDiagnostics.log(
|
||||
"polling started: initialRecordingState=\(lastObservedRecordingState.rawValue) " +
|
||||
"container=\(AppGroup.containerPathForDiagnostics)"
|
||||
)
|
||||
pollingTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
self?.handleKeyboardSignal()
|
||||
@@ -383,7 +419,17 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func handleKeyboardSignal() {
|
||||
switch FlowSessionBridge.recordingState() {
|
||||
let signal = FlowSessionBridge.recordingState()
|
||||
if signal != lastObservedRecordingState {
|
||||
// The single most important cross-process signal: proves whether the
|
||||
// host actually SEES the keyboard's recording state writes.
|
||||
FlowDiagnostics.log(
|
||||
"poll observed recordingState \(lastObservedRecordingState.rawValue) → \(signal.rawValue) " +
|
||||
"[rec=\(isUtteranceRecording) proc=\(isUtteranceProcessing) fg=\(isAppForeground)]"
|
||||
)
|
||||
lastObservedRecordingState = signal
|
||||
}
|
||||
switch signal {
|
||||
case .recording:
|
||||
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
|
||||
beginUtterance()
|
||||
@@ -431,7 +477,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowLiveActivityController.update(phase: .recording)
|
||||
FlowDiagnostics.log(
|
||||
"beginUtterance engine=\(store.engineMode) " +
|
||||
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||
"asrType=\(type(of: asr)) pipelined=true " +
|
||||
"localCustomLM=\(store.localASRCustomLanguageModelEnabled) " +
|
||||
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||
)
|
||||
|
||||
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
||||
@@ -638,12 +686,18 @@ final class FlowSessionManager: ObservableObject {
|
||||
// so the keyboard can show the "fill in your key" hint
|
||||
// inline rather than a generic failure message. The raw
|
||||
// transcript is still delivered — no data loss.
|
||||
let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote
|
||||
let fallback = Self.makeFallbackDelivery(
|
||||
rawText: text,
|
||||
error: error,
|
||||
engineMode: engineMode,
|
||||
chunkWarning: chunkNote
|
||||
)
|
||||
FlowDiagnostics.log(
|
||||
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
|
||||
"\(error.localizedDescription)"
|
||||
)
|
||||
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
|
||||
delivered = fallback.text
|
||||
FlowSessionBridge.storeTranscriptionResult(fallback.text, polishWarning: fallback.polishWarning)
|
||||
}
|
||||
|
||||
SpeechHistoryStore.shared.recordUtterance(
|
||||
@@ -684,15 +738,41 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// v0.2.0: surface the local-mode cloud-polish error path with a
|
||||
/// localised hint ("please fill in your DeepSeek key in Settings")
|
||||
/// rather than letting the keyboard show a generic network error.
|
||||
static func makeFallbackDelivery(
|
||||
rawText: String,
|
||||
error: Error,
|
||||
engineMode: String,
|
||||
chunkWarning: String?
|
||||
) -> TranscriptionDelivery {
|
||||
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
|
||||
let warning = warningFromPolishError(error, engineMode: engineMode)
|
||||
?? polishDegradedWarning()
|
||||
?? chunkWarning
|
||||
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
|
||||
}
|
||||
|
||||
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
|
||||
guard let polishError = error as? PolishingService.PolishError,
|
||||
polishError == .missingAPIKey else {
|
||||
return nil
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .missingAPIKey:
|
||||
if engineMode == "local" {
|
||||
return SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
case .timeout:
|
||||
return polishDegradedWarning()
|
||||
case .noTranscript:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if engineMode == "local" {
|
||||
return AppL10n.string("flow.warning.localPolishUnavailable")
|
||||
if error is LLMError {
|
||||
return polishDegradedWarning()
|
||||
}
|
||||
return AppL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func polishDegradedWarning() -> String? {
|
||||
SharedL10n.string("flow.warning.polishDegraded")
|
||||
}
|
||||
|
||||
private func asrWaitTimeout() -> TimeInterval {
|
||||
|
||||
@@ -62,6 +62,12 @@ final class SpeechHistoryStore: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func delete(id: UUID) {
|
||||
guard entries.contains(where: { $0.id == id }) else { return }
|
||||
entries.removeAll { $0.id == id }
|
||||
persist()
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
entries.removeAll()
|
||||
persist()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// FlowColdStartOverlay.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Minimal cold-start handoff UI: swipe-back guidance and optional return alert.
|
||||
// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct FlowColdStartContext: Equatable {
|
||||
let hostEntry: HostAppEntry?
|
||||
var showReturnAlert: Bool
|
||||
}
|
||||
|
||||
struct FlowColdStartOverlay: View {
|
||||
@@ -18,78 +17,62 @@ struct FlowColdStartOverlay: View {
|
||||
let onReturnToHost: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var showAlert: Bool
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
|
||||
init(
|
||||
context: FlowColdStartContext,
|
||||
onReturnToHost: @escaping () -> Void,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.onReturnToHost = onReturnToHost
|
||||
self.onDismiss = onDismiss
|
||||
_showAlert = State(initialValue: context.showReturnAlert)
|
||||
}
|
||||
private let homeBarWidth: CGFloat = 134
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.opacity(0.96)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Text("flow.coldStart.title")
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(swipeHintKey)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
swipeHintAnimation
|
||||
.padding(.top, Spacing.md)
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text("flow.coldStart.dismiss")
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("flow.coldStart.title")
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("flow.coldStart.swipeHint")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if context.hostEntry != nil {
|
||||
Button(action: onReturnToHost) {
|
||||
Text(returnButtonTitle)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.lg)
|
||||
}
|
||||
.padding(Spacing.xl)
|
||||
}
|
||||
.alert(alertTitle, isPresented: $showAlert) {
|
||||
if context.hostEntry != nil {
|
||||
Button(returnButtonTitle, action: onReturnToHost)
|
||||
}
|
||||
Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss)
|
||||
} message: {
|
||||
Text("flow.coldStart.alert.message")
|
||||
}
|
||||
}
|
||||
|
||||
private var swipeHintKey: LocalizedStringKey {
|
||||
context.hostEntry == nil
|
||||
? "flow.coldStart.swipeHint"
|
||||
: "flow.coldStart.swipeHint.withSystemBack"
|
||||
}
|
||||
Spacer()
|
||||
|
||||
private var alertTitle: String {
|
||||
AppL10n.string("flow.coldStart.alert.title")
|
||||
bottomSwipeGuide
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
Text("flow.coldStart.tapToDismiss")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
}
|
||||
|
||||
private var returnButtonTitle: String {
|
||||
@@ -100,15 +83,38 @@ struct FlowColdStartOverlay: View {
|
||||
return AppL10n.format("flow.coldStart.return.named", appName)
|
||||
}
|
||||
|
||||
private var swipeHintAnimation: some View {
|
||||
private var bottomSwipeGuide: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(palette.textTertiary.opacity(0.5))
|
||||
.frame(width: 120, height: 5)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2.5, style: .continuous)
|
||||
.fill(palette.textTertiary.opacity(0.35))
|
||||
.frame(width: homeBarWidth, height: 5)
|
||||
|
||||
Circle()
|
||||
.fill(palette.accent)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: swipeOffset)
|
||||
}
|
||||
.frame(width: homeBarWidth, height: 16)
|
||||
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "arrow.left")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
|
||||
.onAppear {
|
||||
swipeOffset = 0
|
||||
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
|
||||
swipeOffset = homeBarWidth - 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,16 +32,7 @@ struct HistoryView: View {
|
||||
if store.entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
daySection(day: group.day, items: group.items)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
list
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
@@ -74,6 +65,38 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
Section {
|
||||
ForEach(group.items) { entry in
|
||||
historyRow(entry)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
delete(items: group.items, at: offsets)
|
||||
}
|
||||
} header: {
|
||||
Text(Self.dayFormatter.string(from: group.day))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.contentMargins(.top, Spacing.md, for: .scrollContent)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
@@ -88,30 +111,6 @@ struct HistoryView: View {
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(Self.dayFormatter.string(from: day))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.5)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
|
||||
historyRow(entry)
|
||||
if index < items.count - 1 {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(Self.timeFormatter.string(from: entry.createdAt))
|
||||
@@ -126,4 +125,12 @@ struct HistoryView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func delete(items: [SpeechHistoryEntry], at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
store.delete(id: items[index].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ struct LocalModelsGroup: View {
|
||||
speechRow
|
||||
Divider().background(palette.divider)
|
||||
polishRow
|
||||
Divider().background(palette.divider)
|
||||
customLanguageModelDiagnosticRow
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
@@ -63,6 +65,25 @@ struct LocalModelsGroup: View {
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: Custom language model diagnostic row
|
||||
|
||||
private var customLanguageModelDiagnosticRow: some View {
|
||||
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.localModels.customLM.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.localModels.customLM.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
/// Accent badge naming the engine that backs each local-mode row
|
||||
|
||||
@@ -38,14 +38,19 @@ struct MainAppRoot: View {
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
flowManager.activateOnForeground()
|
||||
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
// Registering here also flushes any URL buffered during a cold
|
||||
// launch (the keyboard → app `startflow` handoff arrives via the
|
||||
// scene delegate before this view is on screen).
|
||||
AppOpenURLRouter.shared.register { url in
|
||||
handleIncomingURL(url)
|
||||
}
|
||||
Task {
|
||||
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||
handleIncomingURL(url)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
config.reloadFromPersistedStorage()
|
||||
}
|
||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||
if done {
|
||||
@@ -59,7 +64,7 @@ struct MainAppRoot: View {
|
||||
flowManager.activateOnForeground()
|
||||
}
|
||||
Task {
|
||||
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// SettingsICloudSyncRow.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings-row toggle for mirroring user preferences through iCloud KVS.
|
||||
// API keys remain in Keychain and are never uploaded.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
struct SettingsICloudSyncRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@State private var isEnabled: Bool = AppGroupStore().settingsICloudSyncEnabled
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.appSettings.iCloudSync.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.appSettings.iCloudSync.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isEnabled },
|
||||
set: { newValue in
|
||||
guard newValue != isEnabled else { return }
|
||||
if newValue {
|
||||
enableSync()
|
||||
} else {
|
||||
disableSync()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
isEnabled = store.settingsICloudSyncEnabled
|
||||
}
|
||||
|
||||
private func enableSync() {
|
||||
isApplyingToggle = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await SettingsCloudSync.shared.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as SettingsCloudSyncError {
|
||||
isEnabled = false
|
||||
syncErrorMessage = localizedSyncError(error)
|
||||
} catch {
|
||||
isEnabled = false
|
||||
syncErrorMessage = error.localizedDescription
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
SettingsCloudSync.shared.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func localizedSyncError(_ error: SettingsCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .encodeFailed, .decodeFailed:
|
||||
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,10 @@ struct SettingsView: View {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
cursorDragNavigationToggleRow
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
SettingsICloudSyncRow()
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
|
||||
@@ -368,6 +368,11 @@
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.title" = "Sync settings via iCloud";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Keep engine, language, polish, and Flow preferences in sync across your devices. API keys stay on each device.";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
"settings.polishIntensity.title" = "Polish intensity";
|
||||
|
||||
@@ -381,15 +386,14 @@
|
||||
"settings.flow.inactivity.3h" = "3 hours";
|
||||
"settings.flow.inactivity.12h" = "12 hours";
|
||||
"settings.flow.inactivity.24h" = "24 hours";
|
||||
"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.swipeHint" = "Swipe up from the bottom edge to return to your previous app.";
|
||||
"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return.";
|
||||
"flow.coldStart.dismiss" = "Got it";
|
||||
"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return";
|
||||
"flow.coldStart.alert.title" = "Voice is ready";
|
||||
"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom.";
|
||||
"flow.coldStart.swipeHint" = "Swipe right along the bar at the bottom to 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";
|
||||
|
||||
|
||||
@@ -367,6 +367,11 @@
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.title" = "设置 iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
|
||||
|
||||
/* v0.3.0: 润色强度 */
|
||||
"settings.polishIntensity.title" = "润色强度";
|
||||
|
||||
@@ -380,15 +385,14 @@
|
||||
"settings.flow.inactivity.3h" = "3 小时";
|
||||
"settings.flow.inactivity.12h" = "12 小时";
|
||||
"settings.flow.inactivity.24h" = "24 小时";
|
||||
"settings.localModels.customLM.title" = "使用自定义语言模型";
|
||||
"settings.localModels.customLM.subtitle" = "诊断开关。本地识别卡住或提示未识别时,可关闭它测试纯 Apple 端侧识别。";
|
||||
|
||||
/* 冷启动兜底(方案 B) */
|
||||
"flow.coldStart.title" = "语音已就绪";
|
||||
"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。";
|
||||
"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。";
|
||||
"flow.coldStart.dismiss" = "知道了";
|
||||
"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回";
|
||||
"flow.coldStart.alert.title" = "语音已就绪";
|
||||
"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。";
|
||||
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。";
|
||||
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
|
||||
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
|
||||
"flow.coldStart.return.named" = "返回%@";
|
||||
"flow.coldStart.return.generic" = "返回 App";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user