9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
541 lines
20 KiB
Swift
541 lines
20 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 }
|
|
)
|
|
)
|
|
Divider().background(palette.divider)
|
|
CursorDragNavigationToggleRow(
|
|
isOn: $config.cursorDragNavigationEnabled
|
|
)
|
|
}
|
|
.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
|
|
@ObservedObject var config: ProviderConfig
|
|
@ObservedObject private var history = ClipboardHistoryStore.shared
|
|
@State private var showClearConfirmation = 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) {
|
|
Text("settings.clipboard.paste.body")
|
|
.font(.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
|
|
Divider().background(palette.divider)
|
|
|
|
Button {
|
|
AppPermissions.requestPasteAccess()
|
|
} label: {
|
|
SettingsNavigationRow(
|
|
titleText: AppL10n.string(
|
|
"settings.clipboard.paste.request",
|
|
language: config.uiLanguage
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Divider().background(palette.divider)
|
|
|
|
Button {
|
|
AppPermissions.openSystemSettings()
|
|
} label: {
|
|
SettingsNavigationRow(
|
|
titleText: AppL10n.string(
|
|
"settings.clipboard.paste.open",
|
|
language: config.uiLanguage
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.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")
|
|
}
|
|
.onAppear {
|
|
history.reload()
|
|
}
|
|
.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 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|