0401f67a89
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>
108 lines
3.8 KiB
Swift
108 lines
3.8 KiB
Swift
// 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)")
|
|
}
|
|
} |