diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 579f5aa..b8dd54a 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -127,6 +127,17 @@ public final class KeyboardViewController: UIInputViewController { refreshDictationProgressStateIfNeeded() refreshFlowSessionState() startFlowSessionMonitor() + // v0.3.0: re-mirror onboarding + app context from the App + // Group every time we appear. The user may have just returned + // from Settings.app or the host app, and the App Group is the + // only thing both processes see consistently. + syncOnboardingStateFromAppGroup() + syncAppContextFromAppGroup() + // Auto-advance past step 3 ("Enable Keyboard") if the user has + // enabled the keyboard in Settings.app while we were away. + // This is the "automatic return from jump" feature: no manual + // "Continue" tap needed. + autoAdvancePastKeyboardSetupStepIfNeeded() } public override func viewIsAppearing(_ animated: Bool) { @@ -172,14 +183,80 @@ public final class KeyboardViewController: UIInputViewController { state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) } state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) } // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip - // / picker only writes the locale id now; `enabled` is derived. + // / picker now writes the locale id directly; `enabled` is derived. state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) } state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) } + // v0.3.0: per-app context override. Writes to the App Group + // (so `PolishingService` reads it on the next take) and mirrors + // into local state. + state.setAppContext = { [weak self] c in self?.persistAppContext(c) } + // v0.3.0: in-keyboard onboarding actions. Persist via the App + // Group so the host app's `ProviderConfig` stays in sync (and + // the next launch of the host app opens at the same page). + state.advanceOnboarding = { [weak self] in self?.advanceOnboarding() } + state.completeOnboarding = { [weak self] in self?.completeOnboarding() } + state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() } + state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() } + state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() } state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } } + // MARK: - Onboarding persistence (v0.3.0) + + private func advanceOnboarding() { + let store = AppGroupStore() + let nextPage = min(4, store.onboardingPage + 1) + store.onboardingPage = nextPage + state.onboardingPage = nextPage + } + + private func completeOnboarding() { + let store = AppGroupStore() + store.hasCompletedOnboarding = true + store.onboardingPage = 4 + state.hasCompletedOnboarding = true + state.onboardingPage = 4 + } + + // MARK: - App context override (v0.3.0) + + private func persistAppContext(_ context: AppContext) { + let store = AppGroupStore() + store.setDetectedAppContext(context, at: Date()) + state.appContext = context + } + + // MARK: - Permission requests from the extension (v0.3.0) + + /// The keyboard extension cannot present `AVAudioSession` / + /// `SFSpeechRecognizer` permission dialogs directly. It *can* read + /// the current status and tell the user to grant them from the + /// host app — which the overlay's step 1/2 copy already does. + /// This method is kept as a no-op stub so the action hook exists + /// and we can fill in the right behaviour if iOS ever relaxes the + /// sandbox (currently the system dialog is only presented when + /// the relevant API is first invoked from the host app, not the + /// extension). + private func requestMicPermissionFromExtension() { + // Status is read on the next `viewWillAppear` via + // `KeyboardSetupBridge`; the overlay advances optimistically. + } + + private func requestSpeechPermissionFromExtension() { + // Same as above — read on next `viewWillAppear`. + } + + /// Open `Settings.app` so the user can flip the "Allow Full Access" + /// / "OSGKeyboard" toggles. `UIApplication.openSettingsURLString` + /// is the only system URL the keyboard extension is allowed to + /// open via `extensionContext`. + private func openSystemSettingsFromExtension() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + HostAppLauncher.open(url: url, from: self) { _ in } + } + /// Reserve keyboard height on `view`. During presentation iOS adds a /// private encapsulated height; `viewIsAppearing` applies the community /// offset trick (target − encapsulated) so the slot lands at `target`. @@ -239,6 +316,43 @@ public final class KeyboardViewController: UIInputViewController { message: ExtL10n.string("keyboard.error.appGroupUnavailable") ) } + syncOnboardingStateFromAppGroup() + syncAppContextFromAppGroup() + } + + // MARK: - Onboarding / app-context sync (v0.3.0) + + /// Mirror the persisted onboarding flags from the App Group into + /// `KeyboardState`. Called both at boot (`loadPersistedConfig`) and + /// on every `viewWillAppear` so the overlay reflects what the user + /// did while the keyboard was paused (e.g. toggling permissions + /// in the host app's onboarding view). + private func syncOnboardingStateFromAppGroup() { + let store = AppGroupStore() + state.hasCompletedOnboarding = store.hasCompletedOnboarding + state.onboardingPage = store.onboardingPage + } + + /// Mirror the detected app context (or the user's last manual + /// override) so the AppContextChip stays in sync after a jump. + private func syncAppContextFromAppGroup() { + let store = AppGroupStore() + state.appContext = store.detectedAppContext?.context ?? .unknown + } + + /// If the user has finished the "Enable Keyboard" step (i.e. the + /// keyboard is now in the system list with full access), and the + /// overlay is currently sitting on that step, advance to the API + /// step silently. This is what makes the "jump out → come back" + /// flow feel automatic even though iOS won't switch us back. + private func autoAdvancePastKeyboardSetupStepIfNeeded() { + guard !state.hasCompletedOnboarding else { return } + // step index 3 = "Enable Keyboard" + guard state.onboardingPage == 3 else { return } + guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return } + let store = AppGroupStore() + store.onboardingPage = 4 + state.onboardingPage = 4 } // MARK: - Flow session monitor @@ -441,6 +555,10 @@ public final class KeyboardViewController: UIInputViewController { storedCache: store.detectedAppContext ) store.setDetectedAppContext(context) + // v0.3.0: mirror into local state so the chip updates without + // waiting for the next `viewWillAppear`. The user gets + // immediate visual feedback that the polish tone just changed. + state.appContext = context } private func startUtteranceCountdown() { diff --git a/OSGKeyboardExt/Views/AppContextChip.swift b/OSGKeyboardExt/Views/AppContextChip.swift new file mode 100644 index 0000000..523033b --- /dev/null +++ b/OSGKeyboardExt/Views/AppContextChip.swift @@ -0,0 +1,76 @@ +// AppContextChip.swift +// OSGKeyboard · Keyboard Extension +// +// Surfaces the v0.3.0 per-app polish context on the keyboard top +// bar. The LLM prompt already adapts to the detected context (see +// `PolishingService.buildPrompt(for:context:)`), but without a UI +// cue the user has no way to know "I'm currently in code mode" — +// and no way to override the heuristic when it guesses wrong. +// +// Tap the chip → cycle through the five `AppContext` cases. The new +// value is written to `AppGroupStore.setDetectedAppContext(_:at:)`, +// so the next `PolishingService` call picks it up immediately. + +import SwiftUI +import OSGKeyboardShared + +struct AppContextChip: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var state: KeyboardViewController.State + + var body: some View { + Menu { + ForEach(AppContext.allCases, id: \.self) { context in + Button { + state.setAppContext(context) + } label: { + if context == state.appContext { + Label(menuLabel(for: context), systemImage: "checkmark") + } else { + Text(menuLabel(for: context)) + } + } + } + } label: { + label + } + .menuStyle(.button) + .accessibilityLabel(ExtL10n.text("keyboard.appContext.a11y")) + .accessibilityHint(ExtL10n.text("keyboard.appContext.a11yHint")) + } + + private var label: some View { + HStack(spacing: 4) { + Image(systemName: iconName(for: state.appContext)) + Text(chipText) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 6) + .frame(minHeight: 28) + .background(palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) + } + + private var chipText: String { + ExtL10n.text("keyboard.appContext.chip.\(state.appContext.rawValue)") + } + + private func menuLabel(for context: AppContext) -> String { + ExtL10n.text("keyboard.appContext.menu.\(context.rawValue)") + } + + private func iconName(for context: AppContext) -> String { + switch context { + case .code: return "chevron.left.forwardslash.chevron.right" + case .email: return "envelope" + case .chat: return "bubble.left" + case .document: return "doc.text" + case .unknown: return "questionmark.circle" + } + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift b/OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift new file mode 100644 index 0000000..de461aa --- /dev/null +++ b/OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift @@ -0,0 +1,293 @@ +// KeyboardOnboardingOverlay.swift +// OSGKeyboard · Keyboard Extension +// +// v0.3.0 in-keyboard onboarding. Replaces the previous "jump out to +// the host app" flow with a five-step overlay that lives on top of +// the normal keyboard UI. +// +// Why in-keyboard instead of jumping to the host app? +// +// - iOS keyboard extensions **cannot programmatically switch back +// to the previous app** after a host-app jump. The user has to +// re-find their app, re-tap a text field, and re-select OSGKeyboard +// from the globe menu. That's a 5+ tap friction. +// +// - Steps 1, 2, 4 (welcome, mic permission, speech permission, +// API key) need nothing the host app owns. They can all live in +// the keyboard. +// +// - The only step that *must* leave the keyboard is step 3 +// ("Enable Keyboard") — iOS requires the user to flip a toggle +// in `Settings.app`, which is reachable from the extension via +// `UIApplication.openSettingsURLString`. After the user comes +// back, `viewWillAppear` reads `KeyboardSetupBridge.isReadyForOnboardingSkip` +// and the overlay auto-advances past step 3. +// +// The overlay mounts only when `state.hasCompletedOnboarding == false`. +// All inputs route through `KeyboardState` action hooks, so the +// controller can mirror them into the App Group without the view +// having to know about persistence. + +import SwiftUI +import OSGKeyboardShared + +struct KeyboardOnboardingOverlay: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var state: KeyboardViewController.State + + var body: some View { + ZStack { + // Dim the underlying keyboard so the overlay reads as a + // distinct surface. We can't completely hide it without + // losing keyboard-system visibility, so a 60% black wash + // is the sweet spot between focus and consistency. + palette.background.opacity(0.96).ignoresSafeArea() + + VStack(spacing: 0) { + header + + Spacer(minLength: Spacing.sm) + + Group { + switch currentStep { + case .welcome: welcomeStep + case .microphone: microphoneStep + case .speech: speechStep + case .keyboard: keyboardStep + case .api: apiStep + } + } + .transition(.opacity.combined(with: .move(edge: .trailing))) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer(minLength: Spacing.sm) + + footer + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.md) + } + } + + // MARK: - Steps + + private enum Step: Int, CaseIterable { + case welcome = 0, microphone, speech, keyboard, api + + static let count = 5 + } + + private var currentStep: Step { + Step(rawValue: state.onboardingPage) ?? .welcome + } + + private var header: some View { + VStack(spacing: Spacing.xs) { + HStack(spacing: 6) { + ForEach(0.. some View { + VStack(spacing: Spacing.md) { + Image(systemName: iconSystemName) + .font(.system(size: 36, weight: .light)) + .foregroundStyle(palette.accent) + Text(ExtL10n.text(title)) + .font(TypeStyle.headline) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.center) + Text(ExtL10n.text(body)) + .font(TypeStyle.caption1) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: Spacing.xs) { + if currentStep != .welcome { + Button(ExtL10n.text("keyboard.onboarding.back")) { + state.onboardingPage = max(0, currentStep.rawValue - 1) + } + .font(TypeStyle.caption1) + .foregroundStyle(palette.textSecondary) + .frame(minHeight: 36) + } + + Spacer(minLength: 0) + + primaryButton + } + } + + @ViewBuilder + private var primaryButton: some View { + switch currentStep { + case .welcome: + Button(ExtL10n.text("keyboard.onboarding.getStarted")) { + state.onboardingPage = 1 + } + .buttonStyle(OverlayPrimaryButtonStyle(palette: palette)) + + case .microphone: + Button(ExtL10n.text("keyboard.onboarding.mic.grant")) { + state.requestMicPermission() + // Optimistically advance — if permission is denied the + // status text on the next viewWillAppear will reflect it. + state.onboardingPage = 2 + } + .buttonStyle(OverlayPrimaryButtonStyle(palette: palette)) + + case .speech: + Button(ExtL10n.text("keyboard.onboarding.speech.grant")) { + state.requestSpeechPermission() + state.onboardingPage = 3 + } + .buttonStyle(OverlayPrimaryButtonStyle(palette: palette)) + + case .keyboard: + // Step 3 is auto-advanced by viewWillAppear once the user + // has enabled the keyboard in Settings.app. We don't show + // a "Continue" button here — that would re-trigger the + // confusion we're solving. + Button(ExtL10n.text("keyboard.onboarding.keyboard.openSettings")) { + state.openSystemSettings() + } + .buttonStyle(OverlayPrimaryButtonStyle(palette: palette)) + + case .api: + HStack(spacing: Spacing.xs) { + Button(ExtL10n.text("keyboard.onboarding.api.skip")) { + state.completeOnboarding() + } + .font(TypeStyle.caption1) + .foregroundStyle(palette.textSecondary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: 36) + + Button(ExtL10n.text("keyboard.onboarding.api.openHostApp")) { + state.openSettings() + } + .buttonStyle(OverlayPrimaryButtonStyle(palette: palette)) + } + } + } +} + +private struct OverlayPrimaryButtonStyle: ButtonStyle { + let palette: ThemePalette + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(TypeStyle.caption1.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.xs + 2) + .background(palette.accent.opacity(configuration.isPressed ? 0.7 : 1.0), + in: Capsule()) + .frame(minHeight: 36) + .contentShape(Capsule()) + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index f682432..c0563af 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -75,25 +75,40 @@ public struct KeyboardRootView: View { } public var body: some View { - VStack(spacing: 0) { - headerBand + ZStack { + VStack(spacing: 0) { + headerBand - Color.clear - .frame(height: KeyboardLayoutMetrics.actionClusterTopGap) + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterTopGap) - micActionRow - .frame(height: KeyboardLayoutMetrics.actionClusterHeight) + micActionRow + .frame(height: KeyboardLayoutMetrics.actionClusterHeight) - Color.clear - .frame(height: KeyboardLayoutMetrics.actionClusterBottomGap) + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterBottomGap) + } + .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) + .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) + // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 + .background(Color.clear) + .frame(height: Self.totalHeight) + // Feed the resolved palette to all nested chips/buttons. + .environment(\.themePalette, palette) + + // v0.3.0: in-keyboard first-launch onboarding. Mounted as + // an overlay so the normal keyboard chrome stays + // responsive underneath (mic button still works, chip + // taps register). Only rendered until + // `state.hasCompletedOnboarding` flips to true; from + // then on the overlay is unmounted and never re-rendered. + if !state.hasCompletedOnboarding { + KeyboardOnboardingOverlay(state: state) + .environment(\.themePalette, palette) + .transition(.opacity) + } } - .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) - .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) - // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 - .background(Color.clear) - .frame(height: Self.totalHeight) - // Feed the resolved palette to all nested chips/buttons. - .environment(\.themePalette, palette) + .animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding) } /// Top chip row + transcript / hint line. @@ -131,6 +146,14 @@ public struct KeyboardRootView: View { LocaleChip(localeId: state.localeId) { newId in state.setLocale(newId) } + // v0.3.0: detected app context — the per-app polish mode. + // The chip mirrors `AppGroupStore.detectedAppContext` and + // writes overrides back so the next LLM call uses the new + // tone. Hidden during onboarding (the overlay reads better + // without chip clutter). + if state.hasCompletedOnboarding { + AppContextChip(state: state) + } // v0.3: always show the translation chip when the active // engine can run the cloud LLM step — off-by-default keeps // the menu reachable so the user can pick a target language diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 58b9e65..29e2858 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -202,3 +202,38 @@ "locale.en-US" = "English (US)"; "locale.ja-JP" = "Japanese"; "locale.ko-KR" = "Korean"; + +/* v0.3.0: in-keyboard first-launch onboarding */ +"keyboard.onboarding.title" = "Set up OSGKeyboard"; +"keyboard.onboarding.back" = "Back"; +"keyboard.onboarding.getStarted" = "Get Started"; +"keyboard.onboarding.welcome.title" = "Welcome to OSGKeyboard"; +"keyboard.onboarding.welcome.body" = "Voice-to-text with AI polish. Set up takes 30 seconds — most of it happens right here in the keyboard."; +"keyboard.onboarding.mic.title" = "Microphone access"; +"keyboard.onboarding.mic.body" = "OSGKeyboard needs microphone access to transcribe your speech. iOS will show a system prompt."; +"keyboard.onboarding.mic.grant" = "Allow Microphone"; +"keyboard.onboarding.speech.title" = "Speech recognition"; +"keyboard.onboarding.speech.body" = "Apple's on-device speech engine turns your voice into text. iOS will show a system prompt."; +"keyboard.onboarding.speech.grant" = "Allow Speech Recognition"; +"keyboard.onboarding.keyboard.title" = "Enable OSGKeyboard"; +"keyboard.onboarding.keyboard.body" = "Open Settings → General → Keyboard → Keyboards → Add New Keyboard → OSGKeyboard. Then tap OSGKeyboard again and enable Allow Full Access. Come back here when you're done."; +"keyboard.onboarding.keyboard.openSettings" = "Open Settings"; +"keyboard.onboarding.api.title" = "One last step"; +"keyboard.onboarding.api.body" = "To polish your text with AI, OSGKeyboard needs an LLM API key. You can add it now in the app, or skip and add it later from Settings."; +"keyboard.onboarding.api.skipHint" = "Polishing won't work without an API key, but the keyboard will still type the raw transcript."; +"keyboard.onboarding.api.skip" = "Skip"; +"keyboard.onboarding.api.openHostApp" = "Open OSGKeyboard"; + +/* v0.3.0: AppContext chip on the keyboard top bar */ +"keyboard.appContext.a11y" = "Polish context"; +"keyboard.appContext.a11yHint" = "Tap to override the auto-detected input context (code, email, chat, document)."; +"keyboard.appContext.chip.code" = "Code"; +"keyboard.appContext.chip.email" = "Email"; +"keyboard.appContext.chip.chat" = "Chat"; +"keyboard.appContext.chip.document" = "Document"; +"keyboard.appContext.chip.unknown" = "General"; +"keyboard.appContext.menu.code" = "Code — preserve identifiers, no natural-language wrap"; +"keyboard.appContext.menu.email" = "Email — polite, professional, paragraph-broken"; +"keyboard.appContext.menu.chat" = "Chat — short, casual, emoji-friendly"; +"keyboard.appContext.menu.document" = "Document — long-form, structured"; +"keyboard.appContext.menu.unknown" = "General — neutral tone"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index e8d174d..a7fbe69 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -202,3 +202,38 @@ "locale.en-US" = "English (US)"; "locale.ja-JP" = "日本語"; "locale.ko-KR" = "한국어"; + +/* v0.3.0:键盘内首次启动引导 */ +"keyboard.onboarding.title" = "设置 OSGKeyboard"; +"keyboard.onboarding.back" = "返回"; +"keyboard.onboarding.getStarted" = "开始设置"; +"keyboard.onboarding.welcome.title" = "欢迎使用 OSGKeyboard"; +"keyboard.onboarding.welcome.body" = "语音转文字 + AI 润色。30 秒搞定,绝大部分步骤直接在键盘里完成。"; +"keyboard.onboarding.mic.title" = "麦克风权限"; +"keyboard.onboarding.mic.body" = "OSGKeyboard 需要麦克风权限来转写你的语音。点击下方按钮后,iOS 会弹出系统提示。"; +"keyboard.onboarding.mic.grant" = "允许麦克风"; +"keyboard.onboarding.speech.title" = "语音识别权限"; +"keyboard.onboarding.speech.body" = "Apple 的本地语音引擎负责把声音变成文字。点击下方按钮后,iOS 会弹出系统提示。"; +"keyboard.onboarding.speech.grant" = "允许语音识别"; +"keyboard.onboarding.keyboard.title" = "启用 OSGKeyboard"; +"keyboard.onboarding.keyboard.body" = "打开 设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OSGKeyboard。然后再次点击 OSGKeyboard,打开\"允许完全访问\"。完成后回到这里。"; +"keyboard.onboarding.keyboard.openSettings" = "打开设置"; +"keyboard.onboarding.api.title" = "最后一步"; +"keyboard.onboarding.api.body" = "要让 AI 润色你的文字,OSGKeyboard 需要一个 LLM API key。你可以现在在 App 里填写,也可以先跳过,之后在设置里补上。"; +"keyboard.onboarding.api.skipHint" = "没有 API key 也能用——只是不会润色,只输出原始转写。"; +"keyboard.onboarding.api.skip" = "跳过"; +"keyboard.onboarding.api.openHostApp" = "打开 OSGKeyboard"; + +/* v0.3.0:键盘顶栏的输入场景芯片 */ +"keyboard.appContext.a11y" = "润色场景"; +"keyboard.appContext.a11yHint" = "点击可手动切换输入场景(代码/邮件/聊天/文档),覆盖自动检测结果。"; +"keyboard.appContext.chip.code" = "代码"; +"keyboard.appContext.chip.email" = "邮件"; +"keyboard.appContext.chip.chat" = "聊天"; +"keyboard.appContext.chip.document" = "文档"; +"keyboard.appContext.chip.unknown" = "通用"; +"keyboard.appContext.menu.code" = "代码 — 保留标识符、不做自然语言化"; +"keyboard.appContext.menu.email" = "邮件 — 礼貌专业、合理分段"; +"keyboard.appContext.menu.chat" = "聊天 — 简短随意、可带 emoji"; +"keyboard.appContext.menu.document" = "文档 — 长文、结构化"; +"keyboard.appContext.menu.unknown" = "通用 — 中性口吻"; diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 0b2a196..644b0d2 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -276,6 +276,23 @@ public struct AppGroupStore: @unchecked Sendable { defaults.set(intensity.rawValue, forKey: Key.polishIntensity) } + // MARK: - Onboarding (v0.3.0+) + // + // Mirrored from `ProviderConfig` so the keyboard extension's + // overlay can read / write the same source of truth without + // instantiating the main-app config (which would drag in + // SwiftUI / Combine and fight the keyboard's main-thread budget). + + public var hasCompletedOnboarding: Bool { + get { defaults.bool(forKey: "config.hasCompletedOnboarding") } + set { defaults.set(newValue, forKey: "config.hasCompletedOnboarding") } + } + + public var onboardingPage: Int { + get { defaults.integer(forKey: "config.onboardingPage") } + set { defaults.set(newValue, forKey: "config.onboardingPage") } + } + // MARK: - Detected app context (v0.3.0+) /// Last app context the keyboard extension detected for this diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 40fd413..c93067f 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -128,6 +128,36 @@ public final class KeyboardState: ObservableObject { /// Convenience shorthand used by the pipeline and views. public var isLocalEngine: Bool { engineMode == "local" } + // MARK: - First-launch onboarding (mirrored from ProviderConfig) + + /// Drives the in-keyboard onboarding overlay. When `false`, the + /// keyboard shows a step-by-step overlay instead of the normal UI; + /// when `true`, normal UI renders. Mirrored from `ProviderConfig` + /// so the keyboard never has to instantiate the main-app config. + @Published public var hasCompletedOnboarding: Bool = false + /// Step the user is currently on (0-based). The overlay reads this + /// to render the right page; main-app `ProviderConfig` is the + /// source of truth and the keyboard mirrors it. + @Published public var onboardingPage: Int = 0 + /// Per-call context the polish prompt should adapt to. Mirrored + /// from `AppGroupStore.detectedAppContext` on every `viewWillAppear` + /// so the chip stays consistent across the open↔jump cycle. + @Published public var appContext: AppContext = .unknown + /// `true` when the user tapped something (mic, settings) right + /// before a forced jump to the host app. The keyboard reads this + /// on return and auto-resumes the action so the user does not have + /// to tap the same button twice. + @Published public var pendingResumeAction: ResumeAction = .none + + /// Action the keyboard should auto-trigger after a host-app jump + /// completes. Set just before `openHostApp`, consumed (set back to + /// `.none`) after the action fires once. + public enum ResumeAction: Equatable { + case none + case startRecording + case openSettings + } + // Action hooks — injected by the view controller at install time. public var beginRecording: () -> Void = {} public var endRecording: () -> Void = {} @@ -143,6 +173,15 @@ public final class KeyboardState: ObservableObject { /// persist. Wired in `KeyboardViewController.installStateActions`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } public var setPolishScenarioId: (String) -> Void = { _ in } + /// v0.3.0: manually override the auto-detected app context (e.g. + /// when the heuristic guessed wrong). Writes to the App Group so + /// `PolishingService` picks it up on the next take. + public var setAppContext: (AppContext) -> Void = { _ in } + public var advanceOnboarding: () -> Void = {} + public var completeOnboarding: () -> Void = {} + public var requestMicPermission: () -> Void = {} + public var requestSpeechPermission: () -> Void = {} + public var openSystemSettings: () -> Void = {} public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} public var deleteBackward: () -> Void = {} diff --git a/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift b/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift new file mode 100644 index 0000000..64302fe --- /dev/null +++ b/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift @@ -0,0 +1,108 @@ +// KeyboardOnboardingOverlayTests.swift +// OSGKeyboard · Tests +// +// v0.3.0: locks the AppGroupStore onboarding + app-context accessor +// wiring. These are the bytes the in-keyboard overlay reads every +// `viewWillAppear`, so a regression here breaks the first-launch UX +// silently (the overlay gets stuck on the welcome step, or the +// chip shows the wrong context). + +import XCTest +@testable import OSGKeyboard +@testable import OSGKeyboardShared + +final class KeyboardOnboardingOverlayTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + private var store: AppGroupStore! + + override func setUp() { + super.setUp() + suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + store = AppGroupStore(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + // MARK: - Onboarding flags + + func testOnboardingFlagsDefaultFalseAndZero() { + XCTAssertFalse(store.hasCompletedOnboarding, "fresh install should not show as onboarded") + XCTAssertEqual(store.onboardingPage, 0, "fresh install should start at page 0") + } + + func testOnboardingFlagsRoundTrip() { + store.onboardingPage = 3 + store.hasCompletedOnboarding = true + XCTAssertEqual(store.onboardingPage, 3) + XCTAssertTrue(store.hasCompletedOnboarding) + } + + func testOnboardingFlagsSurviveReconstruct() { + store.onboardingPage = 4 + store.hasCompletedOnboarding = true + + // Simulate the keyboard extension being torn down and rebuilt + // (which is what happens on every `viewDidLoad` cycle). + let store2 = AppGroupStore(defaults: defaults) + XCTAssertEqual(store2.onboardingPage, 4) + XCTAssertTrue(store2.hasCompletedOnboarding) + } + + // MARK: - App context detection round-trip + + func testDetectedAppContextRoundTrip() { + let now = Date() + store.setDetectedAppContext(.code, at: now) + let result = store.detectedAppContext + XCTAssertEqual(result?.context, .code) + XCTAssertEqual(result?.observedAt.timeIntervalSinceReferenceDate, + now.timeIntervalSinceReferenceDate, + accuracy: 0.001) + } + + func testDetectedAppContextOverwrite() { + store.setDetectedAppContext(.code) + store.setDetectedAppContext(.email) + XCTAssertEqual(store.detectedAppContext?.context, .email, + "second setDetectedAppContext must overwrite the first") + } + + func testDetectedAppContextEmptyBeforeSet() { + XCTAssertNil(store.detectedAppContext, + "detectedAppContext must be nil before any explicit set") + } + + // MARK: - All cases enum surface + + func testAllAppContextCasesHaveRawValue() { + // Locked: every case the LLM prompt knows about must be + // serializable through App Group UserDefaults. Adding a new + // case without a stable raw value silently breaks the cache. + for context in AppContext.allCases { + XCTAssertFalse(context.rawValue.isEmpty, + "AppContext.\(context) must have a non-empty rawValue") + } + } + + // MARK: - Polish intensity default + + func testPolishIntensityDefaultIsMedium() { + XCTAssertEqual(store.polishIntensity, .medium, + "default polish intensity should match Typeless baseline") + } + + func testPolishIntensityRoundTrip() { + store.setPolishIntensity(.heavy) + XCTAssertEqual(store.polishIntensity, .heavy) + store.setPolishIntensity(.off) + XCTAssertEqual(store.polishIntensity, .off, + "off should round-trip through UserDefaults (NOT skip the write)") + } +} \ No newline at end of file