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:
@@ -140,6 +140,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = true
|
isActive = true
|
||||||
|
ScreenWakeLock.acquire()
|
||||||
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
||||||
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
||||||
}
|
}
|
||||||
@@ -187,6 +188,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
capture.stop()
|
capture.stop()
|
||||||
endBackgroundKeepAlive()
|
endBackgroundKeepAlive()
|
||||||
|
ScreenWakeLock.release()
|
||||||
sessionASR = nil
|
sessionASR = nil
|
||||||
FlowSessionBridge.markSessionInactive()
|
FlowSessionBridge.markSessionInactive()
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
@@ -321,6 +323,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.markSessionActive(duration: duration)
|
FlowSessionBridge.markSessionActive(duration: duration)
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = true
|
isActive = true
|
||||||
|
ScreenWakeLock.acquire()
|
||||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
sessionExpiresAt = Date().addingTimeInterval(duration)
|
||||||
|
|
||||||
startHeartbeat()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ struct SettingsView: View {
|
|||||||
localEngineSettingsSection
|
localEngineSettingsSection
|
||||||
}
|
}
|
||||||
if presentation == .tab {
|
if presentation == .tab {
|
||||||
|
preferencesSection
|
||||||
footerLinks
|
footerLinks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +254,27 @@ struct SettingsView: View {
|
|||||||
dynamicLocales = entries
|
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)
|
// MARK: - Footer links (tab settings only)
|
||||||
|
|
||||||
private var footerLinks: some View {
|
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)
|
// MARK: - Picker row (generic)
|
||||||
|
|
||||||
private struct PickerRow: View {
|
private struct PickerRow: View {
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"settings.systemPrompt.edit" = "Edit system prompt";
|
"settings.systemPrompt.edit" = "Edit system prompt";
|
||||||
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
||||||
"settings.about.title" = "About";
|
"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.systemPrompt.reset" = "Reset";
|
||||||
"settings.asrLocale" = "ASR locale";
|
"settings.asrLocale" = "ASR locale";
|
||||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
|
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
"settings.systemPrompt.edit" = "编辑系统提示";
|
"settings.systemPrompt.edit" = "编辑系统提示";
|
||||||
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
||||||
"settings.about.title" = "关于";
|
"settings.about.title" = "关于";
|
||||||
|
"settings.preferences.title" = "偏好设置";
|
||||||
|
"settings.handedness.title" = "握持偏好";
|
||||||
|
"settings.handedness.left" = "左手";
|
||||||
|
"settings.handedness.right" = "右手";
|
||||||
"settings.systemPrompt.reset" = "重置";
|
"settings.systemPrompt.reset" = "重置";
|
||||||
"settings.asrLocale" = "识别语言";
|
"settings.asrLocale" = "识别语言";
|
||||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
|
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ExtensionScreenWakeLock.releaseAll()
|
||||||
cancelPipeline()
|
cancelPipeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +397,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
isFlowRecording = false
|
isFlowRecording = false
|
||||||
stopUtteranceCountdown()
|
stopUtteranceCountdown()
|
||||||
|
ExtensionScreenWakeLock.release()
|
||||||
FlowSessionBridge.setRecordingState(.stopped)
|
FlowSessionBridge.setRecordingState(.stopped)
|
||||||
state.phase = .processing
|
state.phase = .processing
|
||||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||||
@@ -412,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
isFlowRecording = true
|
isFlowRecording = true
|
||||||
state.lastTranscript = ""
|
state.lastTranscript = ""
|
||||||
state.phase = .recording
|
state.phase = .recording
|
||||||
|
ExtensionScreenWakeLock.acquire(from: view)
|
||||||
startUtteranceCountdown()
|
startUtteranceCountdown()
|
||||||
startFlowLevelWatchdog()
|
startFlowLevelWatchdog()
|
||||||
debug("startFlowRecording")
|
debug("startFlowRecording")
|
||||||
@@ -574,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if isFlowRecording || isPendingFlowStart {
|
if isFlowRecording || isPendingFlowStart {
|
||||||
if isFlowRecording {
|
if isFlowRecording {
|
||||||
FlowSessionBridge.setRecordingState(.aborted)
|
FlowSessionBridge.setRecordingState(.aborted)
|
||||||
|
ExtensionScreenWakeLock.release()
|
||||||
}
|
}
|
||||||
isFlowRecording = false
|
isFlowRecording = false
|
||||||
isPendingFlowStart = false
|
isPendingFlowStart = false
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
|
|||||||
// the keyboard stays open.
|
// the keyboard stays open.
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
state.polishScenarioId = store.polishScenarioId
|
state.polishScenarioId = store.polishScenarioId
|
||||||
|
state.handednessPreference = store.handednessPreference
|
||||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||||
// into the State flags so downstream consumers see the same
|
// into the State flags so downstream consumers see the same
|
||||||
@@ -99,6 +100,7 @@ public struct AppGroupPersistor {
|
|||||||
if !shouldProtectScenario {
|
if !shouldProtectScenario {
|
||||||
state.polishScenarioId = store.polishScenarioId
|
state.polishScenarioId = store.polishScenarioId
|
||||||
}
|
}
|
||||||
|
state.handednessPreference = store.handednessPreference
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||||
// toggles here so the keyboard UI doesn't flicker if the host
|
// toggles here so the keyboard UI doesn't flicker if the host
|
||||||
// app briefly clears them while refactoring.
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
// │ [polish] [中] ⚙ │ ← header band (top)
|
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||||
// │ (transcript preview) │
|
// │ (transcript preview) │
|
||||||
// │ ┊ │
|
// │ ┊ │
|
||||||
// │ (⌫) ◯ mic (↩) │ ← action cluster:
|
// │ ◯ mic (centred) │ ← action cluster:
|
||||||
// │ (space) │ centred below header
|
// │ [delete] [ space ] [return] │ mic + bottom row
|
||||||
// │ ┊ │
|
// │ ┊ │
|
||||||
// └───────────────────────────────────────────┘
|
// └───────────────────────────────────────────┘
|
||||||
|
|
||||||
@@ -20,36 +20,39 @@ import SwiftUI
|
|||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
private enum KeyboardLayoutMetrics {
|
private enum KeyboardLayoutMetrics {
|
||||||
static let sideActionButtonSize: CGFloat = 53
|
static let micSize: CGFloat = 121
|
||||||
static let sideActionIconSize: CGFloat = 19
|
static let micToButtonGap: CGFloat = 8
|
||||||
static let sideSpaceBarWidth: CGFloat = 19
|
static let bottomActionRowHeight: CGFloat = 48
|
||||||
static let micFlankMinSpacing: CGFloat = 36
|
static let bottomActionFixedWidth: CGFloat = 86
|
||||||
static let sideActionStackSpacing: CGFloat = 16
|
static let bottomActionSpacing: CGFloat = Spacing.xs
|
||||||
/// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%).
|
/// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%).
|
||||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
|
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
|
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||||
|
|
||||||
// MARK: - Content-driven keyboard height (single source of truth)
|
// MARK: - Content-driven keyboard height (single source of truth)
|
||||||
static let outerPaddingTop: CGFloat = 2
|
static let outerPaddingTop: CGFloat = 2
|
||||||
static let outerPaddingBottom: CGFloat = 6
|
static let outerPaddingBottom: CGFloat = 1
|
||||||
static let topBarHeight: CGFloat = 38
|
static let topBarHeight: CGFloat = 38
|
||||||
static let transcriptLineHeight: CGFloat = 22
|
static let transcriptLineHeight: CGFloat = 22
|
||||||
static let actionClusterHeight: CGFloat = 132
|
/// mic (121) + gap (8) + bottom row (48) = 177 pt
|
||||||
/// Fixed breathing room above/below the mic row (not flexible Spacers).
|
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
||||||
static let actionClusterVerticalGap: CGFloat = Spacing.md
|
/// 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 {
|
static var headerBandHeight: CGFloat {
|
||||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
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 {
|
static var totalHeight: CGFloat {
|
||||||
outerPaddingTop
|
outerPaddingTop
|
||||||
+ headerBandHeight
|
+ headerBandHeight
|
||||||
+ actionClusterVerticalGap
|
+ actionClusterTopGap
|
||||||
+ actionClusterHeight
|
+ actionClusterHeight
|
||||||
+ actionClusterVerticalGap
|
+ actionClusterBottomGap
|
||||||
+ outerPaddingBottom
|
+ outerPaddingBottom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,13 +79,13 @@ public struct KeyboardRootView: View {
|
|||||||
headerBand
|
headerBand
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
|
||||||
|
|
||||||
micActionRow
|
micActionRow
|
||||||
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
|
||||||
}
|
}
|
||||||
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||||
@@ -152,33 +155,29 @@ public struct KeyboardRootView: View {
|
|||||||
|
|
||||||
// MARK: - Action cluster
|
// MARK: - Action cluster
|
||||||
|
|
||||||
/// Delete (left), mic (centre), return + space stacked on the right.
|
/// Mic centred above a bottom row: delete · space · return (or swapped).
|
||||||
/// Fixed vertical gaps in `body` keep the cluster centred without
|
|
||||||
/// flexible Spacers consuming extra keyboard height.
|
|
||||||
private var micActionRow: some View {
|
private var micActionRow: some View {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
let editingBlocked = voiceInputBlocksEditing
|
||||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
let swapKeys = state.handednessPreference.swapsActionKeys
|
||||||
state.deleteBackward()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
|
||||||
|
|
||||||
|
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
||||||
RecordButton(
|
RecordButton(
|
||||||
phase: buttonPhase,
|
phase: buttonPhase,
|
||||||
level: state.level,
|
level: state.level,
|
||||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||||
onToggle: state.tapMic
|
onToggle: state.tapMic
|
||||||
)
|
)
|
||||||
.frame(width: 132, height: 132)
|
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||||
|
|
||||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||||
|
if swapKeys {
|
||||||
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
|
bottomReturnButton(disabled: editingBlocked)
|
||||||
CircularToolbarButton(systemName: "return", label: "newline") {
|
bottomSpaceButton(disabled: editingBlocked)
|
||||||
state.insertNewline()
|
bottomDeleteButton(disabled: editingBlocked)
|
||||||
}
|
} else {
|
||||||
CircularToolbarButton(spaceStyle: true, label: "space") {
|
bottomDeleteButton(disabled: editingBlocked)
|
||||||
state.insertSpace()
|
bottomSpaceButton(disabled: editingBlocked)
|
||||||
|
bottomReturnButton(disabled: editingBlocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +185,43 @@ public struct KeyboardRootView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.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 {
|
private var buttonPhase: RecordButton.Phase {
|
||||||
switch state.phase {
|
switch state.phase {
|
||||||
case .idle: return .idle
|
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)
|
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
|
||||||
|
|
||||||
private struct CloudEngineChip: View {
|
private struct CloudEngineChip: View {
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ struct RecordButton: View {
|
|||||||
return remainingSeconds <= 10
|
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.
|
/// by `KeyboardRootView` so glow / breath animations are not clipped.
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
static let disc: CGFloat = 104
|
static let disc: CGFloat = 95
|
||||||
static let outerRing: CGFloat = 112
|
static let outerRing: CGFloat = 106
|
||||||
static let breathRing: CGFloat = 108
|
static let breathRing: CGFloat = 100
|
||||||
static let glow: CGFloat = 128
|
static let glow: CGFloat = 119
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -65,8 +65,8 @@ struct RecordButton: View {
|
|||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [palette.recordRed.opacity(0.55), .clear],
|
colors: [palette.recordRed.opacity(0.55), .clear],
|
||||||
center: .center,
|
center: .center,
|
||||||
startRadius: 50,
|
startRadius: 46,
|
||||||
endRadius: 100
|
endRadius: 92
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: Layout.glow, height: Layout.glow)
|
.frame(width: Layout.glow, height: Layout.glow)
|
||||||
@@ -93,10 +93,10 @@ struct RecordButton: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
.font(.system(size: 38, weight: .medium))
|
.font(.system(size: 36, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
case .recording:
|
case .recording:
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 3) {
|
||||||
if let remainingSeconds {
|
if let remainingSeconds {
|
||||||
Text(formatRemaining(remainingSeconds))
|
Text(formatRemaining(remainingSeconds))
|
||||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
.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),
|
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
.frame(width: 72, height: 32)
|
.frame(width: 73, height: 32)
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
case .processing:
|
case .processing:
|
||||||
@@ -119,7 +119,7 @@ struct RecordButton: View {
|
|||||||
.scaleEffect(2.5)
|
.scaleEffect(2.5)
|
||||||
case .error:
|
case .error:
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 30, weight: .medium))
|
.font(.system(size: 32, weight: .medium))
|
||||||
.foregroundStyle(palette.warning)
|
.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
|
// FlowUtteranceChunkConfig.swift
|
||||||
// OSGKeyboard · Shared
|
// 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
|
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.
|
// "on" state during init, but new writes never touch the key.
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||||
static let polishScenarioId = "config.polishScenarioId"
|
static let polishScenarioId = "config.polishScenarioId"
|
||||||
|
static let handednessPreference = "config.handednessPreference"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published public var providerId: String {
|
@Published public var providerId: String {
|
||||||
@@ -163,6 +164,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
AppGroupConfigDarwin.postConfigChanged()
|
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
|
/// Whether the pipeline should run translate-and-polish (not just
|
||||||
/// polish). Cloud engine: any selected target locale. Local engine:
|
/// 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.polishScenarioId = PolishScenarioCatalog.defaultId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.handednessPreference = HandednessPreference.fromStored(
|
||||||
|
resolvedDefaults.string(forKey: Key.handednessPreference)
|
||||||
|
)
|
||||||
|
|
||||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||||
@@ -350,6 +362,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
model = preset.defaultModel
|
model = preset.defaultModel
|
||||||
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
||||||
polishScenarioId = PolishScenarioCatalog.defaultId
|
polishScenarioId = PolishScenarioCatalog.defaultId
|
||||||
|
handednessPreference = .left
|
||||||
hasAcknowledgedCloudSharing = false
|
hasAcknowledgedCloudSharing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
// computed shim for source compatibility.
|
// computed shim for source compatibility.
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||||
static let polishScenarioId = "config.polishScenarioId"
|
static let polishScenarioId = "config.polishScenarioId"
|
||||||
|
static let handednessPreference = "config.handednessPreference"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reads
|
// MARK: - Reads
|
||||||
@@ -133,6 +134,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
|
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
|
// MARK: - Writes
|
||||||
|
|
||||||
public func setModeId(_ id: String) {
|
public func setModeId(_ id: String) {
|
||||||
@@ -184,6 +190,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
AppGroupConfigDarwin.postConfigChanged()
|
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.
|
/// Whether ASR output should be sent through the cloud LLM step.
|
||||||
/// Cloud engine: always. Local engine: only when cloud polish is
|
/// Cloud engine: always. Local engine: only when cloud polish is
|
||||||
/// enabled (translation is a sub-option of that step).
|
/// 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.
|
/// Default Flow session length when started from the keyboard.
|
||||||
public static let defaultSessionDuration: TimeInterval = 480
|
public static let defaultSessionDuration: TimeInterval = 480
|
||||||
|
|
||||||
/// Maximum duration for a single keyboard utterance (3 minutes).
|
/// Maximum duration for a single keyboard utterance (3.5 minutes).
|
||||||
public static let maxUtteranceDuration: TimeInterval = 180
|
public static let maxUtteranceDuration: TimeInterval = 210
|
||||||
|
|
||||||
/// Host polls for pipelined ASR drain after mic stop. Pipelining usually
|
/// Host polls for pipelined ASR drain after mic stop. Pipelining usually
|
||||||
/// finishes most chunks during recording; this is a soft deadline before
|
/// 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
|
/// v0.2.0: mirrored from App Group — local engine runs the cloud
|
||||||
/// LLM step only when this is `true`.
|
/// LLM step only when this is `true`.
|
||||||
@Published public var localModeCloudPolishEnabled: Bool = false
|
@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
|
/// Whether translate-and-polish is actually armed for the current
|
||||||
/// engine (local requires cloud polish + a target locale).
|
/// engine (local requires cloud polish + a target locale).
|
||||||
public var isTranslationEffective: Bool {
|
public var isTranslationEffective: Bool {
|
||||||
|
|||||||
+2
-2
@@ -39,8 +39,8 @@ settings:
|
|||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
ENABLE_MODULE_VERIFIER: YES
|
ENABLE_MODULE_VERIFIER: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "0.3.0"
|
MARKETING_VERSION: "0.3.1"
|
||||||
CURRENT_PROJECT_VERSION: "5"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
|
|||||||
Reference in New Issue
Block a user