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:
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user