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:
@@ -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") {
|
||||
|
||||
@@ -40,6 +40,8 @@ public struct AppGroupPersistor {
|
||||
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
||||
// the keyboard stays open.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||
// into the State flags so downstream consumers see the same
|
||||
// shape they did when the previous Qwen3 stack reported "ready".
|
||||
@@ -75,14 +77,28 @@ public struct AppGroupPersistor {
|
||||
|
||||
/// Lightweight refresh for flags the host app may update while the
|
||||
/// keyboard stays open (model downloads, engine switches).
|
||||
public func refreshRuntimeFlags(into state: KeyboardViewController.State) {
|
||||
///
|
||||
/// When `protectTranslationUntil` is in the future, the translation
|
||||
/// target locale is not overwritten — avoids the 1 Hz poll clobbering
|
||||
/// a chip selection the user just wrote to the App Group.
|
||||
public func refreshRuntimeFlags(
|
||||
into state: KeyboardViewController.State,
|
||||
protectTranslationUntil: Date? = nil,
|
||||
protectPolishScenarioUntil: Date? = nil
|
||||
) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
// v0.2.1 follow-up: same as `load` — only the locale is
|
||||
// persisted, `enabled` is derived.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||
if !shouldProtectTranslation {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
}
|
||||
let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false
|
||||
if !shouldProtectScenario {
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
}
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||
// toggles here so the keyboard UI doesn't flicker if the host
|
||||
// app briefly clears them while refactoring.
|
||||
@@ -127,4 +143,9 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
||||
}
|
||||
|
||||
public func persist(polishScenarioId: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setPolishScenarioId(polishScenarioId)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,17 @@
|
||||
//
|
||||
// Typeless-inspired keyboard surface. The keyboard is laid out in three
|
||||
// vertical bands, but the entire height is reserved for us — we set
|
||||
// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's
|
||||
// frame is honoured, and we add safe-area insets at the top and bottom so
|
||||
// the system Spotlight / home-indicator chrome never clips our controls.
|
||||
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
|
||||
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — see presentation offset
|
||||
// in `applyPresentationHeightOffset()`.
|
||||
//
|
||||
// ┌───────────────────────────────────────────┐
|
||||
// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%)
|
||||
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||
// │ (transcript preview) │
|
||||
// │ │
|
||||
// │ (⌫) ◯ mic (↩) │ ← action row: circular
|
||||
// │ (space) │ flanking buttons
|
||||
// │ ┊ │
|
||||
// │ (⌫) ◯ mic (↩) │ ← action cluster:
|
||||
// │ (space) │ centred below header
|
||||
// │ ┊ │
|
||||
// └───────────────────────────────────────────┘
|
||||
|
||||
import SwiftUI
|
||||
@@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics {
|
||||
static let sideSpaceBarWidth: CGFloat = 19
|
||||
static let micFlankMinSpacing: CGFloat = 36
|
||||
static let sideActionStackSpacing: CGFloat = 16
|
||||
/// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%).
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
|
||||
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 2
|
||||
static let outerPaddingBottom: CGFloat = 6
|
||||
static let topBarHeight: CGFloat = 38
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
static let actionClusterHeight: CGFloat = 132
|
||||
/// Fixed breathing room above/below the mic row (not flexible Spacers).
|
||||
static let actionClusterVerticalGap: CGFloat = Spacing.md
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
|
||||
static var totalHeight: CGFloat {
|
||||
outerPaddingTop
|
||||
+ headerBandHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ actionClusterHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ outerPaddingBottom
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@@ -37,11 +63,9 @@ public struct KeyboardRootView: View {
|
||||
self.state = state
|
||||
}
|
||||
|
||||
/// Total keyboard height. We set the same value as a height-anchor
|
||||
/// constraint in the view controller so the host UIInputView picks
|
||||
/// it up.
|
||||
static let totalHeight: CGFloat = 280
|
||||
private static let topBarHeight: CGFloat = 38
|
||||
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
|
||||
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
|
||||
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
@@ -49,14 +73,19 @@ public struct KeyboardRootView: View {
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
.frame(height: Self.topBarHeight)
|
||||
headerBand
|
||||
|
||||
centreArea
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
|
||||
micActionRow
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
||||
.background(Color.clear)
|
||||
.frame(height: Self.totalHeight)
|
||||
@@ -64,6 +93,26 @@ public struct KeyboardRootView: View {
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
|
||||
/// Top chip row + transcript / hint line.
|
||||
private var headerBand: some View {
|
||||
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
|
||||
topBar
|
||||
.frame(height: KeyboardLayoutMetrics.topBarHeight)
|
||||
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
@@ -73,22 +122,20 @@ public struct KeyboardRootView: View {
|
||||
} else {
|
||||
CloudEngineChip()
|
||||
}
|
||||
if state.isPolishScenarioChipVisible {
|
||||
ScenarioChip(state: state)
|
||||
}
|
||||
LocaleChip(localeId: state.localeId) { newId in
|
||||
state.setLocale(newId)
|
||||
}
|
||||
// v0.2.1: translation chip — sits next to the locale picker
|
||||
// and doubles as both the on/off switch and the target-
|
||||
// language picker (Menu pattern matches LocaleChip so the
|
||||
// top bar stays visually consistent).
|
||||
// v0.2.1 final review: only render the chip when translation
|
||||
// is actually on. Off-by-default keeps the top bar compact
|
||||
// for users who don't need translation; the menu still lives
|
||||
// in onboarding so the feature is discoverable.
|
||||
if state.translationEnabled {
|
||||
// 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
|
||||
// without opening Settings.
|
||||
if state.isTranslationChipVisible {
|
||||
TranslationChip(state: state)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||
Button(action: state.openSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
@@ -103,35 +150,11 @@ public struct KeyboardRootView: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
// MARK: - Centre area
|
||||
|
||||
private var centreArea: some View {
|
||||
VStack(spacing: Spacing.xxs) {
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
.frame(height: 22)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
micActionRow
|
||||
.padding(.bottom, Spacing.xs)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Delete (left), mic (centre), return + space stacked on the right.
|
||||
/// HStack vertical alignment keeps delete, mic centre, and the gap
|
||||
/// between return/space on one horizontal axis.
|
||||
/// Fixed vertical gaps in `body` keep the cluster centred without
|
||||
/// flexible Spacers consuming extra keyboard height.
|
||||
private var micActionRow: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
||||
@@ -186,19 +209,19 @@ extension KeyboardRootView {
|
||||
#if DEBUG
|
||||
#Preview("Keyboard · Idle") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Recording") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Processing") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
@@ -367,62 +390,6 @@ private struct CircularToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status badge
|
||||
|
||||
private struct StatusBadge: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
/// Reflects whether the active ASR session is on-device. We surface
|
||||
/// a small ⚠️ during recording so the user knows their audio is
|
||||
/// going to the cloud for this locale (and so devs catch it during
|
||||
/// QA without staring at the Xcode console).
|
||||
let onDeviceSupported: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .requestingPermissions:
|
||||
EmptyView()
|
||||
case .recording:
|
||||
if onDeviceSupported {
|
||||
dot(color: palette.recordRed, labelKey: "keyboard.status.rec")
|
||||
} else {
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true)
|
||||
}
|
||||
case .processing:
|
||||
dot(color: palette.accent, labelKey: "keyboard.status.processing")
|
||||
case .error:
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.error")
|
||||
case .denied:
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
if showWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
Text(ExtL10n.string(labelKey))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
|
||||
|
||||
private struct CloudEngineChip: View {
|
||||
|
||||
@@ -42,11 +42,20 @@ struct RecordButton: View {
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
/// Decorative rings are sized to stay inside the 132 pt frame applied
|
||||
/// by `KeyboardRootView` so glow / breath animations are not clipped.
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 104
|
||||
static let outerRing: CGFloat = 112
|
||||
static let breathRing: CGFloat = 108
|
||||
static let glow: CGFloat = 128
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: 150, height: 150)
|
||||
.frame(width: Layout.breathRing, height: Layout.breathRing)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
@@ -60,7 +69,7 @@ struct RecordButton: View {
|
||||
endRadius: 100
|
||||
)
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
.frame(width: Layout.glow, height: Layout.glow)
|
||||
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
||||
.blur(radius: 18)
|
||||
.animation(Motion.soft, value: phase)
|
||||
@@ -71,7 +80,7 @@ struct RecordButton: View {
|
||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -115,7 +124,7 @@ struct RecordButton: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
.frame(width: Layout.disc, height: Layout.disc)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ScenarioChip.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Compact chip on the keyboard top bar for quick polish scenario
|
||||
// switching. Same Menu pattern as `TranslationChip`.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ScenarioChip: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var state: KeyboardViewController.State
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(PolishScenarioCatalog.all) { scenario in
|
||||
Button {
|
||||
state.setPolishScenarioId(scenario.id)
|
||||
} label: {
|
||||
if scenario.id == state.polishScenarioId {
|
||||
Label(displayLabel(for: scenario), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: scenario))
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint"))
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
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 {
|
||||
PolishScenarioCatalog.chipLabel(for: state.polishScenarioId)
|
||||
}
|
||||
|
||||
private func displayLabel(for scenario: PolishScenario) -> String {
|
||||
PolishScenarioCatalog.displayName(for: scenario.id)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
// off / on, with the same accent treatment either way.
|
||||
//
|
||||
// Visual states:
|
||||
// • off → dim outline, "翻译" label
|
||||
// • off → dim outline, "翻译" chip label (menu first row = "不翻译")
|
||||
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
||||
//
|
||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||
@@ -88,14 +88,14 @@ struct TranslationChip: View {
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return ExtL10n.string("keyboard.translation.off")
|
||||
return ExtL10n.string("keyboard.translation.offMenu")
|
||||
}
|
||||
return language.nativeName
|
||||
}
|
||||
|
||||
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||
if !enabled {
|
||||
return ExtL10n.string("keyboard.translation.off")
|
||||
return ExtL10n.string("keyboard.translation.chip")
|
||||
}
|
||||
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
||||
// language name for languages without a chip-style abbreviation
|
||||
|
||||
@@ -176,12 +176,16 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* Translation chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "Translate";
|
||||
/* Translation chip (v0.3) */
|
||||
"keyboard.translation.chip" = "Translate";
|
||||
"keyboard.translation.offMenu" = "Don't translate";
|
||||
"keyboard.translation.off" = "Don't translate";
|
||||
"keyboard.translation.enable" = "Enable translation";
|
||||
"keyboard.translation.disable" = "Disable translation";
|
||||
"keyboard.translation.a11y" = "Translation";
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
"keyboard.scenario.a11y" = "Polish scenario";
|
||||
"keyboard.scenario.a11yHint" = "Choose how dictation is polished.";
|
||||
|
||||
/* Mode chip labels (used in both ext + preview stub) */
|
||||
"mode.off" = "Off";
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
|
||||
"settings.provider.title" = "提供商";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"settings.api.title" = "接口";
|
||||
"settings.language.title" = "语言";
|
||||
@@ -176,12 +176,16 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* 翻译 chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "翻译";
|
||||
/* Translation chip (v0.3) */
|
||||
"keyboard.translation.chip" = "翻译";
|
||||
"keyboard.translation.offMenu" = "不翻译";
|
||||
"keyboard.translation.off" = "不翻译";
|
||||
"keyboard.translation.enable" = "开启翻译";
|
||||
"keyboard.translation.disable" = "关闭翻译";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||
"keyboard.scenario.a11y" = "润色场景";
|
||||
"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。";
|
||||
|
||||
/* Mode chip labels */
|
||||
"mode.off" = "关闭";
|
||||
|
||||
Reference in New Issue
Block a user