feat: polish scenarios and stabilize keyboard layout height

Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
This commit is contained in:
Rocky
2026-06-29 00:08:00 +08:00
parent 4fec0da7f0
commit 1bdb8824ac
32 changed files with 1247 additions and 340 deletions
+105 -31
View File
@@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController {
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
private var translationConfigProtectedUntil: Date?
private var polishScenarioConfigProtectedUntil: Date?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
/// Drives the keyboard slot height on `view` (priority 999).
private var keyboardHeightConstraint: NSLayoutConstraint?
/// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device).
private var systemEncapsulatedHeight: CGFloat = 228
private var targetKeyboardHeight: CGFloat {
KeyboardRootView.totalHeight
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// Keyboard extension MUST opt in to self-sizing, otherwise
// our SwiftUI `frame(height:)` is ignored and the keyboard is
// cropped by the system chrome (Spotlight bar, home indicator).
inputView?.allowsSelfSizing = true
installKeyboardHeight()
configureDictationBehavior()
installStateActions()
installSwiftUI()
loadPersistedConfig()
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
installFlowSessionDarwinObserver()
installConfigDarwinObserver()
refreshFlowSessionState()
}
@@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
@@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController {
startFlowSessionMonitor()
}
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Presentation finished lock to the true content-driven height.
keyboardHeightConstraint?.constant = targetKeyboardHeight
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
@@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController {
refreshDictationProgressStateIfNeeded()
}
// MARK: - System keyboard chrome
/// Tell iOS this keyboard provides its own dictation entry (centre mic).
/// When `true`, the system dictation key in the bottom-right is not shown.
private func configureDictationBehavior() {
hasDictationKey = true
}
// MARK: - Wiring
private func installStateActions() {
@@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController {
// v0.2.1 follow-up: removed `setTranslationEnabled` the chip
// / picker only writes the locale id now; `enabled` is derived.
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
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() }
}
/// 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`.
private func installKeyboardHeight() {
let constraint = view.heightAnchor.constraint(
equalToConstant: targetKeyboardHeight
)
constraint.priority = UILayoutPriority(999)
constraint.isActive = true
keyboardHeightConstraint = constraint
}
/// Read the system encapsulated height and prime our constraint so iOS
/// presentation math (custom + encapsulated) equals `targetKeyboardHeight`.
/// See: https://developer.apple.com/forums/thread/799003
private func applyPresentationHeightOffset() {
if let encapsulated = view.constraints.first(where: { constraint in
constraint.firstItem as? UIView === view
&& constraint.firstAttribute == .height
&& constraint !== keyboardHeightConstraint
}) {
systemEncapsulatedHeight = encapsulated.constant
}
let primed = targetKeyboardHeight - systemEncapsulatedHeight
keyboardHeightConstraint?.constant = max(0, primed)
}
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.clipsToBounds = false
// Keep keyboard layout anchored to the top edge across keyboard
// switches don't let UIHostingController re-inset for safe area.
host.view.insetsLayoutMarginsFromSafeArea = false
host.safeAreaRegions = []
addChild(host)
view.addSubview(host.view)
NSLayoutConstraint.activate([
@@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController {
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// Pin the host view to a fixed height matching KeyboardRootView.totalHeight.
// Without this, iOS lets the system chrome (Spotlight, home
// indicator) bleed into our content. With it, our content area
// is fully reserved and the keyboard feels intentional.
host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight)
])
host.didMove(toParent: self)
self.hosting = host
@@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController {
flowSessionMonitorTask = nil
}
private func installConfigDarwinObserver() {
configDarwinObserver = FlowSessionDarwinObserver(
notificationName: AppGroupConfigDarwin.notificationName
) { [weak self] in
self?.refreshConfigFromAppGroup()
}
}
private func refreshConfigFromAppGroup() {
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
)
}
private func refreshFlowSessionState() {
persistor.refreshRuntimeFlags(into: state)
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
)
consumePendingFlowDeliveryIfNeeded()
let active = FlowSessionBridge.isSessionActive()
@@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController {
debug("received transcript length=\(trimmed.count)")
awaitingDictationResult = false
stopDictationWatchdog()
// Local engine: host app delivers raw ASR transcript; insert as-is.
if state.isLocalEngine {
let runtimeStore = AppGroupStore()
guard runtimeStore.shouldRunCloudLLMStep else {
textDocumentProxy.insertText(trimmed)
state.lastTranscript = ""
if let warning = delivery.polishWarning {
@@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController {
}
return
}
// Cloud engine: always polish via the configured LLM.
// Cloud engine, or local engine with cloud polish / translation.
state.phase = .processing
Task { @MainActor [weak self] in
guard let self else { return }
// v0.2.1: pick the polish mode once at task start so a
// mid-flight toggle flip doesn't change the request we
// already sent. `isTranslationEffective` no longer gates on
// `engineMode == "cloud"` the row visibility predicate
// already keeps the picker honest, and the local engine's
// translate-and-polish path now routes through DeepSeek.
let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
: .polish
// v0.2.1: local engine routes through DeepSeek for the
// polish / translate step regardless of the user's chosen
// cloud provider DeepSeek is cheap and strong on
// Chinese, which is the dominant input for the on-device
// ASR transcript. Cloud engine honors the user's own
// provider id by passing `nil`.
let overrideProviderId: String? = self.state.engineMode == "local"
? "deepseek"
: nil
let polishMode = runtimeStore.polishModeForPipeline
let overrideProviderId = runtimeStore.polishProviderIdOverride
do {
let polished = try await self.polisher.polish(
trimmed,
@@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController {
private func persistTranslationTargetLocaleId(_ id: String) {
let resolved = TranslationLanguageCatalog.resolve(id).id
state.translationTargetLocaleId = resolved
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(translationTargetLocaleId: resolved)
}
private func persistPolishScenarioId(_ id: String) {
let resolved = PolishScenarioCatalog.resolve(id).id
state.polishScenarioId = resolved
polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(polishScenarioId: resolved)
}
// MARK: - Open host app
private func openHostApp(path: String = "settings") {