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:
Rocky
2026-06-18 22:27:22 +08:00
parent d22154e119
commit 6148d05093
15 changed files with 96 additions and 92 deletions
+8 -6
View File
@@ -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)
+4 -4
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+3 -2
View File
@@ -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
+7 -13
View File
@@ -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)
+15 -5
View File
@@ -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
}
+16 -13
View File
@@ -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 1825)
// 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 1825
/// `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)
+5
View File
@@ -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" = "按住说话";