d656bac8c3
Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
309 lines
9.9 KiB
Swift
309 lines
9.9 KiB
Swift
// SettingsPreferenceRows.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// Shared preference picker / toggle rows used by Settings home and
|
|
// secondary pages (General, Voice session, Daily).
|
|
|
|
import SwiftUI
|
|
import Speech
|
|
import OSGKeyboardShared
|
|
|
|
// MARK: - App language picker row
|
|
|
|
struct AppLanguagePickerRow: View {
|
|
@Binding var selection: AppUILanguage
|
|
|
|
private var options: [(id: String, label: String)] {
|
|
AppUILanguage.allCases.map { language in
|
|
(language.rawValue, AppL10n.string(language.labelKey))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.appLanguage.title"),
|
|
options: options,
|
|
selection: Binding(
|
|
get: { selection.rawValue },
|
|
set: { newValue in
|
|
selection = AppUILanguage(rawValue: newValue) ?? .auto
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Appearance picker row
|
|
|
|
struct AppearancePickerRow: View {
|
|
@AppStorage(AppearancePreference.storageKey)
|
|
private var appearanceRaw = AppearancePreference.system.rawValue
|
|
|
|
private var options: [(id: String, label: String)] {
|
|
AppearancePreference.allCases.map { preference in
|
|
(preference.rawValue, AppL10n.string(preference.labelKey))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.appearance.title"),
|
|
options: options,
|
|
selection: $appearanceRaw
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow keep-alive mode picker row
|
|
|
|
struct FlowKeepAliveModePickerRow: View {
|
|
@Binding var selection: FlowKeepAliveMode
|
|
|
|
private var options: [(id: String, label: String)] {
|
|
FlowKeepAliveMode.allCases.map { mode in
|
|
(mode.rawValue, AppL10n.string(mode.labelKey))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.flow.keepAlive.title"),
|
|
options: options,
|
|
selection: Binding(
|
|
get: { selection.rawValue },
|
|
set: { newValue in
|
|
selection = FlowKeepAliveMode(rawValue: newValue) ?? .default
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow inactivity picker row
|
|
|
|
struct FlowInactivityPickerRow: View {
|
|
@Binding var selection: FlowInactivityDuration
|
|
|
|
private var options: [(id: String, label: String)] {
|
|
FlowInactivityDuration.allCases.map { duration in
|
|
(duration.rawValue, AppL10n.string(duration.labelKey))
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.flow.inactivity.title"),
|
|
options: options,
|
|
selection: Binding(
|
|
get: { selection.rawValue },
|
|
set: { newValue in
|
|
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Handedness picker row
|
|
|
|
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 {
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.handedness.title"),
|
|
options: options,
|
|
selection: Binding(
|
|
get: { selection.rawValue },
|
|
set: { newValue in
|
|
selection = HandednessPreference(rawValue: newValue) ?? .left
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Polish intensity picker row
|
|
|
|
struct PolishIntensityPickerRow: View {
|
|
@ObservedObject var config: ProviderConfig
|
|
|
|
var body: some View {
|
|
// 与「惯用手」一致的右侧下拉菜单行样式,保持偏好设置卡片内各行风格统一。
|
|
SettingsMenuPickerRow(
|
|
title: AppL10n.string("settings.polishIntensity.title"),
|
|
options: PolishIntensity.allCases.map { intensity in
|
|
(intensity.rawValue, SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
|
|
},
|
|
selection: Binding(
|
|
get: { config.polishIntensity.rawValue },
|
|
set: { newValue in
|
|
config.polishIntensity = PolishIntensity(rawValue: newValue) ?? .medium
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Cursor drag navigation toggle
|
|
|
|
struct CursorDragNavigationToggleRow: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
@Binding var isOn: Bool
|
|
|
|
var body: some View {
|
|
Toggle(isOn: $isOn) {
|
|
Text("settings.cursorDragNavigation.title")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
}
|
|
.tint(palette.accent)
|
|
.settingsListRow()
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu picker row (generic)
|
|
|
|
struct SettingsMenuPickerRow: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
|
|
let title: String
|
|
let options: [(id: String, label: String)]
|
|
@Binding var selection: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Menu {
|
|
ForEach(options, id: \.id) { o in
|
|
Button {
|
|
selection = o.id
|
|
} label: {
|
|
if o.id == selection {
|
|
Label(o.label, systemImage: "checkmark")
|
|
} else {
|
|
Text(o.label)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(currentLabel)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.settingsListRow()
|
|
}
|
|
|
|
private var currentLabel: String {
|
|
options.first(where: { $0.id == selection })?.label ?? "—"
|
|
}
|
|
}
|
|
|
|
// MARK: - Locale picker row (with on-device indicator)
|
|
|
|
struct LocalePickerRow: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
@ObservedObject private var config = ProviderConfig.shared
|
|
|
|
let locales: [(id: String, onDevice: Bool)]
|
|
@Binding var selection: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text("settings.asrLocale")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Menu {
|
|
ForEach(locales, id: \.id) { locale in
|
|
Button {
|
|
selection = locale.id
|
|
} label: {
|
|
// iOS Menu converts SwiftUI Label to UIAction (title + image).
|
|
// Using Label keeps checkmark + on-device icon both visible.
|
|
let name = label(for: locale.id)
|
|
if locale.id == selection {
|
|
Label(name, systemImage: "checkmark")
|
|
} else if locale.onDevice {
|
|
Label(name, systemImage: "iphone")
|
|
} else {
|
|
Text(name)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
// On-device badge for the currently selected locale.
|
|
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
|
|
Image(systemName: "iphone")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(palette.accent)
|
|
}
|
|
Text(currentLabel)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.settingsListRow()
|
|
}
|
|
|
|
private func label(for localeId: String) -> String {
|
|
ASRLocaleLabels.displayName(for: localeId, language: config.uiLanguage)
|
|
}
|
|
|
|
private var currentLabel: String {
|
|
label(for: selection)
|
|
}
|
|
}
|
|
|
|
// MARK: - Dynamic ASR locale loading
|
|
|
|
enum SettingsASRLocales {
|
|
/// Falls back to a short static list while `SFSpeechRecognizer` is loading.
|
|
static let staticFallback: [(id: String, onDevice: Bool)] = [
|
|
("auto", false),
|
|
("zh-Hans", false),
|
|
("zh-Hant", false),
|
|
("en-US", false),
|
|
("ja-JP", false),
|
|
("ko-KR", false),
|
|
]
|
|
|
|
static func loadDynamic() async -> [(id: String, onDevice: Bool)] {
|
|
// 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 here; we only read locale metadata (no transcription session).
|
|
await Task.detached(priority: .userInitiated) {
|
|
var result: [(id: String, onDevice: Bool)] = [("auto", false)]
|
|
|
|
for locale in SFSpeechRecognizer.supportedLocales()
|
|
.sorted(by: { $0.identifier < $1.identifier }) {
|
|
let id = locale.identifier
|
|
let onDevice = SFSpeechRecognizer(locale: locale)?.supportsOnDeviceRecognition ?? false
|
|
result.append((id: id, onDevice: onDevice))
|
|
}
|
|
return result
|
|
}.value
|
|
}
|
|
}
|