feat: keyboard bottom-row layout, handedness preference, and screen wake lock

Rework the action cluster to a mic-above-bottom-row layout, add left/right
handedness setting that swaps delete and return, and keep the screen awake
during Flow recording sessions.
This commit is contained in:
Rocky
2026-07-02 21:01:54 +08:00
parent 1bdb8824ac
commit bc77cabb62
18 changed files with 501 additions and 104 deletions
@@ -140,6 +140,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.writeHeartbeat()
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
if let expires = FlowSessionBridge.sessionExpiresAt() {
sessionExpiresAt = Date(timeIntervalSince1970: expires)
}
@@ -187,6 +188,7 @@ final class FlowSessionManager: ObservableObject {
capture.stop()
endBackgroundKeepAlive()
ScreenWakeLock.release()
sessionASR = nil
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
@@ -321,6 +323,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.markSessionActive(duration: duration)
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
sessionExpiresAt = Date().addingTimeInterval(duration)
startHeartbeat()
@@ -0,0 +1,26 @@
// ScreenWakeLock.swift
// OSGKeyboard · Main App
//
// Reference-counted idle-timer disable for Flow session ownership.
import UIKit
@MainActor
enum ScreenWakeLock {
private static var holdCount = 0
static func acquire() {
holdCount += 1
if holdCount == 1 {
UIApplication.shared.isIdleTimerDisabled = true
}
}
static func release() {
guard holdCount > 0 else { return }
holdCount -= 1
if holdCount == 0 {
UIApplication.shared.isIdleTimerDisabled = false
}
}
}
+47
View File
@@ -78,6 +78,7 @@ struct SettingsView: View {
localEngineSettingsSection
}
if presentation == .tab {
preferencesSection
footerLinks
}
}
@@ -253,6 +254,27 @@ struct SettingsView: View {
dynamicLocales = entries
}
// MARK: - Preferences (tab settings only)
private var preferencesSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.preferences.title")
VStack(spacing: 0) {
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Footer links (tab settings only)
private var footerLinks: some View {
@@ -366,6 +388,31 @@ struct SettingsView: View {
}
}
// MARK: - Handedness picker row
private struct HandednessPickerRow: View {
@Binding var selection: HandednessPreference
private var options: [(id: String, label: String)] {
HandednessPreference.allCases.map { preference in
(preference.rawValue, AppL10n.string(preference.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.handedness.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = HandednessPreference(rawValue: newValue) ?? .left
}
)
)
}
}
// MARK: - Picker row (generic)
private struct PickerRow: View {
+4
View File
@@ -135,6 +135,10 @@
"settings.systemPrompt.edit" = "Edit system prompt";
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About";
"settings.preferences.title" = "Preferences";
"settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -135,6 +135,10 @@
"settings.systemPrompt.edit" = "编辑系统提示";
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于";
"settings.preferences.title" = "偏好设置";
"settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手";
"settings.handedness.right" = "右手";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -115,6 +115,7 @@ public final class KeyboardViewController: UIInputViewController {
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
return
}
ExtensionScreenWakeLock.releaseAll()
cancelPipeline()
}
@@ -396,6 +397,7 @@ public final class KeyboardViewController: UIInputViewController {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.stopped)
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
@@ -412,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController {
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
ExtensionScreenWakeLock.acquire(from: view)
startUtteranceCountdown()
startFlowLevelWatchdog()
debug("startFlowRecording")
@@ -574,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController {
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
ExtensionScreenWakeLock.release()
}
isFlowRecording = false
isPendingFlowStart = false
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
// the keyboard stays open.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.polishScenarioId = store.polishScenarioId
state.handednessPreference = store.handednessPreference
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
// into the State flags so downstream consumers see the same
@@ -99,6 +100,7 @@ public struct AppGroupPersistor {
if !shouldProtectScenario {
state.polishScenarioId = store.polishScenarioId
}
state.handednessPreference = store.handednessPreference
// 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.
@@ -0,0 +1,45 @@
// ExtensionScreenWakeLock.swift
// OSGKeyboard · Keyboard Extension
//
// Keyboard extensions cannot call `UIApplication.shared`; walk the
// responder chain to reach the host app's `UIApplication` instead.
import UIKit
@MainActor
enum ExtensionScreenWakeLock {
private static var holdCount = 0
private static weak var capturedApplication: UIApplication?
static func acquire(from responder: UIResponder) {
holdCount += 1
if holdCount == 1 {
capturedApplication = findApplication(from: responder)
capturedApplication?.isIdleTimerDisabled = true
}
}
static func release() {
guard holdCount > 0 else { return }
holdCount -= 1
if holdCount == 0 {
capturedApplication?.isIdleTimerDisabled = false
capturedApplication = nil
}
}
static func releaseAll() {
holdCount = 0
capturedApplication?.isIdleTimerDisabled = false
capturedApplication = nil
}
private static func findApplication(from responder: UIResponder) -> UIApplication? {
var current: UIResponder? = responder
while let node = current {
if let application = node as? UIApplication { return application }
current = node.next
}
return nil
}
}
+71 -88
View File
@@ -11,8 +11,8 @@
// [polish] [] header band (top)
// (transcript preview)
//
// () mic () action cluster:
// (space) centred below header
// mic (centred) action cluster:
// [delete] [ space ] [return] mic + bottom row
//
//
@@ -20,36 +20,39 @@ import SwiftUI
import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 53
static let sideActionIconSize: CGFloat = 19
static let sideSpaceBarWidth: CGFloat = 19
static let micFlankMinSpacing: CGFloat = 36
static let sideActionStackSpacing: CGFloat = 16
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// 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%).
/// Outer inset for the bottom action row 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 outerPaddingBottom: CGFloat = 1
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
/// mic (121) + gap (8) + bottom row (48) = 177 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic (30% from former 16 pt).
static let actionClusterTopGap: CGFloat = Spacing.md * 0.7
/// Minimal gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = Spacing.xs / 2
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterVerticalGap
+ actionClusterTopGap
+ actionClusterHeight
+ actionClusterVerticalGap
+ actionClusterBottomGap
+ outerPaddingBottom
}
}
@@ -76,13 +79,13 @@ public struct KeyboardRootView: View {
headerBand
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
@@ -152,33 +155,29 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster
/// Delete (left), mic (centre), return + space stacked on the right.
/// Fixed vertical gaps in `body` keep the cluster centred without
/// flexible Spacers consuming extra keyboard height.
/// Mic centred above a bottom row: delete · space · return (or swapped).
private var micActionRow: some View {
HStack(alignment: .center, spacing: 0) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 132, height: 132)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
CircularToolbarButton(systemName: "return", label: "newline") {
state.insertNewline()
}
CircularToolbarButton(spaceStyle: true, label: "space") {
state.insertSpace()
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked)
} else {
bottomDeleteButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
}
}
}
@@ -186,6 +185,43 @@ public struct KeyboardRootView: View {
.frame(maxWidth: .infinity)
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
state.insertNewline()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
}
/// Option C: block typing keys during the full voice-input pipeline.
private var voiceInputBlocksEditing: Bool {
switch state.phase {
case .requestingPermissions, .recording, .processing:
return true
case .idle, .error, .denied:
return false
}
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
@@ -337,59 +373,6 @@ private struct TranscriptLine: View {
}
}
// MARK: - Circular toolbar button
private struct CircularToolbarButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String?
let spaceStyle: Bool
let label: String
let action: () -> Void
init(systemName: String, label: String, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.action = action
}
init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
.foregroundStyle(palette.textPrimary)
}
}
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
.background(sideButtonFill, in: Circle())
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
private var sideButtonFill: Color {
colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
}
}
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
private struct CloudEngineChip: View {
+11 -11
View File
@@ -42,13 +42,13 @@ struct RecordButton: View {
return remainingSeconds <= 10
}
/// Decorative rings are sized to stay inside the 132 pt frame applied
/// Decorative rings are sized to stay inside the 121 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
static let disc: CGFloat = 95
static let outerRing: CGFloat = 106
static let breathRing: CGFloat = 100
static let glow: CGFloat = 119
}
var body: some View {
@@ -65,8 +65,8 @@ struct RecordButton: View {
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 50,
endRadius: 100
startRadius: 46,
endRadius: 92
)
)
.frame(width: Layout.glow, height: Layout.glow)
@@ -93,10 +93,10 @@ struct RecordButton: View {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
case .recording:
VStack(spacing: 4) {
VStack(spacing: 3) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
@@ -109,7 +109,7 @@ struct RecordButton: View {
color: Color(red: 1.0, green: 0.78, blue: 0.78),
active: true
)
.frame(width: 72, height: 32)
.frame(width: 73, height: 32)
}
.transition(.opacity)
case .processing:
@@ -119,7 +119,7 @@ struct RecordButton: View {
.scaleEffect(2.5)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 30, weight: .medium))
.font(.system(size: 32, weight: .medium))
.foregroundStyle(palette.warning)
}
}
@@ -0,0 +1,224 @@
// ToolbarActionButtons.swift
// OSGKeyboard · Keyboard Extension
//
// Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import UIKit
import OSGKeyboardShared
// MARK: - Layout metrics
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
static let pressOverlayOpacity: CGFloat = 0.18
}
// MARK: - Haptics
private enum ToolbarHaptics {
@MainActor
static func tap() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
// MARK: - Press styling
private struct ToolbarKeyPressStyle: ButtonStyle {
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.overlay {
if configuration.isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in
pressed
}
}
}
private struct ToolbarKeySurface<Content: View>: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
let isPressed: Bool
let cornerRadius: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
}
.overlay {
if isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: isPressed)
}
private var buttonFill: Color {
let base = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return isPressed ? base.opacity(0.82) : base
}
}
// MARK: - Repeating delete
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
let disabled: Bool
let action: () -> Void
@State private var isPressing = false
@State private var repeatTask: Task<Void, Never>?
@State private var repeatStartedAt: Date?
private let initialDelay: TimeInterval = 0.4
private let normalInterval: TimeInterval = 0.08
private let accelTier2: TimeInterval = 0.05
private let accelTier3: TimeInterval = 0.03
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
ToolbarHaptics.tap()
action()
startRepeating()
}
.onEnded { _ in
stopRepeating()
}
}
private func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
}
private func startRepeating() {
repeatTask?.cancel()
repeatTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000))
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
action()
let elapsed = Date().timeIntervalSince(anchor)
let wait = interval(for: elapsed)
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
}
}
private func stopRepeating() {
isPressing = false
repeatStartedAt = nil
repeatTask?.cancel()
repeatTask = nil
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.themePalette) private var palette
let systemName: String?
let spaceStyle: Bool
let label: String
let disabled: Bool
let action: () -> Void
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.action = action
}
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.disabled = disabled
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(keyBackground)
.overlay(
RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius))
.disabled(disabled)
.opacity(disabled ? 0.38 : 1)
.accessibilityLabel(Text(label))
}
@Environment(\.colorScheme) private var colorScheme
private var keyBackground: some View {
let fill = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.fill(fill)
}
}
@@ -1,7 +1,7 @@
// FlowUtteranceChunkConfig.swift
// OSGKeyboard · Shared
//
// Chunking policy for pipelined Flow utterance ASR (up to 3 minutes).
// Chunking policy for pipelined Flow utterance ASR (up to 3.5 minutes).
import Foundation
@@ -0,0 +1,29 @@
// HandednessPreference.swift
// OSGKeyboard · Shared
//
// Which hand the user holds the phone with controls bottom-row key order
// on the keyboard (delete return swap for right-handed use).
import Foundation
public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable, Codable {
case left
case right
public var id: String { rawValue }
public var labelKey: String {
switch self {
case .left: return "settings.handedness.left"
case .right: return "settings.handedness.right"
}
}
/// Right-handed preference places return on the left and delete on the right.
public var swapsActionKeys: Bool { self == .right }
public static func fromStored(_ raw: String?) -> HandednessPreference {
guard let raw, let value = HandednessPreference(rawValue: raw) else { return .left }
return value
}
}
@@ -52,6 +52,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// "on" state during init, but new writes never touch the key.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let polishScenarioId = "config.polishScenarioId"
static let handednessPreference = "config.handednessPreference"
}
@Published public var providerId: String {
@@ -163,6 +164,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
}
/// Which hand the user holds the phone with mirrors to the keyboard
/// extension so delete / return can swap on the bottom row.
@Published public var handednessPreference: HandednessPreference {
didSet {
defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference)
AppGroupConfigDarwin.postConfigChanged()
}
}
/// Whether the pipeline should run translate-and-polish (not just
/// polish). Cloud engine: any selected target locale. Local engine:
@@ -299,6 +308,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.polishScenarioId = PolishScenarioCatalog.defaultId
}
}
self.handednessPreference = HandednessPreference.fromStored(
resolvedDefaults.string(forKey: Key.handednessPreference)
)
// Cloud no longer exposes off/transcribe; migrate legacy values.
if self.engineMode == "cloud", self.modeId != "polish" {
@@ -350,6 +362,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
model = preset.defaultModel
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
polishScenarioId = PolishScenarioCatalog.defaultId
handednessPreference = .left
hasAcknowledgedCloudSharing = false
}
}
@@ -46,6 +46,7 @@ public struct AppGroupStore: @unchecked Sendable {
// computed shim for source compatibility.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let polishScenarioId = "config.polishScenarioId"
static let handednessPreference = "config.handednessPreference"
}
// MARK: - Reads
@@ -133,6 +134,11 @@ public struct AppGroupStore: @unchecked Sendable {
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
}
/// Bottom-row key order on the keyboard extension.
public var handednessPreference: HandednessPreference {
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
}
// MARK: - Writes
public func setModeId(_ id: String) {
@@ -184,6 +190,11 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setHandednessPreference(_ preference: HandednessPreference) {
defaults.set(preference.rawValue, forKey: Key.handednessPreference)
AppGroupConfigDarwin.postConfigChanged()
}
/// Whether ASR output should be sent through the cloud LLM step.
/// Cloud engine: always. Local engine: only when cloud polish is
/// enabled (translation is a sub-option of that step).
@@ -24,8 +24,8 @@ public enum FlowSessionKeys {
/// Default Flow session length when started from the keyboard.
public static let defaultSessionDuration: TimeInterval = 480
/// Maximum duration for a single keyboard utterance (3 minutes).
public static let maxUtteranceDuration: TimeInterval = 180
/// Maximum duration for a single keyboard utterance (3.5 minutes).
public static let maxUtteranceDuration: TimeInterval = 210
/// Host polls for pipelined ASR drain after mic stop. Pipelining usually
/// finishes most chunks during recording; this is a soft deadline before
@@ -103,6 +103,8 @@ public final class KeyboardState: ObservableObject {
/// v0.2.0: mirrored from App Group local engine runs the cloud
/// LLM step only when this is `true`.
@Published public var localModeCloudPolishEnabled: Bool = false
/// Mirrored from App Group swaps delete / return on the bottom row.
@Published public var handednessPreference: HandednessPreference = .left
/// Whether translate-and-polish is actually armed for the current
/// engine (local requires cloud polish + a target locale).
public var isTranslationEffective: Bool {
+2 -2
View File
@@ -39,8 +39,8 @@ settings:
GENERATE_INFOPLIST_FILE: NO
ENABLE_MODULE_VERIFIER: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "0.3.0"
CURRENT_PROJECT_VERSION: "5"
MARKETING_VERSION: "0.3.1"
CURRENT_PROJECT_VERSION: "6"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target