Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
162 lines
6.2 KiB
Swift
162 lines
6.2 KiB
Swift
// KeyboardSurfaceRoot.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// Switches between voice and typing surfaces driven by KeyboardState.surface.
|
|
|
|
import OSGKeyboardShared
|
|
import SwiftUI
|
|
|
|
struct KeyboardSurfaceRoot: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Namespace private var keyboardTabSelectionNamespace
|
|
|
|
var state: KeyboardState
|
|
@ObservedObject var typing: TypingSessionController
|
|
|
|
var onInsert: (String) -> Void
|
|
var onDeleteBackward: () -> Void
|
|
|
|
private var palette: ThemePalette {
|
|
colorScheme == .dark ? Palette.dark : Palette.light
|
|
}
|
|
|
|
/// True only for overlays that replace the keyboard surface. The auto-reply
|
|
/// guide is excluded — it dims the keyboard but keeps it visible underneath.
|
|
private var surfacesHidden: Bool {
|
|
switch state.clipboardOverlay {
|
|
case .enableGuide, .historyPanel:
|
|
return true
|
|
case .none, .autoReplyGuide:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Height is deliberately independent of the surface: the voice surface
|
|
/// adopts the typing surface's content-driven height and parks the surplus
|
|
/// above its action cluster, so switching surfaces never resizes the
|
|
/// keyboard. Keeping this a single expression is what guarantees it.
|
|
static func height(
|
|
for surface: KeyboardState.Surface,
|
|
isIPad: Bool = false,
|
|
width: CGFloat = 0,
|
|
isExpandedPhone: Bool = false,
|
|
isCompactHeightPhone: Bool = false
|
|
) -> CGFloat {
|
|
TypingSurfaceMetrics.contentHeight(
|
|
isIPad: isIPad,
|
|
width: width,
|
|
isExpandedPhone: isExpandedPhone,
|
|
isCompactHeightPhone: isCompactHeightPhone
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Group {
|
|
switch state.surface {
|
|
case .voice:
|
|
AIKeyboardView(
|
|
state: state,
|
|
typing: typing,
|
|
onInsert: wrappedInsert
|
|
)
|
|
case .typing:
|
|
TypingRootView(
|
|
state: state,
|
|
typing: typing,
|
|
onInsert: wrappedInsert,
|
|
onDeleteBackward: wrappedDeleteBackward
|
|
)
|
|
case .ai:
|
|
// Legacy persisted surface; controller canonicalizes it to
|
|
// `.voice`, but render the unified assistant defensively.
|
|
AIKeyboardView(
|
|
state: state,
|
|
typing: typing,
|
|
onInsert: wrappedInsert
|
|
)
|
|
}
|
|
}
|
|
// The enable-guide / history panel replace the surface; the
|
|
// auto-reply guide instead layers over the still-visible keyboard.
|
|
.opacity(surfacesHidden ? 0 : 1)
|
|
.allowsHitTesting(!surfacesHidden)
|
|
|
|
// Match every surface's outer chrome so the panel title / X sit in
|
|
// the same slot as the logo + clipboard chip (not 4 pt higher).
|
|
clipboardOverlayLayer
|
|
.padding(.top, TypingSurfaceMetrics.outerPaddingTop)
|
|
.padding(.bottom, TypingSurfaceMetrics.outerPaddingBottom)
|
|
|
|
// Scrim-based nudge sits on top of the live keyboard, no padding so
|
|
// the dim reaches every edge.
|
|
if state.clipboardOverlay == .autoReplyGuide, state.canShowClipboardEntry {
|
|
ClipboardAutoReplyGuideView(
|
|
onClose: state.dismissClipboardOverlay,
|
|
onTry: state.tryAutoReplyFromGuide
|
|
)
|
|
}
|
|
}
|
|
// Overlays are siblings of the surfaces, so the palette has to be
|
|
// injected here or they fall back to the environment's dark default.
|
|
.environment(\.themePalette, palette)
|
|
// The input surfaces are replaced when switching tabs. Keep one
|
|
// namespace above that switch so the selected glass pill can morph
|
|
// between the outgoing and incoming top-control instances.
|
|
.environment(\.keyboardTabSelectionNamespace, keyboardTabSelectionNamespace)
|
|
// Bottom-anchored on purpose: UIKit hands the input view a container up
|
|
// to the full screen height while the keyboard slides in, and centering
|
|
// a fixed-height surface in it parks the whole keyboard above the
|
|
// visible slot — which reads as a blank keyboard whenever that frame
|
|
// lingers (a slow Universal Clipboard read, a system alert).
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
.animation(.easeInOut(duration: 0.15), value: state.surface)
|
|
}
|
|
|
|
private func wrappedInsert(_ text: String) {
|
|
if !text.isEmpty {
|
|
state.noteUserDidInputText()
|
|
}
|
|
onInsert(text)
|
|
}
|
|
|
|
private func wrappedDeleteBackward() {
|
|
state.noteUserDidInputText()
|
|
onDeleteBackward()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var clipboardOverlayLayer: some View {
|
|
if !state.canShowClipboardEntry {
|
|
EmptyView()
|
|
} else {
|
|
switch state.clipboardOverlay {
|
|
case .none:
|
|
EmptyView()
|
|
case .enableGuide:
|
|
ClipboardEnableGuideView(
|
|
onClose: state.dismissClipboardOverlay,
|
|
onOpenSettings: {
|
|
state.dismissClipboardOverlay()
|
|
state.openClipboardSettings()
|
|
}
|
|
)
|
|
case .historyPanel:
|
|
ClipboardHistoryPanelView(
|
|
history: ClipboardHistoryStore.shared,
|
|
onClose: state.dismissClipboardOverlay,
|
|
onClear: state.clearClipboardHistory,
|
|
onInsert: { state.insertClipboardText($0) },
|
|
onDelete: { state.deleteClipboardHistoryEntry($0) },
|
|
pastePermissionHint: state.needsClipboardFullAccessHint
|
|
? ExtL10n.string("keyboard.clipboard.panel.fullAccessHint")
|
|
: nil
|
|
)
|
|
case .autoReplyGuide:
|
|
// Rendered as a scrim layer over the live keyboard, not here.
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
}
|