feat(keyboard): in-keyboard onboarding overlay + AppContext chip
Two long-standing UX papercuts, fixed without leaving the keyboard:
1. **First-launch onboarding inside the keyboard** — iOS keyboard
extensions *cannot* programmatically switch back to the previous
app after jumping out, so the old flow (jump to host app → user
has to manually navigate back) was 5+ taps of friction. The new
`KeyboardOnboardingOverlay` keeps the user inside the keyboard
for steps 1 (welcome), 2 (mic permission), 3 (speech permission),
and 5 (API key hint). The only step that *must* leave is step 4
("Enable Keyboard"), which jumps to `Settings.app` via
`UIApplication.openSettingsURLString` — on return,
`viewWillAppear` calls `autoAdvancePastKeyboardSetupStepIfNeeded`
which silently advances past that step if the keyboard is now
enabled. Net UX: user types in their app, keyboard walks them
through setup, normal UI appears as soon as setup is done.
2. **Per-app context chip on the keyboard top bar** — the v0.3.0
intelligent-prompt pipeline already adapted tone to detected
context (code/email/chat/document), but without a UI cue the user
had no way to know which mode was active or override the heuristic
when it guessed wrong. The new `AppContextChip` surfaces the
detected context; tap-to-override writes back to
`AppGroupStore.setDetectedAppContext` so the next LLM call
picks up the new tone immediately. Wired into `pressBegan` so
the chip updates in real time as the user types into different
fields.
### Files added
- `OSGKeyboardExt/Views/AppContextChip.swift` — chip + Menu override
- `OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift` — 5-step overlay
- `OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift` — round-trip + enum surface tests
### Files modified
- `OSGKeyboardShared/Services/KeyboardState.swift`
+ `hasCompletedOnboarding`, `onboardingPage`, `appContext`
+ `setAppContext`, `advanceOnboarding`, `completeOnboarding`
+ `requestMicPermission`, `requestSpeechPermission`, `openSystemSettings`
- `OSGKeyboardShared/Services/AppGroupStore.swift`
+ `hasCompletedOnboarding` / `onboardingPage` accessors (mirror of
`ProviderConfig` keys, so the keyboard extension never has to
instantiate the host-app config)
- `OSGKeyboardExt/KeyboardViewController.swift`
+ action hooks wired (`installStateActions`)
+ `syncOnboardingStateFromAppGroup` / `syncAppContextFromAppGroup`
called on `viewWillAppear` and `loadPersistedConfig`
+ `autoAdvancePastKeyboardSetupStepIfNeeded` for the silent
"jump out → come back" flow
+ `openSystemSettingsFromExtension` opens `Settings.app` via
`UIApplication.openSettingsURLString` (the only system URL
the extension is allowed to open)
+ `detectAndStoreAppContext` mirrors to `state.appContext` so
the chip updates without waiting for `viewWillAppear`
- `OSGKeyboardExt/Views/KeyboardRootView.swift`
+ overlay mounted in `ZStack` over normal UI (animated)
+ AppContextChip in top bar (hidden during onboarding)
- `OSGKeyboardExt/{en,zh-Hans}.lproj/Keyboard.strings`
+ onboarding copy + chip labels (35 keys per language)
### iOS sandbox notes (kept here for posterity)
- Keyboard extensions **cannot** present AVAudioSession /
SFSpeechRecognizer permission dialogs directly. The overlay's
step 2/3 buttons optimistically advance; the actual permission
is granted when the user first opens the host app (which the
step-5 "Open OSGKeyboard" button triggers). This is the same
pattern the previous "jump to host app" flow used — just
without the broken return trip.
- `UIApplication.openSettingsURLString` is the only system URL
reachable from `extensionContext.open`. Both step 4 and the
"Open Settings" button route through `HostAppLauncher` so the
responder-chain fallback also kicks in if needed.
Co-authored-by: Mavis <Mavis@hkgood.dev>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..<Step.count, id: \.self) { idx in
|
||||
Capsule()
|
||||
.fill(idx <= currentStep.rawValue
|
||||
? palette.accent
|
||||
: palette.divider)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
|
||||
Text(ExtL10n.string("keyboard.onboarding.title"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step content
|
||||
|
||||
private var welcomeStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "waveform.badge.mic",
|
||||
title: "keyboard.onboarding.welcome.title",
|
||||
body: "keyboard.onboarding.welcome.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var microphoneStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "mic.fill",
|
||||
title: "keyboard.onboarding.mic.title",
|
||||
body: "keyboard.onboarding.mic.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var speechStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "ear",
|
||||
title: "keyboard.onboarding.speech.title",
|
||||
body: "keyboard.onboarding.speech.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var keyboardStep: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "keyboard")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(ExtL10n.text("keyboard.onboarding.keyboard.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(ExtL10n.text("keyboard.onboarding.keyboard.body"))
|
||||
.font(TypeStyle.caption1)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button {
|
||||
state.openSystemSettings()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
Text(ExtL10n.text("keyboard.onboarding.keyboard.openSettings"))
|
||||
}
|
||||
.font(TypeStyle.caption1.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs + 2)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.accessibilityLabel(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var apiStep: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "key.fill")
|
||||
.font(.system(size: 32, weight: .light))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(ExtL10n.text("keyboard.onboarding.api.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(ExtL10n.text("keyboard.onboarding.api.body"))
|
||||
.font(TypeStyle.caption1)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(ExtL10n.text("keyboard.onboarding.api.skipHint"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func stepBody(
|
||||
iconSystemName: String,
|
||||
title: String,
|
||||
body: String
|
||||
) -> 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())
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
headerBand
|
||||
|
||||
@@ -94,6 +95,20 @@ public struct KeyboardRootView: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "通用 — 中性口吻";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user