diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index be493f0..9c7c95f 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -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() diff --git a/OSGKeyboard/Utilities/ScreenWakeLock.swift b/OSGKeyboard/Utilities/ScreenWakeLock.swift new file mode 100644 index 0000000..4be52fb --- /dev/null +++ b/OSGKeyboard/Utilities/ScreenWakeLock.swift @@ -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 + } + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 46cd7db..947161e 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -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 { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 730c1b2..816a638 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -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"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 970448d..5d1f2df 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -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 — 始终端侧"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 2472391..ddbb7f8 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -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 diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 1d8e08a..5021bcf 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -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. diff --git a/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift new file mode 100644 index 0000000..0cfe779 --- /dev/null +++ b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift @@ -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 + } +} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 4469abc..f682432 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -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 { diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index d589698..379b524 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -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) } } diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift new file mode 100644 index 0000000..16bca44 --- /dev/null +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -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: 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? + @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) + } +} diff --git a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift index 10c8505..39da178 100644 --- a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift +++ b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift @@ -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 diff --git a/OSGKeyboardShared/Models/HandednessPreference.swift b/OSGKeyboardShared/Models/HandednessPreference.swift new file mode 100644 index 0000000..1e6535c --- /dev/null +++ b/OSGKeyboardShared/Models/HandednessPreference.swift @@ -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 + } +} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index fb7f2e5..f77822f 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -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 } } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 50fd422..89dc3e3 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -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). diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index 3f0e976..3f78e93 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -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 diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index ad79a53..40fd413 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -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 { diff --git a/project.yml b/project.yml index 11b3547..05b7897 100644 --- a/project.yml +++ b/project.yml @@ -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.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target