chore: align iOS 26 capability docs and UI localization
Keep repository messaging consistent with the implemented iOS 26 SpeechAnalyzer path, and remove mixed hardcoded copy by routing remaining UI/error text through localized string keys.
This commit is contained in:
@@ -24,7 +24,7 @@ struct APISettingsCard: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
field(
|
||||
title: "Base URL · 接口地址",
|
||||
title: NSLocalizedString("api.baseUrl", comment: ""),
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
text: $config.baseURL,
|
||||
autocap: false
|
||||
@@ -33,7 +33,7 @@ struct APISettingsCard: View {
|
||||
keyField
|
||||
Divider().background(palette.divider)
|
||||
field(
|
||||
title: "Model · 模型",
|
||||
title: NSLocalizedString("api.model", comment: ""),
|
||||
placeholder: "gpt-4o-mini",
|
||||
text: $config.model,
|
||||
autocap: false
|
||||
@@ -41,7 +41,7 @@ struct APISettingsCard: View {
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Divider().background(palette.divider)
|
||||
// Use a Button + UIApplication.open instead of SwiftUI
|
||||
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that
|
||||
// `Link`. SwiftUI `Link` can have hit-test quirks on some
|
||||
// makes its tappable area eat gestures from the adjacent
|
||||
// TextField, which manifests as "typing jumps to a website".
|
||||
Button {
|
||||
@@ -86,7 +86,9 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showKey ? "隐藏密钥 · Hide key" : "显示密钥 · Show key"))
|
||||
.accessibilityLabel(Text(showKey
|
||||
? NSLocalizedString("api.key.hide", comment: "")
|
||||
: NSLocalizedString("api.key.show", comment: "")))
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
@@ -120,7 +122,7 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
// `.asciiCapable` is the *minimum* contract for these fields:
|
||||
// API keys, base URLs, and model names are all ASCII by spec,
|
||||
// and SwiftUI's `.URL` keyboard on iOS 18 still occasionally
|
||||
// and SwiftUI's `.URL` keyboard can still occasionally
|
||||
// hands control to the system keyboard which then auto-suggests
|
||||
// Chinese / emoji completions that corrupt the value. Forcing
|
||||
// `.asciiCapable` keeps the system out of the way.
|
||||
@@ -132,7 +134,7 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { /* no-op: prevent the keyboard from "submitting"
|
||||
and dismissing the sheet on iOS 18 */ }
|
||||
and dismissing the sheet unexpectedly */ }
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
|
||||
@@ -33,15 +33,15 @@ struct EnginePickerSection: View {
|
||||
engineOptionRow(
|
||||
id: "local",
|
||||
icon: "iphone.badge.checkmark",
|
||||
title: "本地识别 · On-device",
|
||||
title: NSLocalizedString("settings.engine.local.title", comment: ""),
|
||||
subtitle: localSubtitle
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
engineOptionRow(
|
||||
id: "cloud",
|
||||
icon: "wand.and.stars",
|
||||
title: "云端润色 · Cloud polish",
|
||||
subtitle: "ASR 转录 + LLM 润色,需要 API Key\nASR + LLM polish, API key required"
|
||||
title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
|
||||
subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
|
||||
)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
@@ -55,7 +55,7 @@ struct EnginePickerSection: View {
|
||||
private var localSubtitle: String {
|
||||
// iOS 26's `SpeechAnalyzer` is always fully on-device, so the
|
||||
// local engine's only contract is "no network, no LLM".
|
||||
"SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network"
|
||||
NSLocalizedString("settings.engine.local.ios26", comment: "")
|
||||
}
|
||||
|
||||
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
|
||||
|
||||
@@ -45,7 +45,7 @@ struct HomeView: View {
|
||||
Circle()
|
||||
.fill(config.isConfigured ? palette.success : palette.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(config.isConfigured ? "就绪 · Ready" : "未完成配置 · Setup incomplete")
|
||||
Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
// In-app preview of the keyboard extension. Renders a stand-in
|
||||
// `KeyboardPreviewStub` so the user can see what the real extension
|
||||
// looks like, AND drives a *real* `PreviewASRController` so tapping
|
||||
// the disc actually records from the mic, runs SFSpeechRecognizer,
|
||||
// the disc actually records from the mic and runs the shared ASR
|
||||
// service (`SpeechAnalyzer` on iOS 26+),
|
||||
// and lands recognized text in the top textbox. Without the real ASR
|
||||
// the preview was a static mock — "the text never appears" was a
|
||||
// fair review note.
|
||||
//
|
||||
// Lifecycle (local engine = "transcribe" only, no LLM):
|
||||
// tap → start ASR (idempotent re-entry guard)
|
||||
// → SFSpeechRecognizer emits .partial / .final
|
||||
// → ASR emits .partial / .final
|
||||
// → currentPartial updates the transcript line in real time
|
||||
// tap → stop ASR
|
||||
// → lastFinal event lands
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// We deliberately do NOT use `TabView` with `.page` style for the
|
||||
// pager. That style wraps the content in a `UIPageViewController`,
|
||||
// and `UIPageViewController` has a long-standing iOS 18 bug where
|
||||
// and `UIPageViewController` has a long-standing behavior on some iOS
|
||||
// versions where
|
||||
// the keyboard-showing layout reflow on a `TextField` focus is
|
||||
// misread as a horizontal swipe — the page jumps back to step 1
|
||||
// the moment the user starts typing. Replacing the TabView with a
|
||||
@@ -136,10 +137,6 @@ private struct WelcomePage: View {
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("onboarding.welcome.subtitle")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
PrivacyFootnote()
|
||||
@@ -210,15 +207,12 @@ private struct EnableKeyboardPage: View {
|
||||
Text("onboarding.enable.title")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("onboarding.enable.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
step(num: 1, text: "设置 → 通用 → 键盘 → 键盘")
|
||||
step(num: 2, text: "点击「添加新键盘…」并选择 OSGKeyboard")
|
||||
step(num: 3, text: "点击 OSGKeyboard 并启用「允许完全访问」")
|
||||
step(num: 4, text: "Allow Full Access is required for microphone + LLM calls · 允许完全访问是麦克风和网络调用的前提")
|
||||
step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
|
||||
step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
|
||||
step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
|
||||
step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
|
||||
}
|
||||
.cardSurface()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
@@ -228,7 +222,7 @@ private struct EnableKeyboardPage: View {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
|
||||
Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
|
||||
.primaryButton()
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
@@ -133,7 +133,10 @@ final class PreviewASRController: ObservableObject {
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
didConfigureAudioSession = true
|
||||
} catch {
|
||||
phase = .error("Audio session 错误 · Audio session error: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.audioSession", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -219,7 +222,11 @@ final class PreviewASRController: ObservableObject {
|
||||
// state from a previous foreground/background transition.
|
||||
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
||||
phase = .error(
|
||||
"麦克风不可用 · Microphone unavailable (hw format \(hwFormat.sampleRate) Hz / \(hwFormat.channelCount) ch)"
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.micUnavailable", comment: ""),
|
||||
hwFormat.sampleRate,
|
||||
Int(hwFormat.channelCount)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -231,11 +238,11 @@ final class PreviewASRController: ObservableObject {
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
) else {
|
||||
phase = .error("无法创建 16 kHz 音频格式")
|
||||
phase = .error(NSLocalizedString("preview.error.formatCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
|
||||
phase = .error("无法创建音频转换器")
|
||||
phase = .error(NSLocalizedString("preview.error.converterCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +287,10 @@ final class PreviewASRController: ObservableObject {
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
phase = .error("无法启动音频引擎 · Engine start failed: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.engineStart", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ struct SettingsView: View {
|
||||
|
||||
private var apiSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("API · 接口", subtitle: nil)
|
||||
sectionHeader("settings.api.title", subtitle: nil)
|
||||
APISettingsCard(config: config)
|
||||
}
|
||||
}
|
||||
@@ -91,12 +91,15 @@ struct SettingsView: View {
|
||||
// MARK: - Language (ASR + mode)
|
||||
|
||||
private var languageSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("Language · 语言", subtitle: "选择识别语言\(config.engineMode == "cloud" ? "和文字处理模式" : "")。Recognition language\(config.engineMode == "cloud" ? " and text processing mode" : "").")
|
||||
let subtitleKey: LocalizedStringKey = config.engineMode == "cloud"
|
||||
? "settings.language.subtitle.cloud"
|
||||
: "settings.language.subtitle.local"
|
||||
return VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("settings.language.title", subtitle: subtitleKey)
|
||||
VStack(spacing: 0) {
|
||||
if config.engineMode == "cloud" {
|
||||
PickerRow(
|
||||
title: "Mode · 模式",
|
||||
title: NSLocalizedString("settings.mode.title", comment: ""),
|
||||
options: modeOptions,
|
||||
selection: Binding(
|
||||
get: { config.modeId },
|
||||
@@ -121,7 +124,8 @@ struct SettingsView: View {
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
|
||||
// Legend — only relevant for SFSpeechRecognizer path (iOS 18–25)
|
||||
// Legacy legend block: kept for UI compatibility, but with
|
||||
// iOS 26 as minimum target this branch never executes.
|
||||
if #unavailable(iOS 26) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "iphone")
|
||||
@@ -146,8 +150,7 @@ struct SettingsView: View {
|
||||
/// ASR engine row. With iOS 26 as the deployment target, the
|
||||
/// only ASR backend is `SpeechAnalyzer` and it is always fully
|
||||
/// on-device — so this row is now a static badge rather than a
|
||||
/// toggle. The previous `requiresOnDevice` toggle (iOS 18–25
|
||||
/// `SFSpeechRecognizer` cloud-fallback control) is gone.
|
||||
/// toggle. The old cloud-fallback toggle is gone.
|
||||
private var asrEngineRow: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.engineRow.title")
|
||||
@@ -170,7 +173,7 @@ struct SettingsView: View {
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
/// Falls back to a static list while SFSpeechRecognizer locales are loading.
|
||||
/// Falls back to a static list while dynamic locales are loading.
|
||||
private var effectiveLocales: [(id: String, label: String, onDevice: Bool)] {
|
||||
dynamicLocales.isEmpty ? staticLocales : dynamicLocales
|
||||
}
|
||||
@@ -197,15 +200,15 @@ struct SettingsView: View {
|
||||
// MARK: - Dynamic locale loading
|
||||
|
||||
private func loadDynamicLocales() async {
|
||||
// Run everything in a background task: SFSpeechRecognizer.supportedLocales()
|
||||
// Run everything in a background task: `SFSpeechRecognizer.supportedLocales()`
|
||||
// can return 100+ locales, and we probe supportsOnDeviceRecognition for each.
|
||||
// Creating SFSpeechRecognizer instances in a @Sendable closure is safe —
|
||||
// the existing ASRService.swift does the same thing inside AsyncStream { }.
|
||||
// Creating `SFSpeechRecognizer` instances in a @Sendable closure is
|
||||
// safe here; we only read locale metadata (no transcription session).
|
||||
let entries: [(id: String, label: String, onDevice: Bool)] = await Task.detached(
|
||||
priority: .userInitiated
|
||||
) {
|
||||
var result: [(id: String, label: String, onDevice: Bool)] = []
|
||||
result.append(("auto", "Auto · 跟随系统", false))
|
||||
result.append(("auto", NSLocalizedString("locale.auto", comment: ""), false))
|
||||
|
||||
let currentLocale = Locale.current // snapshot on background thread is fine
|
||||
for locale in SFSpeechRecognizer.supportedLocales()
|
||||
@@ -228,7 +231,7 @@ struct SettingsView: View {
|
||||
private var promptSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
sectionHeader("System Prompt · 系统提示", subtitle: nil)
|
||||
sectionHeader("settings.systemPrompt.title", subtitle: nil)
|
||||
Spacer()
|
||||
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
|
||||
.font(TypeStyle.caption2)
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"preview.modeChip.cycle" = "Cycle input mode";
|
||||
"preview.localeChip.cycle" = "Cycle recognition language";
|
||||
"preview.error.audioSession" = "Audio session error: %@";
|
||||
"preview.error.micUnavailable" = "Microphone unavailable (hw format %.0f Hz / %d ch)";
|
||||
"preview.error.formatCreate" = "Failed to create 16 kHz audio format.";
|
||||
"preview.error.converterCreate" = "Failed to create audio converter.";
|
||||
"preview.error.engineStart" = "Engine start failed: %@";
|
||||
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "Hold to talk";
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"preview.modeChip.cycle" = "切换输入模式";
|
||||
"preview.localeChip.cycle" = "切换识别语言";
|
||||
"preview.error.audioSession" = "音频会话错误: %@";
|
||||
"preview.error.micUnavailable" = "麦克风不可用 (硬件格式 %.0f Hz / %d 声道)";
|
||||
"preview.error.formatCreate" = "无法创建 16 kHz 音频格式。";
|
||||
"preview.error.converterCreate" = "无法创建音频转换器。";
|
||||
"preview.error.engineStart" = "无法启动音频引擎: %@";
|
||||
|
||||
/* Keyboard (ext) */
|
||||
"keyboard.placeholder.idle" = "按住说话";
|
||||
|
||||
Reference in New Issue
Block a user