Files
OSGKeyboard/OSGKeyboard/Views/SettingsSecondaryPages.swift
T
Rocky fb97ec2937 feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
2026-08-16 12:07:06 +08:00

594 lines
22 KiB
Swift

// SettingsSecondaryPages.swift
// OSGKeyboard · Main App
//
// Secondary Settings screens: speech recognition, text polish, voice
// session, general preferences, and about. Main Settings stays a
// daily console with summary navigation rows.
import SwiftUI
import OSGKeyboardShared
// MARK: - Navigation row (title + optional trailing summary before chevron)
struct SettingsNavigationRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
private let localizedTitle: LocalizedStringKey?
private let resolvedTitle: String?
var subtitle: String?
init(title: LocalizedStringKey, subtitle: String? = nil) {
self.localizedTitle = title
self.resolvedTitle = nil
self.subtitle = subtitle
}
/// Prefers in-app language via an already-resolved string (`AppL10n`).
init(titleText: String, subtitle: String? = nil) {
self.localizedTitle = nil
self.resolvedTitle = titleText
self.subtitle = subtitle
}
var body: some View {
HStack(spacing: Spacing.sm) {
if let resolvedTitle {
Text(resolvedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
} else if let localizedTitle {
Text(localizedTitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
Spacer(minLength: Spacing.xs)
// Trailing summary — same slot as SettingsVersionRow's value.
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
.lineLimit(1)
}
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
}
/// Version / build row for Settings home (below About). Opens release notes.
struct SettingsVersionRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
var body: some View {
Button {
ReleaseNotesController.shared.presentManually()
} label: {
HStack(spacing: Spacing.sm) {
Text(AppL10n.string("settings.version.title", language: config.uiLanguage))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.xs)
Text(AppVersionDisplay.detailedLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(AppL10n.string("releaseNotes.openHint", language: config.uiLanguage))
}
}
// MARK: - Config entry summaries (shown on Settings home)
enum SettingsConfigSummary {
static func speechRecognition(config: ProviderConfig) -> String {
if config.engineMode == "local" {
return SharedL10n.string(
"engine.asr.appleSpeech",
language: config.uiLanguage
)
}
let providerName = ProviderDisplayName.name(
for: config.asrProviderId,
language: config.uiLanguage
)
let trimmedModel = config.asrModel.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedModel.isEmpty {
return providerName
}
return "\(providerName) · \(trimmedModel)"
}
static func textPolish(config: ProviderConfig) -> String {
let providerName = ProviderDisplayName.name(
for: config.providerId,
language: config.uiLanguage
)
let trimmedModel = config.model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedModel.isEmpty {
return providerName
}
return "\(providerName) · \(trimmedModel)"
}
}
// MARK: - Shared cloud provider card chrome
private struct CloudProviderSettingsCard<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack(spacing: 0) {
content()
}
.surfaceCard()
}
}
// MARK: - Speech recognition (ASR / local engine)
struct SpeechRecognitionSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
if config.engineMode == "cloud" {
CardSection("settings.asrProvider.title") {
CloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
} else {
CardSection("settings.localEngine.title") {
LocalModelsGroup(config: config)
}
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.speechRecognition.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - Text polish (LLM)
struct TextPolishSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.polishProvider.title") {
CloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.textPolish.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - General (appearance, keyboard, sync)
struct GeneralSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@ObservedObject private var typingConfiguration = TypingInputConfiguration.shared
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.general.appearanceLanguage.title") {
VStack(spacing: 0) {
AppLanguagePickerRow(
selection: Binding(
get: { config.uiLanguage },
set: { config.uiLanguage = $0 }
)
)
Divider().background(palette.divider)
AppearancePickerRow()
}
.surfaceCard()
}
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
DefaultInputModePickerRow(
selection: $typingConfiguration.defaultInputMode
)
Divider().background(palette.divider)
RememberLastSurfaceToggleRow(
isOn: $typingConfiguration.rememberLastSurface
)
Divider().background(palette.divider)
NavigationLink {
TypingInputSettingsView()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.typingInput.title",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
Divider().background(palette.divider)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
Divider().background(palette.divider)
KeyboardHapticPickerRow(
selection: Binding(
get: { config.keyboardHapticIntensity },
set: { config.keyboardHapticIntensity = $0 }
)
)
}
.surfaceCard()
}
CardSection("settings.general.sync.title") {
VStack(spacing: 0) {
SettingsICloudSyncRow()
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.general.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - AI Agent
struct AIAgentSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.aiAgent.responseLength.section") {
VStack(spacing: 0) {
AIResponseLengthPickerRow(config: config)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
// MARK: - Clipboard
struct ClipboardSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var config: ProviderConfig
@ObservedObject private var history = ClipboardHistoryStore.shared
@State private var showClearConfirmation = false
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
@State private var pasteAccessNeedsRecovery = false
@State private var showPasteNoTextAlert = false
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.clipboard.section") {
VStack(spacing: 0) {
Toggle(isOn: $config.clipboardHistoryEnabled) {
VStack(alignment: .leading, spacing: 4) {
Text("settings.clipboard.history.title")
.foregroundStyle(palette.textPrimary)
Text("settings.clipboard.history.footer")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
}
}
.tint(palette.accent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Toggle(isOn: clipboardCandidateBinding) {
VStack(alignment: .leading, spacing: 4) {
Text("settings.clipboard.candidate.title")
.foregroundStyle(palette.textPrimary)
Text("settings.clipboard.candidate.footer")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
}
}
.tint(palette.accent)
.disabled(!config.clipboardHistoryEnabled)
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.surfaceCard()
}
// iOS asks per read unless the user flips the durable
// "Paste from Other Apps" permission to Allow.
CardSection("settings.clipboard.paste.section") {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: Spacing.sm) {
Text("settings.clipboard.paste.body")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
if pasteAccessVerified {
Label(
"settings.clipboard.paste.verified",
systemImage: "checkmark.circle.fill"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
.fixedSize()
.accessibilityIdentifier("settings.clipboard.paste.verified")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if !pasteAccessVerified {
Divider().background(palette.divider)
Button {
verifyPasteAccess()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.clipboard.paste.request",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("settings.clipboard.paste.verify")
if pasteAccessNeedsRecovery {
Divider().background(palette.divider)
Button {
AppPermissions.openSystemSettings()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.clipboard.paste.open",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("settings.clipboard.paste.openSettings")
}
}
}
.surfaceCard()
}
CardSection("settings.clipboard.storage.section") {
VStack(spacing: 0) {
Text("settings.clipboard.storage.body")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Button(role: .destructive) {
showClearConfirmation = true
} label: {
HStack {
Text("settings.clipboard.clear.button")
Spacer()
Image(systemName: "trash")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.45 : 1)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.confirmationDialog(
"settings.clipboard.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("settings.clipboard.clear.confirm", role: .destructive) {
history.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("settings.clipboard.clear.message")
}
.alert(
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
isPresented: $showPasteNoTextAlert
) {
Button("common.done") { showPasteNoTextAlert = false }
} message: {
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
}
.onAppear {
history.reload()
refreshPasteAccessState()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPasteAccessState()
pasteAccessNeedsRecovery = false
}
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
if !enabled {
config.clipboardCandidateBarEnabled = false
}
}
}
private var clipboardCandidateBinding: Binding<Bool> {
Binding(
get: { config.clipboardHistoryEnabled && config.clipboardCandidateBarEnabled },
set: { config.clipboardCandidateBarEnabled = $0 }
)
}
private func verifyPasteAccess() {
switch AppPermissions.requestPasteAccess() {
case .verified:
withAnimation(Motion.quick) {
pasteAccessVerified = true
pasteAccessNeedsRecovery = false
}
case .noTextAvailable:
showPasteNoTextAlert = true
case .unavailable:
withAnimation(Motion.quick) {
pasteAccessVerified = false
pasteAccessNeedsRecovery = true
}
}
}
private func refreshPasteAccessState() {
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
}
}
// MARK: - About
struct AboutSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.openURL) private var openURL
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.about.title") {
VStack(spacing: 0) {
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
config.hasCompletedOnboarding = false
config.onboardingPage = 0
}
} label: {
SettingsNavigationRow(title: "settings.onboarding.replay")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
PrivacyPolicyView()
} label: {
SettingsNavigationRow(title: "settings.privacy.policy")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
HelpFeedbackView()
} label: {
SettingsNavigationRow(title: "settings.link.support")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
Button {
openURL(LegalLinks.repositoryURL)
} label: {
HStack(spacing: Spacing.sm) {
Text("settings.link.github")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider().background(palette.divider)
NavigationLink {
OpenSourceLicensesView()
} label: {
SettingsNavigationRow(title: "settings.link.licenses")
}
.buttonStyle(.plain)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.about.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}