feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start, tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
This commit is contained in:
@@ -39,8 +39,8 @@
|
||||
<string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
|
||||
<string>OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OSGKeyboard uses on-device speech recognition to transcribe your dictation. Audio never leaves your device.</string>
|
||||
<string>OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||||
// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline:
|
||||
//
|
||||
// AudioCaptureService ──► ASRService ──► PolishingService ──► insertText
|
||||
// host app dictation handoff ──► App Group transcript ──► insertText
|
||||
//
|
||||
// Design notes:
|
||||
// • The class is `@MainActor` — every UI mutation and `textDocumentProxy`
|
||||
@@ -19,12 +19,22 @@
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@objc(KeyboardViewController)
|
||||
@MainActor
|
||||
public final class KeyboardViewController: UIInputViewController {
|
||||
private enum FlowWatchdog {
|
||||
static let pollIntervalNs: UInt64 = 200_000_000
|
||||
/// Give the user time to manually open the host app when auto-jump fails.
|
||||
static let startTimeout: TimeInterval = 30
|
||||
static let resultTimeout: TimeInterval = 45
|
||||
}
|
||||
|
||||
private enum DictationWatchdog {
|
||||
static let pollIntervalNs: UInt64 = 400_000_000
|
||||
static let timeout: TimeInterval = 45
|
||||
}
|
||||
|
||||
// MARK: - View model
|
||||
|
||||
@@ -37,17 +47,21 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// MARK: - State
|
||||
|
||||
private let state = State()
|
||||
private let audio = AudioCaptureService()
|
||||
private let asr: ASRService = ASRServiceFactory.make()
|
||||
private let polisher = PolishingService()
|
||||
private let permissions = PermissionManager()
|
||||
private let persistor = AppGroupPersistor()
|
||||
|
||||
private var session: AudioCaptureService.Session?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var levelTask: Task<Void, Never>?
|
||||
|
||||
private var hosting: UIHostingController<KeyboardRootView>!
|
||||
/// Legacy one-shot handoff (`osgkeyboard://dictate`).
|
||||
private var awaitingDictationResult = false
|
||||
private var dictationRequestStartedAt: TimeInterval = 0
|
||||
private var dictationWatchdogTask: Task<Void, Never>?
|
||||
/// Flow session: waiting for host app to come alive after `startflow`.
|
||||
private var isPendingFlowStart = false
|
||||
private var flowStartDeadline: TimeInterval = 0
|
||||
private var isFlowRecording = false
|
||||
private var flowWatchdogTask: Task<Void, Never>?
|
||||
private var utteranceTimerTask: Task<Void, Never>?
|
||||
private var utteranceStartedAt: TimeInterval = 0
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@@ -60,6 +74,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
installStateActions()
|
||||
installSwiftUI()
|
||||
loadPersistedConfig()
|
||||
consumePendingDictationResultIfNeeded()
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
@@ -67,6 +83,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
consumePendingDictationResultIfNeeded()
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
cancelPipeline()
|
||||
@@ -74,7 +96,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func textDidChange(_ textInput: (any UITextInput)?) {
|
||||
super.textDidChange(textInput)
|
||||
// Hook for future per-app mode switching (e.g. password field → .off).
|
||||
consumePendingDictationResultIfNeeded()
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Wiring
|
||||
@@ -82,7 +105,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private func installStateActions() {
|
||||
state.beginRecording = { [weak self] in self?.pressBegan() }
|
||||
state.endRecording = { [weak self] in self?.pressEnded() }
|
||||
state.tapMic = { [weak self] in self?.advanceToNextInputMode() }
|
||||
state.tapMic = { [weak self] in self?.toggleRecording() }
|
||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.setMode = { [weak self] m in self?.persistMode(m) }
|
||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||
@@ -125,11 +148,18 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
// MARK: - Press handlers
|
||||
|
||||
private func toggleRecording() {
|
||||
switch state.phase {
|
||||
case .recording:
|
||||
pressEnded()
|
||||
case .idle, .denied, .error:
|
||||
pressBegan()
|
||||
case .requestingPermissions, .processing:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func pressBegan() {
|
||||
// Allow re-entry from `.denied` and from a finished/cleared
|
||||
// `.error` so the user can simply press the mic again after
|
||||
// returning from Settings with permission granted — they
|
||||
// shouldn't have to wait for an auto-clear timer.
|
||||
switch state.phase {
|
||||
case .idle, .denied, .error:
|
||||
break
|
||||
@@ -137,106 +167,213 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
return
|
||||
}
|
||||
guard state.mode != .off else { return }
|
||||
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
|
||||
// press (before the first Task has had a chance to flip phase to
|
||||
// .recording) is rejected by the guard above. This fixes the race
|
||||
// where the user double-tapped the mic and we started two
|
||||
// pipelines at once.
|
||||
state.phase = .requestingPermissions
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let micGranted = await self.permissions.requestMicPermission()
|
||||
guard micGranted else {
|
||||
self.state.phase = .denied(.mic)
|
||||
return
|
||||
}
|
||||
// We explicitly ask for Speech recognition permission here.
|
||||
// `SpeechAnalyzer` does not expose a dedicated request API,
|
||||
// so the app still relies on the shared Speech permission
|
||||
// gate and `NSSpeechRecognitionUsageDescription`.
|
||||
let speechGranted = await self.permissions.requestSpeechPermission()
|
||||
guard speechGranted else {
|
||||
self.state.phase = .denied(.speech)
|
||||
return
|
||||
}
|
||||
self.startPipeline()
|
||||
guard hasFullAccess else {
|
||||
let msg = "请在系统设置中为 OSGKeyboard 开启“允许完全访问”,否则无法使用语音输入"
|
||||
state.phase = .error(.unknown(msg), message: msg)
|
||||
scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
guard AppGroup.isAvailable else {
|
||||
let msg = "App Group 未配置,键盘无法与主 App 通信。请重新安装并检查签名配置。"
|
||||
state.phase = .error(.appGroupUnavailable, message: msg)
|
||||
scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
|
||||
if FlowSessionBridge.isSessionActive() {
|
||||
startFlowRecording()
|
||||
} else {
|
||||
beginFlowStart()
|
||||
}
|
||||
}
|
||||
|
||||
private func pressEnded() {
|
||||
guard state.phase == .recording else { return }
|
||||
stopPipeline()
|
||||
if isPendingFlowStart {
|
||||
cancelPendingFlowStart()
|
||||
return
|
||||
}
|
||||
guard isFlowRecording else { return }
|
||||
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
FlowSessionBridge.setRecordingState(.stopped)
|
||||
state.phase = .processing
|
||||
state.lastTranscript = "识别中..."
|
||||
startFlowResultWatchdog()
|
||||
}
|
||||
|
||||
// MARK: - Pipeline
|
||||
private func startFlowRecording() {
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
|
||||
private func startPipeline() {
|
||||
let session = audio.start()
|
||||
self.session = session
|
||||
state.phase = .recording
|
||||
state.level = 0
|
||||
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
|
||||
FlowSessionBridge.setRecordingState(.recording)
|
||||
isFlowRecording = true
|
||||
state.lastTranscript = ""
|
||||
state.phase = .recording
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
debug("startFlowRecording")
|
||||
}
|
||||
|
||||
let locale = resolveLocale(state.localeId)
|
||||
let events = asr.transcribe(
|
||||
stream: session.audio,
|
||||
locale: locale
|
||||
)
|
||||
|
||||
asrTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var lastPartial: String = ""
|
||||
for await event in events {
|
||||
switch event {
|
||||
case .capability(let onDevice):
|
||||
self.state.onDeviceSupported = onDevice
|
||||
case .partial(let s):
|
||||
lastPartial = s
|
||||
self.state.lastTranscript = s
|
||||
case .final(let s):
|
||||
let transcript = s.isEmpty ? lastPartial : s
|
||||
self.handleFinalTranscript(transcript)
|
||||
case .error(let m):
|
||||
self.state.phase = .error(.asr(m))
|
||||
self.scheduleAutoClearError()
|
||||
private func startUtteranceCountdown() {
|
||||
utteranceStartedAt = Date().timeIntervalSince1970
|
||||
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
utteranceTimerTask?.cancel()
|
||||
utteranceTimerTask = Task { @MainActor [weak self] in
|
||||
while let self, self.isFlowRecording, !Task.isCancelled {
|
||||
let elapsed = Date().timeIntervalSince1970 - self.utteranceStartedAt
|
||||
let remaining = max(0, Int(ceil(FlowSessionKeys.maxUtteranceDuration - elapsed)))
|
||||
self.state.utteranceRemainingSeconds = remaining
|
||||
if remaining <= 0 {
|
||||
self.pressEnded()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
levelTask = Task { @MainActor [weak self] in
|
||||
for await level in session.levels {
|
||||
guard let self else { return }
|
||||
// Smooth a little extra to feel natural.
|
||||
self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipeline() {
|
||||
session?.stop()
|
||||
session = nil
|
||||
asrTask?.cancel(); asrTask = nil
|
||||
levelTask?.cancel(); levelTask = nil
|
||||
private func stopUtteranceCountdown() {
|
||||
utteranceTimerTask?.cancel()
|
||||
utteranceTimerTask = nil
|
||||
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
}
|
||||
|
||||
private func beginFlowStart() {
|
||||
isPendingFlowStart = true
|
||||
isFlowRecording = false
|
||||
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||||
state.lastTranscript = "正在启动语音会话..."
|
||||
state.phase = .processing
|
||||
openHostApp(path: "startflow")
|
||||
startFlowStartWatchdog()
|
||||
debug("beginFlowStart")
|
||||
}
|
||||
|
||||
private func cancelPendingFlowStart() {
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
}
|
||||
|
||||
private func startFlowStartWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||
if FlowSessionBridge.isSessionActive() {
|
||||
self.startFlowRecording()
|
||||
return
|
||||
}
|
||||
let now = Date().timeIntervalSince1970
|
||||
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
|
||||
self.isPendingFlowStart = false
|
||||
self.flowStartDeadline = 0
|
||||
self.showManualSettingsHint(path: "startflow")
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startFlowLevelWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.isFlowRecording {
|
||||
let levels = FlowSessionBridge.audioLevels()
|
||||
if let peak = levels.max(), peak > 0 {
|
||||
self.state.level = Double(peak)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startFlowResultWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
let startedAt = Date().timeIntervalSince1970
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled {
|
||||
if let result = FlowSessionBridge.consumeTranscriptionResult() {
|
||||
self.stopFlowWatchdog()
|
||||
self.handleFlowTranscript(result)
|
||||
return
|
||||
}
|
||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||
self.stopFlowWatchdog()
|
||||
self.state.phase = .error(.unknown(error), message: error)
|
||||
self.scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - startedAt > FlowWatchdog.resultTimeout {
|
||||
self.stopFlowWatchdog()
|
||||
let msg = "等待识别结果超时,请重试"
|
||||
self.state.phase = .error(.unknown(msg), message: msg)
|
||||
self.scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFlowTranscript(_ transcript: String) {
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
state.phase = .idle
|
||||
state.level = 0
|
||||
return
|
||||
}
|
||||
// Host app already polished when configured; keyboard only inserts.
|
||||
textDocumentProxy.insertText(trimmed)
|
||||
state.lastTranscript = ""
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
debug("flow insert length=\(trimmed.count)")
|
||||
}
|
||||
|
||||
private func stopFlowWatchdog() {
|
||||
flowWatchdogTask?.cancel()
|
||||
flowWatchdogTask = nil
|
||||
}
|
||||
|
||||
private func cancelPipeline() {
|
||||
stopPipeline()
|
||||
asr.cancel()
|
||||
if state.phase == .recording || state.phase == .processing {
|
||||
if isFlowRecording || isPendingFlowStart {
|
||||
if isFlowRecording {
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
}
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
stopUtteranceCountdown()
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
}
|
||||
if awaitingDictationResult {
|
||||
debug("cancelPipeline ignored while awaiting legacy handoff result")
|
||||
return
|
||||
}
|
||||
if state.phase == .processing {
|
||||
state.phase = .idle
|
||||
}
|
||||
state.level = 0
|
||||
// Reset the on-device flag so the StatusBadge stops showing the
|
||||
// cloud-fallback indicator between recordings.
|
||||
state.onDeviceSupported = false
|
||||
}
|
||||
|
||||
private func handleFinalTranscript(_ transcript: String) {
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
debug("received empty transcript")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
state.phase = .idle
|
||||
return
|
||||
}
|
||||
debug("received transcript length=\(trimmed.count)")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
// Local engine or transcribe mode: insert directly, no LLM call.
|
||||
if state.isLocalEngine || state.mode == .transcribe {
|
||||
textDocumentProxy.insertText(trimmed)
|
||||
@@ -307,18 +444,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
persistor.persist(mode: m)
|
||||
if isRecording {
|
||||
if m == .off {
|
||||
// Switching to .off while recording: drop the partial
|
||||
// (no insertion, no LLM). User has explicitly disabled
|
||||
// the keyboard, so we honour that immediately.
|
||||
stopPipeline()
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
} else if m == .transcribe {
|
||||
// Switching to .transcribe while in .polish: end the
|
||||
// recording, the partial will flow through
|
||||
// handleFinalTranscript which inserts the raw text in
|
||||
// .transcribe mode (no LLM call).
|
||||
pressEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,34 +462,123 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
// MARK: - Open host app
|
||||
|
||||
private func openHostApp() {
|
||||
let urlString = "osgkeyboard://settings"
|
||||
if let url = URL(string: urlString) {
|
||||
var responder: UIResponder? = self
|
||||
while let r = responder {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(url)
|
||||
return
|
||||
}
|
||||
responder = r.next
|
||||
}
|
||||
private func openHostApp(path: String = "settings") {
|
||||
guard hasFullAccess else {
|
||||
let msg = "未开启“允许完全访问”,请先在键盘设置中打开"
|
||||
state.phase = .error(.unknown(msg), message: msg)
|
||||
scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
var responder: UIResponder? = self
|
||||
while let r = responder {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(url); return
|
||||
}
|
||||
responder = r.next
|
||||
guard let url = URL(string: "osgkeyboard://\(path)") else {
|
||||
handleHostAppOpenResult(path: path, success: false)
|
||||
return
|
||||
}
|
||||
HostAppLauncher.open(url: url, from: self) { [weak self] success in
|
||||
self?.handleHostAppOpenResult(path: path, success: success)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHostAppOpenResult(path: String, success: Bool) {
|
||||
debug("openHostApp path=\(path) success=\(success)")
|
||||
guard !success else { return }
|
||||
|
||||
// Flow start: auto-jump often fails in WeChat/Safari — keep polling
|
||||
// so a manually opened host app can still satisfy the session check.
|
||||
if path == "startflow", isPendingFlowStart {
|
||||
state.lastTranscript = "无法自动跳转,请从主屏幕打开 OSGKeyboard,然后返回继续"
|
||||
return
|
||||
}
|
||||
|
||||
if path == "dictate" {
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
}
|
||||
showManualSettingsHint(path: path)
|
||||
}
|
||||
|
||||
private func consumePendingDictationResultIfNeeded() {
|
||||
guard let transcript = DictationBridge.consumePendingTranscript() else { return }
|
||||
debug("consumePendingDictationResultIfNeeded success")
|
||||
handleFinalTranscript(transcript)
|
||||
}
|
||||
|
||||
private func refreshDictationProgressStateIfNeeded() {
|
||||
guard awaitingDictationResult, case .processing = state.phase else { return }
|
||||
let progress = DictationBridge.currentStatus()
|
||||
switch progress.status {
|
||||
case .requested:
|
||||
state.lastTranscript = state.isLocalEngine
|
||||
? "正在打开 OSGKeyboard(本地转写)..."
|
||||
: "正在打开 OSGKeyboard..."
|
||||
case .recording:
|
||||
state.lastTranscript = state.isLocalEngine
|
||||
? "正在本地录音,请完成后返回当前输入页"
|
||||
: "正在录音,请完成后返回当前输入页"
|
||||
case .transcribing:
|
||||
state.lastTranscript = state.isLocalEngine
|
||||
? "本地识别中,请稍候并返回输入页"
|
||||
: "识别中,请稍候并返回输入页"
|
||||
case .error:
|
||||
let msg = progress.message ?? "录音失败,请重试"
|
||||
debug("host returned error: \(msg)")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
state.phase = .error(.unknown(msg), message: msg)
|
||||
scheduleAutoClearError()
|
||||
case .cancelled:
|
||||
debug("host cancelled")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
state.phase = .idle
|
||||
case .done, .idle:
|
||||
break
|
||||
}
|
||||
// Host app can be killed or leave without callback. If status does not
|
||||
// advance for too long, fail fast with an actionable retry message.
|
||||
let now = Date().timeIntervalSince1970
|
||||
let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt
|
||||
if now - lastProgressAt > DictationWatchdog.timeout {
|
||||
let timeoutMessage = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试"
|
||||
debug("dictation timeout after \(Int(now - lastProgressAt))s")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
DictationBridge.clear()
|
||||
state.phase = .error(.unknown(timeoutMessage), message: timeoutMessage)
|
||||
scheduleAutoClearError()
|
||||
}
|
||||
}
|
||||
|
||||
private func showManualSettingsHint(path: String = "settings") {
|
||||
let msg: String
|
||||
if !hasFullAccess {
|
||||
msg = "请先开启 OSGKeyboard 的“允许完全访问”,否则键盘无法跳转到 App"
|
||||
} else if path == "settings" {
|
||||
msg = "系统拒绝了键盘跳转。请手动打开 OSGKeyboard App 进入设置页"
|
||||
} else if path == "startflow" {
|
||||
msg = "语音会话未启动。请从主屏幕打开 OSGKeyboard App,返回后再按麦克风"
|
||||
} else if state.isLocalEngine {
|
||||
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 完成本地转写,再返回输入页"
|
||||
} else {
|
||||
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 录音,再返回输入页"
|
||||
}
|
||||
state.phase = .error(.unknown(msg), message: msg)
|
||||
scheduleAutoClearError()
|
||||
}
|
||||
|
||||
private func startDictationWatchdog() {
|
||||
stopDictationWatchdog()
|
||||
dictationWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.awaitingDictationResult {
|
||||
self.consumePendingDictationResultIfNeeded()
|
||||
self.refreshDictationProgressStateIfNeeded()
|
||||
try? await Task.sleep(nanoseconds: DictationWatchdog.pollIntervalNs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func resolveLocale(_ id: String) -> Locale {
|
||||
if id == "auto" { return .current }
|
||||
return Locale(identifier: id)
|
||||
private func stopDictationWatchdog() {
|
||||
dictationWatchdogTask?.cancel()
|
||||
dictationWatchdogTask = nil
|
||||
}
|
||||
|
||||
private func scheduleAutoClearError() {
|
||||
@@ -381,4 +597,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func debug(_ message: String) {
|
||||
#if DEBUG
|
||||
print("🎙️[KeyboardVC] \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
<key>com.apple.security.keychain-access-groups</key>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>com.osgkeyboard.shared</string>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -62,16 +62,19 @@ public struct AppGroupPersistor {
|
||||
|
||||
/// Persist `mode` to the App Group store.
|
||||
public func persist(mode: KeyboardViewController.State.InputMode) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setModeId(mode.rawValue)
|
||||
}
|
||||
|
||||
/// Persist `localeId` to the App Group store.
|
||||
public func persist(localeId: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setLocaleId(localeId)
|
||||
}
|
||||
|
||||
/// Persist `engineMode` to the App Group store.
|
||||
public func persist(engineMode: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setEngineMode(engineMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// HostAppLauncher.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Opens the host app via URL using every extension-safe strategy:
|
||||
// 1. `extensionContext.open` (official)
|
||||
// 2. Responder-chain `UIApplication.open` (TypeWhisper pattern)
|
||||
// 3. `sharedApplication` KVC fallback (common in full-access keyboards)
|
||||
|
||||
import UIKit
|
||||
|
||||
enum HostAppLauncher {
|
||||
@MainActor
|
||||
static func open(
|
||||
url: URL,
|
||||
from controller: KeyboardViewController,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
if let context = controller.extensionContext {
|
||||
context.open(url) { success in
|
||||
Task { @MainActor in
|
||||
if success {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
completion(openViaFallback(url, from: controller))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(openViaFallback(url, from: controller))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaFallback(
|
||||
_ url: URL,
|
||||
from controller: KeyboardViewController
|
||||
) -> Bool {
|
||||
if openViaResponderChain(url, from: controller) {
|
||||
return true
|
||||
}
|
||||
return openViaSharedApplication(url)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaResponderChain(
|
||||
_ url: URL,
|
||||
from controller: KeyboardViewController
|
||||
) -> Bool {
|
||||
var responder: UIResponder? = controller
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:]) { _ in }
|
||||
return true
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaSharedApplication(_ url: URL) -> Bool {
|
||||
guard
|
||||
let application = UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication
|
||||
else {
|
||||
return false
|
||||
}
|
||||
application.open(url, options: [:]) { _ in }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,34 @@ public final class PermissionManager: @unchecked Sendable {
|
||||
/// method of its own and the framework checks the same TCC
|
||||
/// entry on first use.
|
||||
public func requestSpeechPermission() async -> Bool {
|
||||
await Self.requestSpeechPermissionNonisolated()
|
||||
}
|
||||
|
||||
// MARK: - Nonisolated permission bridge
|
||||
//
|
||||
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
|
||||
// to run on main queue. Building the callback inline inside a
|
||||
// `@MainActor` method can trigger runtime actor/isolation assertions.
|
||||
// Keep the continuation + callback creation in nonisolated helpers.
|
||||
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
switch status {
|
||||
case .authorized: cont.resume(returning: true)
|
||||
case .denied, .restricted, .notDetermined:
|
||||
cont.resume(returning: false)
|
||||
@unknown default:
|
||||
cont.resume(returning: false)
|
||||
}
|
||||
SFSpeechRecognizer.requestAuthorization(
|
||||
makeSpeechAuthHandler(continuation: cont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func makeSpeechAuthHandler(
|
||||
continuation: CheckedContinuation<Bool, Never>
|
||||
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
|
||||
return { status in
|
||||
switch status {
|
||||
case .authorized:
|
||||
continuation.resume(returning: true)
|
||||
case .denied, .restricted, .notDetermined:
|
||||
continuation.resume(returning: false)
|
||||
@unknown default:
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: State
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct KeyboardRootView: View {
|
||||
/// it up.
|
||||
static let totalHeight: CGFloat = 280
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
@@ -48,26 +52,12 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
// iOS keyboard extensions always render dark (Apple's default
|
||||
// for custom keyboards), and we let the system UI chrome show
|
||||
// through by drawing no background of our own.
|
||||
// Let the system UI chrome show through by drawing no background
|
||||
// of our own.
|
||||
.background(Color.clear)
|
||||
.frame(height: Self.totalHeight)
|
||||
// Top edge: subtle highlight gradient + 0.5pt divider line.
|
||||
// These give the keyboard a "physical surface" feel and visually
|
||||
// separate it from the host text field above. We overlay (not
|
||||
// background) so the underlying color stays clear.
|
||||
.overlay(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.white.opacity(0.05), .clear],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
))
|
||||
.frame(height: 1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
@@ -96,7 +86,7 @@ public struct KeyboardRootView: View {
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("home.action.openSettingsA11y"))
|
||||
.accessibilityLabel(Text(KeyboardL10n.openSettingsA11y))
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
@@ -115,9 +105,8 @@ public struct KeyboardRootView: View {
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
onPressBegan: state.beginRecording,
|
||||
onPressEnded: state.endRecording,
|
||||
onTap: state.tapMic
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
}
|
||||
@@ -139,7 +128,7 @@ public struct KeyboardRootView: View {
|
||||
state.deleteBackward()
|
||||
}
|
||||
Button(action: state.insertSpace) {
|
||||
Text("common.space")
|
||||
Text(KeyboardL10n.space)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 42)
|
||||
@@ -150,7 +139,7 @@ public struct KeyboardRootView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("common.space"))
|
||||
.accessibilityLabel(Text(KeyboardL10n.space))
|
||||
ToolbarIconButton(systemName: "return", label: "newline") {
|
||||
state.insertNewline()
|
||||
}
|
||||
@@ -211,13 +200,13 @@ private struct TranscriptLine: View {
|
||||
ZStack {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("keyboard.placeholder.idle")
|
||||
Text(KeyboardL10n.placeholderIdle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
Text("keyboard.placeholder.preparing")
|
||||
Text(KeyboardL10n.placeholderPreparing)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -231,9 +220,11 @@ private struct TranscriptLine: View {
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||
Text("keyboard.placeholder.processing")
|
||||
Text(transcript.isEmpty ? KeyboardL10n.placeholderProcessing : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
case .error(_, let msg):
|
||||
Text(msg ?? "")
|
||||
@@ -256,7 +247,7 @@ private struct TranscriptLine: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("keyboard.deniedHint"))
|
||||
.accessibilityHint(Text(KeyboardL10n.deniedHint))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -265,8 +256,8 @@ private struct TranscriptLine: View {
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return "麦克风被拒绝 · Mic denied"
|
||||
case .speech: return "语音识别被拒绝 · Speech denied"
|
||||
case .mic: return KeyboardL10n.micDenied
|
||||
case .speech: return KeyboardL10n.speechDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,7 +352,7 @@ private struct LocalEngineChip: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "iphone.badge.checkmark")
|
||||
Text("keyboard.placeholder.localBadge")
|
||||
Text(KeyboardL10n.localBadge)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
@@ -372,6 +363,34 @@ private struct LocalEngineChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extension text fallback
|
||||
//
|
||||
// Custom keyboard extensions can end up without the expected localized
|
||||
// resource table when signing/project generation drifts. Keep a tiny
|
||||
// in-code fallback map so UI never shows raw key names like
|
||||
// "common.space" in production.
|
||||
private enum KeyboardL10n {
|
||||
private static var isChinese: Bool {
|
||||
Locale.preferredLanguages.first?.hasPrefix("zh") == true
|
||||
}
|
||||
|
||||
static var space: String { isChinese ? "空格" : "Space" }
|
||||
static var placeholderIdle: String { isChinese ? "点按说话" : "Tap to talk" }
|
||||
static var placeholderPreparing: String { isChinese ? "准备中…" : "Preparing" }
|
||||
static var placeholderProcessing: String { isChinese ? "处理中…" : "Processing" }
|
||||
static var localBadge: String { isChinese ? "本地" : "On-device" }
|
||||
static var micDenied: String { isChinese ? "麦克风被拒绝" : "Mic denied" }
|
||||
static var speechDenied: String { isChinese ? "语音识别被拒绝" : "Speech denied" }
|
||||
static var deniedHint: String {
|
||||
isChinese
|
||||
? "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"
|
||||
: "Open OSGKeyboard settings to grant microphone / speech access."
|
||||
}
|
||||
static var openSettingsA11y: String {
|
||||
isChinese ? "打开 OSGKeyboard 设置" : "Open OSGKeyboard settings"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mode chip
|
||||
|
||||
private struct ModeChip: View {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// RecordButton.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// The hero control. 120 pt primary disc with a soft inner gradient, a
|
||||
// breathing outer ring while recording, and a centred waveform that maps
|
||||
// directly to the real audio RMS. Idle / recording / processing are three
|
||||
// distinct visual states — no flicker, no surprise transitions.
|
||||
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
|
||||
// remaining-time countdown while recording; last 10 seconds turn red.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -21,38 +19,38 @@ struct RecordButton: View {
|
||||
|
||||
let phase: Phase
|
||||
let level: Double // 0...1
|
||||
let onPressBegan: () -> Void
|
||||
let onPressEnded: () -> Void
|
||||
let onTap: () -> Void
|
||||
/// Seconds left in the current utterance; shown only while recording.
|
||||
let remainingSeconds: Int?
|
||||
let onToggle: () -> Void
|
||||
|
||||
@GestureState private var isPressed: Bool = false
|
||||
@State private var breath: Bool = false
|
||||
|
||||
init(
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
onPressBegan: @escaping () -> Void,
|
||||
onPressEnded: @escaping () -> Void,
|
||||
onTap: @escaping () -> Void
|
||||
remainingSeconds: Int? = nil,
|
||||
onToggle: @escaping () -> Void
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.onPressBegan = onPressBegan
|
||||
self.onPressEnded = onPressEnded
|
||||
self.onTap = onTap
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.onToggle = onToggle
|
||||
}
|
||||
|
||||
private var isUrgent: Bool {
|
||||
guard phase == .recording, let remainingSeconds else { return false }
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Outer breathing ring (recording only)
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(0.35), lineWidth: 2)
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: 150, height: 150)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
|
||||
// Halo: soft red glow that intensifies with input level
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
@@ -68,7 +66,6 @@ struct RecordButton: View {
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: level)
|
||||
|
||||
// Secondary outer ring (always present, dimmer when idle)
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||
@@ -76,7 +73,6 @@ struct RecordButton: View {
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
|
||||
// Main disc with gradient + soft inner highlight
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(discGradient)
|
||||
@@ -84,7 +80,6 @@ struct RecordButton: View {
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
.blendMode(.overlay)
|
||||
|
||||
// Centre content — switches by phase
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
@@ -92,17 +87,19 @@ struct RecordButton: View {
|
||||
.font(.system(size: 38, weight: .medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
case .recording:
|
||||
WaveformView(level: level, active: true)
|
||||
.frame(width: 80, height: 44)
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 4) {
|
||||
if let remainingSeconds {
|
||||
Text(formatRemaining(remainingSeconds))
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(isUrgent ? .white : palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
WaveformView(level: level, active: true)
|
||||
.frame(width: 72, height: 32)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
// Scaled to ~50pt inside a 120pt disc (~42%) —
|
||||
// same absolute size as the preview stub's
|
||||
// spinner (2.5x of the default ProgressView),
|
||||
// just a smaller fraction because the real
|
||||
// disc is bigger. The user gets the same
|
||||
// visual weight whether they're looking at
|
||||
// the in-app preview or the live keyboard.
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(palette.textPrimary)
|
||||
@@ -115,63 +112,34 @@ struct RecordButton: View {
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
.scaleEffect(isPressed ? 0.94 : 1.0)
|
||||
.animation(Motion.quick, value: isPressed)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
// Press-to-talk: act on the FIRST touch-down, not after a 150 ms
|
||||
// minimum duration. That's what Typeless feels like, and it's what
|
||||
// makes the keyboard feel responsive. A tap (very short press) is
|
||||
// interpreted as "toggle" for the secondary action (onTap), not
|
||||
// "record" — the recording only fires if the press lasts long
|
||||
// enough to read as intentional. This avoids the previous bug
|
||||
// where every single tap fired both onPressBegan AND onTap.
|
||||
.gesture(
|
||||
LongPressGesture(minimumDuration: 0.18)
|
||||
.sequenced(before: DragGesture(minimumDistance: 0))
|
||||
.updating($isPressed) { value, state, _ in
|
||||
switch value {
|
||||
case .second(true, _): state = true
|
||||
default: state = false
|
||||
}
|
||||
}
|
||||
.onChanged { value in
|
||||
if case .second(true, _) = value, !pressArmed {
|
||||
pressArmed = true
|
||||
onPressBegan()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if pressArmed { pressArmed = false; onPressEnded() }
|
||||
}
|
||||
)
|
||||
.simultaneousGesture(
|
||||
// Pure tap: only fires when the user lifts before the long-press
|
||||
// threshold. This becomes the "secondary action" (e.g. cycle
|
||||
// mode). It is paired with, not conflicting with, the long-press.
|
||||
TapGesture(count: 1)
|
||||
.onEnded {
|
||||
if !pressArmed { onTap() }
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
guard phase != .processing else { return }
|
||||
onToggle()
|
||||
}
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
.onChange(of: phase) { _, new in
|
||||
breath = (new == .recording)
|
||||
}
|
||||
.accessibilityLabel(Text("keyboard.pressToTalkA11y"))
|
||||
.accessibilityLabel(Text("keyboard.tapToTalkA11y"))
|
||||
}
|
||||
|
||||
@State private var pressArmed: Bool = false
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
let m = seconds / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
}
|
||||
|
||||
private var discGradient: LinearGradient {
|
||||
switch phase {
|
||||
case .recording:
|
||||
return LinearGradient(
|
||||
colors: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
let colors: [Color] = isUrgent
|
||||
? [palette.recordRed, palette.recordRed.opacity(0.85)]
|
||||
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
|
||||
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(
|
||||
colors: [palette.surfaceElevated, palette.surface],
|
||||
@@ -185,10 +153,6 @@ struct RecordButton: View {
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .idle:
|
||||
// Brand green — same hue as `Palette.{dark,light}.accent`
|
||||
// and the AccentColor asset. The disc is the keyboard's
|
||||
// primary CTA, and a dark-gray disc looked like an inert
|
||||
// surface, not an actionable button.
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.95),
|
||||
|
||||
@@ -105,15 +105,15 @@
|
||||
|
||||
/* Keyboard preview */
|
||||
"preview.title" = "Keyboard Preview";
|
||||
"preview.subtitle" = "Tap the disc to start/stop recording. The real keyboard uses the same layout.";
|
||||
"preview.placeholder" = "Type or tap to record";
|
||||
"preview.subtitle" = "Tap Start/Stop Recording to test here. On the real keyboard, press and hold the mic disc.";
|
||||
"preview.placeholder" = "Type here or tap the record button";
|
||||
"preview.clear" = "Clear text";
|
||||
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"preview.modeChip.cycle" = "Cycle input mode";
|
||||
"preview.localeChip.cycle" = "Cycle recognition language";
|
||||
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "Hold to talk";
|
||||
"keyboard.placeholder.idle" = "Tap to talk";
|
||||
"keyboard.placeholder.preparing" = "Preparing";
|
||||
"keyboard.placeholder.processing" = "Processing";
|
||||
"keyboard.placeholder.error" = "Polishing failed";
|
||||
@@ -124,7 +124,7 @@
|
||||
"keyboard.denied.speech" = "Speech denied";
|
||||
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
|
||||
"keyboard.pressToTalkA11y" = "Push to talk";
|
||||
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||
|
||||
/* Mode chip labels (used in both ext + preview stub) */
|
||||
"mode.off" = "Off";
|
||||
|
||||
@@ -105,15 +105,15 @@
|
||||
|
||||
/* Keyboard preview */
|
||||
"preview.title" = "键盘预览";
|
||||
"preview.subtitle" = "点按 disc 开始/结束录音;真实键盘使用同样布局。";
|
||||
"preview.placeholder" = "试着输入或按 disc 录音";
|
||||
"preview.subtitle" = "点击「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
|
||||
"preview.placeholder" = "试着输入或点击按钮录音";
|
||||
"preview.clear" = "清空";
|
||||
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"preview.modeChip.cycle" = "切换输入模式";
|
||||
"preview.localeChip.cycle" = "切换识别语言";
|
||||
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "按住说话";
|
||||
"keyboard.placeholder.idle" = "点按说话";
|
||||
"keyboard.placeholder.preparing" = "准备中…";
|
||||
"keyboard.placeholder.processing" = "处理中…";
|
||||
"keyboard.placeholder.error" = "润色失败";
|
||||
@@ -124,7 +124,7 @@
|
||||
"keyboard.denied.speech" = "语音识别被拒绝";
|
||||
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
|
||||
"keyboard.pressToTalkA11y" = "按住说话";
|
||||
"keyboard.tapToTalkA11y" = "点按说话";
|
||||
|
||||
/* Mode chip labels */
|
||||
"mode.off" = "关闭";
|
||||
|
||||
Reference in New Issue
Block a user