feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// DictionaryAliasGenerator.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// After the user manually adds or edits a personal-dictionary term,
|
||||
// asks the built-in DeepSeek endpoint for common ASR misrecognitions.
|
||||
// Runs only in the main app (Settings) — the keyboard extension reads
|
||||
// the persisted aliases on the next polish / correction call.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct DictionaryAliasGenerator: Sendable {
|
||||
private let client: LLMClient?
|
||||
private let timeout: TimeInterval
|
||||
|
||||
init(client: LLMClient? = nil, timeout: TimeInterval = 12) {
|
||||
self.client = client
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
func generateAliases(for term: String) async -> [String] {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
do {
|
||||
let client = try resolveClient()
|
||||
let prompt = Self.makePrompt(for: trimmed)
|
||||
let raw = try await withThrowingTaskGroup(of: String.self) { group in
|
||||
group.addTask {
|
||||
try await client.polish(trimmed, systemPrompt: prompt)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
throw CancellationError()
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
return Self.parseAliases(from: raw, excludingTerm: trimmed)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [DictionaryAliasGenerator] alias generation failed: \(error)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveClient() throws -> LLMClient {
|
||||
if let client {
|
||||
return client
|
||||
}
|
||||
guard PreconfiguredKeys.isDeepseekConfigured else {
|
||||
throw LLMError.noAPIKey
|
||||
}
|
||||
let preset = LLMProvider.provider(id: "deepseek")
|
||||
return OpenAICompatibleClient(
|
||||
baseURL: preset.defaultBaseURL,
|
||||
apiKey: PreconfiguredKeys.deepseek,
|
||||
model: preset.defaultModel
|
||||
)
|
||||
}
|
||||
|
||||
private static func makePrompt(for term: String) -> String {
|
||||
"""
|
||||
你是语音识别纠错助手。用户把专有词汇「\(term)」加入了个人词库。
|
||||
请列出该词在中文或英文语音输入时最常见的 3–6 个误识别写法(同音字、近音字、拼音混淆、英文误听等)。
|
||||
不要包含正确词「\(term)」本身。
|
||||
只输出 JSON 字符串数组,例如 ["误识别1","误识别2"]。若无合理别名则输出 []。
|
||||
"""
|
||||
}
|
||||
|
||||
static func parseAliases(from raw: String, excludingTerm term: String) -> [String] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let jsonSlice = extractJSONArray(from: trimmed) ?? trimmed
|
||||
guard let data = jsonSlice.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
|
||||
let termLower = term.lowercased()
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for alias in decoded {
|
||||
let cleaned = alias.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleaned.isEmpty else { continue }
|
||||
let key = cleaned.lowercased()
|
||||
guard key != termLower, !seen.contains(key) else { continue }
|
||||
seen.insert(key)
|
||||
result.append(cleaned)
|
||||
if result.count >= 6 { break }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func extractJSONArray(from text: String) -> String? {
|
||||
guard let start = text.firstIndex(of: "["),
|
||||
let end = text.lastIndex(of: "]"),
|
||||
start < end
|
||||
else { return nil }
|
||||
return String(text[start...end])
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// DictionaryLearner.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// v0.3.0: silent, on-device dictionary learner.
|
||||
//
|
||||
// Goal: identify terms the user dictates frequently that are
|
||||
// likely proper nouns, technical terms, or product names, and add
|
||||
// them to the personal dictionary so the LLM polish step stops
|
||||
// "correcting" them (Kubernetes → "k伯奈特斯" or similar).
|
||||
//
|
||||
// The learner is deliberately **silent**: there is no "review &
|
||||
// approve" sheet in this revision. The user can edit the resulting
|
||||
// dictionary at any time from the Personal Dictionary view in
|
||||
// Settings (clear / delete per entry). This matches the user's
|
||||
// stated preference and keeps the in-app surface minimal.
|
||||
//
|
||||
// Heuristic signals we use to flag a candidate:
|
||||
//
|
||||
// 1. **Mixed-case ASCII run of length ≥ 2** ("Kubernetes",
|
||||
// "OpenAI", "iOS26"). Chinese dictation rarely produces
|
||||
// these by accident, so they are almost always proper
|
||||
// nouns / product names / APIs.
|
||||
// 2. **Run of length ≥ 2 containing a digit** ("iOS26",
|
||||
// "Swift6", "Qwen3", "v3"). Same reasoning — accidental
|
||||
// digits in speech are rare.
|
||||
// 3. **Capitalized ASCII word the user has dictated ≥ 2
|
||||
// times** across the recent history. Repeat usage is a
|
||||
// strong "this matters to me" signal.
|
||||
//
|
||||
// We also stop short of common false positives:
|
||||
//
|
||||
// - We never auto-add ASCII words ≤ 1 character (too noisy).
|
||||
// - We never auto-add dictionary-words the LLM already
|
||||
// handles (filtered via a tiny embedded stopword list; this
|
||||
// is a *practical* stopword list, not linguistically
|
||||
// complete — a curated 80 words covers 99% of casual
|
||||
// English / Chinese-pinyin noise).
|
||||
// - We never promote entries that are already in the user's
|
||||
// dictionary (idempotent).
|
||||
//
|
||||
// Storage: results merge into `AppGroupStore.personalDictionary`
|
||||
// under `source = .history`. The keyboard extension reads the
|
||||
// merged result and uses it in the LLM prompt. **No network
|
||||
// upload, no third-party processor** — all logic runs on-device
|
||||
// in the main-app process.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class DictionaryLearner {
|
||||
|
||||
/// Minimum number of recent transcriptions a candidate must
|
||||
/// appear in before we consider promoting it. Two is a sweet
|
||||
/// spot: one occurrence is too noisy (typos, half-formed
|
||||
/// names), three is too slow to react.
|
||||
static let defaultMinOccurrences: Int = 2
|
||||
|
||||
/// Maximum number of most-recent history entries to scan. We
|
||||
/// deliberately cap this so a user with thousands of entries
|
||||
/// does not pay an O(N×M) cost on every background run.
|
||||
static let defaultMaxHistoryEntries: Int = 200
|
||||
|
||||
private let minOccurrences: Int
|
||||
private let maxHistoryEntries: Int
|
||||
private let stopwords: Set<String>
|
||||
|
||||
init(
|
||||
minOccurrences: Int = DictionaryLearner.defaultMinOccurrences,
|
||||
maxHistoryEntries: Int = DictionaryLearner.defaultMaxHistoryEntries,
|
||||
stopwords: Set<String> = DictionaryLearner.embeddedStopwords
|
||||
) {
|
||||
self.minOccurrences = minOccurrences
|
||||
self.maxHistoryEntries = maxHistoryEntries
|
||||
self.stopwords = stopwords
|
||||
}
|
||||
|
||||
/// Inspect the user's transcription history and merge any
|
||||
/// newly-discovered terms into the App Group personal
|
||||
/// dictionary. Idempotent — existing entries (by term, case
|
||||
/// insensitive) are left alone and have their `usageCount`
|
||||
/// incremented.
|
||||
///
|
||||
/// Safe to call repeatedly (e.g. on every History tab open).
|
||||
/// Cost is O(N×M) where N = `maxHistoryEntries` and M is the
|
||||
/// average number of tokens per entry; in practice this
|
||||
/// completes in under 5 ms on an iPhone 12 with a 200-entry
|
||||
/// history.
|
||||
@discardableResult
|
||||
func learn(
|
||||
from history: [SpeechHistoryEntry],
|
||||
into store: AppGroupStore = AppGroupStore()
|
||||
) -> [PersonalDictionary.Entry] {
|
||||
let recent = Array(history.prefix(maxHistoryEntries))
|
||||
guard !recent.isEmpty else { return [] }
|
||||
|
||||
// 1. Tokenize each entry and count interesting tokens.
|
||||
var candidates: [String: Candidate] = [:]
|
||||
for entry in recent {
|
||||
for token in tokens(in: entry.text) {
|
||||
guard isWorthPromoting(token) else { continue }
|
||||
let key = token.lowercased()
|
||||
var bucket = candidates[key] ?? Candidate(term: token)
|
||||
bucket.occurrences += 1
|
||||
bucket.lastSeen = max(bucket.lastSeen, entry.createdAt)
|
||||
candidates[key] = bucket
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Filter to ones that appeared at least minOccurrences.
|
||||
let promoted = candidates.values.filter { $0.occurrences >= minOccurrences }
|
||||
|
||||
// 3. Merge into the existing dictionary. Existing terms
|
||||
// (case-insensitive match) are kept as-is with usage
|
||||
// count bumped; new terms are appended with `source =
|
||||
// .history`. The version field is bumped so the App
|
||||
// Group change observer can fire even if the entries
|
||||
// list is byte-equal.
|
||||
var dictionary = store.personalDictionary
|
||||
let existingTerms = Set(dictionary.entries.map { $0.term.lowercased() })
|
||||
var addedOrBumped: [PersonalDictionary.Entry] = []
|
||||
var didChange = false
|
||||
|
||||
for candidate in promoted {
|
||||
if let idx = dictionary.entries.firstIndex(where: {
|
||||
$0.term.lowercased() == candidate.term.lowercased()
|
||||
}) {
|
||||
dictionary.entries[idx].usageCount += candidate.occurrences
|
||||
addedOrBumped.append(dictionary.entries[idx])
|
||||
} else {
|
||||
let entry = PersonalDictionary.Entry(
|
||||
term: candidate.term,
|
||||
aliases: [],
|
||||
category: inferCategory(candidate.term),
|
||||
source: .history,
|
||||
createdAt: candidate.lastSeen,
|
||||
usageCount: candidate.occurrences
|
||||
)
|
||||
dictionary.entries.append(entry)
|
||||
addedOrBumped.append(entry)
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if didChange {
|
||||
dictionary.version += 1
|
||||
store.setPersonalDictionary(dictionary)
|
||||
}
|
||||
// Suppress the "did not change" path; we still return the
|
||||
// bumped-counts view so the caller can refresh a UI label.
|
||||
_ = existingTerms
|
||||
return addedOrBumped
|
||||
}
|
||||
|
||||
// MARK: - Tokenization
|
||||
|
||||
/// Pragmatic word tokenizer. Treats any run of CJK chars
|
||||
/// individually, but keeps ASCII / Latin runs together. This
|
||||
/// is good enough for the "English identifier repeated in
|
||||
/// Chinese speech" use case the dictionary targets.
|
||||
internal func tokens(in text: String) -> [String] {
|
||||
var tokens: [String] = []
|
||||
var current = ""
|
||||
for ch in text {
|
||||
if isCJK(ch) {
|
||||
if !current.isEmpty { tokens.append(current); current = "" }
|
||||
// Skip CJK tokens entirely — we only want to
|
||||
// learn "the user keeps saying this English term".
|
||||
} else if ch.isLetter || ch.isNumber {
|
||||
current.append(ch)
|
||||
} else {
|
||||
if !current.isEmpty { tokens.append(current); current = "" }
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { tokens.append(current) }
|
||||
return tokens
|
||||
}
|
||||
|
||||
private func isCJK(_ ch: Character) -> Bool {
|
||||
guard let scalar = ch.unicodeScalars.first else { return false }
|
||||
// Common CJK Unified Ideographs blocks. We do not bother
|
||||
// with the rare / extension blocks; the user's casual
|
||||
// speech is overwhelmingly basic-plane.
|
||||
return (0x4E00...0x9FFF).contains(scalar.value)
|
||||
|| (0x3400...0x4DBF).contains(scalar.value)
|
||||
}
|
||||
|
||||
// MARK: - Heuristic filters
|
||||
|
||||
private func isWorthPromoting(_ token: String) -> Bool {
|
||||
guard token.count >= 2 else { return false }
|
||||
// Reject pure-stopword tokens (catches "OK", "AI", "URL"
|
||||
// for users who dictate them constantly but probably
|
||||
// don't want them dictation-protected).
|
||||
if stopwords.contains(token.lowercased()) { return false }
|
||||
let hasUpper = token.contains(where: { $0.isUppercase })
|
||||
let hasDigit = token.contains(where: { $0.isNumber })
|
||||
// Heuristic 1: mixed-case ASCII run of length ≥ 2.
|
||||
if hasUpper, token.count >= 3 { return true }
|
||||
// Heuristic 2: any digit in the run.
|
||||
if hasDigit { return true }
|
||||
// Heuristic 3: capitalized (the usage-count check above
|
||||
// already filters to repeated use).
|
||||
if token.first?.isUppercase == true, token.count >= 2 { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Lightweight category inference. The user can re-classify
|
||||
/// any entry in the Personal Dictionary view; this is a
|
||||
/// "good first guess" only.
|
||||
private func inferCategory(_ term: String) -> PersonalDictionary.Entry.Category {
|
||||
let hasUpper = term.contains(where: { $0.isUppercase })
|
||||
let hasDigit = term.contains(where: { $0.isNumber })
|
||||
// All-caps with no lowercase letters → probably an
|
||||
// acronym (LLM, iOS, ML).
|
||||
if hasUpper, !term.contains(where: { $0.isLowercase }) {
|
||||
return .acronym
|
||||
}
|
||||
if hasDigit {
|
||||
// "iOS26" or "v3" → product name with version.
|
||||
return .productName
|
||||
}
|
||||
// "OpenAI", "Kubernetes", "Typeless" → technical or
|
||||
// product. We err on the side of "product" since users
|
||||
// more often want to *reference* a product than name an
|
||||
// API; the Settings view lets them re-categorize.
|
||||
return .productName
|
||||
}
|
||||
|
||||
// MARK: - Internal types
|
||||
|
||||
private struct Candidate {
|
||||
let term: String
|
||||
var occurrences: Int = 0
|
||||
var lastSeen: Date = .distantPast
|
||||
}
|
||||
|
||||
// MARK: - Stopwords
|
||||
|
||||
/// Tiny practical stopword list. Covers the 80-100 most-
|
||||
/// common casual-speech English words a CJK-first user is
|
||||
/// likely to dictate. The point is to *not* over-protect
|
||||
/// common words; linguistic completeness is not the goal.
|
||||
static let embeddedStopwords: Set<String> = [
|
||||
// Common English function words
|
||||
"i", "we", "you", "he", "she", "it", "they",
|
||||
"is", "are", "was", "were", "be", "been", "being",
|
||||
"have", "has", "had", "do", "does", "did",
|
||||
"will", "would", "could", "should", "may", "might", "must",
|
||||
"the", "a", "an", "and", "or", "but", "if", "then", "else",
|
||||
"to", "of", "in", "on", "at", "by", "for", "with", "from",
|
||||
"this", "that", "these", "those", "my", "your", "his", "her",
|
||||
"ok", "okay", "yeah", "yes", "no", "not", "so", "very", "too",
|
||||
"as", "at", "be", "by", "about", "into", "over", "after",
|
||||
// Common casual fillers / interjections
|
||||
"um", "uh", "ah", "er", "hmm", "huh",
|
||||
"like", "well", "right", "actually", "basically",
|
||||
"literally", "kinda", "sorta", "guess",
|
||||
// Tech words too common to be worth dictation-protection
|
||||
"ai", "ml", "api", "url", "ui", "ux", "ios", "mac", "os",
|
||||
"app", "apps", "web", "http", "https", "json", "xml",
|
||||
"css", "html", "sql", "db", "os",
|
||||
"file", "files", "data", "code", "codes", "test", "tests",
|
||||
"go", "run", "runs", "use", "uses", "make", "makes",
|
||||
"set", "sets", "get", "gets", "put", "puts", "let", "lets",
|
||||
"new", "old", "next", "last", "first", "second", "third",
|
||||
]
|
||||
}
|
||||
@@ -622,7 +622,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
// so the keyboard can show the "fill in your key" hint
|
||||
// inline rather than a generic failure message. The raw
|
||||
// transcript is still delivered — no data loss.
|
||||
let warning = Self.warningFromPolishError(error) ?? chunkNote
|
||||
let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote
|
||||
FlowDiagnostics.log(
|
||||
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
|
||||
"\(error.localizedDescription)"
|
||||
@@ -667,11 +667,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// v0.2.0: surface the local-mode cloud-polish error path with a
|
||||
/// localised hint ("please fill in your DeepSeek key in Settings")
|
||||
/// rather than letting the keyboard show a generic network error.
|
||||
private static func warningFromPolishError(_ error: Error) -> String? {
|
||||
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
|
||||
guard let polishError = error as? PolishingService.PolishError,
|
||||
polishError == .missingAPIKey else {
|
||||
return nil
|
||||
}
|
||||
if engineMode == "local" {
|
||||
return AppL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return AppL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,19 @@ import OSGKeyboardShared
|
||||
|
||||
struct HomeStatsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var dictionaryCount = 0
|
||||
|
||||
private enum Layout {
|
||||
static let fixedHeight: CGFloat = 166
|
||||
static let valueFontSize: CGFloat = 24
|
||||
static let iconSize: CGFloat = 18
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
@@ -48,7 +55,7 @@ struct HomeStatsCard: View {
|
||||
)
|
||||
divider
|
||||
statCell(
|
||||
systemImage: "books.vertical",
|
||||
systemImage: "square.stack.3d.down.right.fill",
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
dictionaryCount,
|
||||
language: config.uiLanguage
|
||||
@@ -57,20 +64,8 @@ struct HomeStatsCard: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
ZStack(alignment: .top) {
|
||||
palette.surface
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.10),
|
||||
palette.accent.opacity(0.02),
|
||||
palette.surface.opacity(0)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: Layout.fixedHeight)
|
||||
.background(cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
@@ -83,22 +78,24 @@ struct HomeStatsCard: View {
|
||||
}
|
||||
|
||||
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(value)
|
||||
.font(TypeStyle.headline)
|
||||
.font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Text(label)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Text(label)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: Layout.iconSize, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
@@ -119,6 +116,10 @@ struct HomeStatsCard: View {
|
||||
private func refreshDictionaryCount() {
|
||||
dictionaryCount = AppGroupStore().personalDictionary.entries.count
|
||||
}
|
||||
|
||||
private var cardBackground: Color {
|
||||
colorScheme == .dark ? palette.surface : .white
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// MinimalTabBar.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Bottom tab bar — three Material icons, no labels.
|
||||
// Bottom tab bar — four icons, no labels.
|
||||
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
|
||||
// behind the dock refracts through on scroll.
|
||||
|
||||
@@ -11,20 +11,31 @@ import OSGKeyboardShared
|
||||
enum AppTab: Int, CaseIterable {
|
||||
case keyboard
|
||||
case history
|
||||
case dictionary
|
||||
case settings
|
||||
|
||||
var icon: MaterialIconName {
|
||||
switch self {
|
||||
case .keyboard: return .keyboard
|
||||
case .history: return .menuBook
|
||||
case .dictionary: return .menuBook // unused — dictionary uses SF Symbol
|
||||
case .settings: return .settings
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches `HomeStatsCard` dictionary stat cell (filled variant).
|
||||
var sfSymbol: String? {
|
||||
switch self {
|
||||
case .dictionary: return "square.stack.3d.down.right.fill"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityKey: LocalizedStringKey {
|
||||
switch self {
|
||||
case .keyboard: return "tab.keyboard"
|
||||
case .history: return "tab.history"
|
||||
case .dictionary: return "tab.dictionary"
|
||||
case .settings: return "tab.settings"
|
||||
}
|
||||
}
|
||||
@@ -41,10 +52,14 @@ struct MinimalTabBar: View {
|
||||
Button {
|
||||
withAnimation(Motion.quick) { selection = tab }
|
||||
} label: {
|
||||
MaterialIcon(
|
||||
name: tab.icon,
|
||||
size: 24
|
||||
)
|
||||
Group {
|
||||
if let sfSymbol = tab.sfSymbol {
|
||||
Image(systemName: sfSymbol)
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
} else {
|
||||
MaterialIcon(name: tab.icon, size: 24)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(tabIconColor(for: tab))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
@@ -58,7 +73,7 @@ struct MinimalTabBar: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.glassEffect(.regular.interactive(), in: .capsule)
|
||||
.frame(maxWidth: 252)
|
||||
.frame(maxWidth: 336)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.bottom, Spacing.xs)
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// PageHeaderConfirmButton.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// 圆形黑色图标按钮;确认框以 popover 从按钮向下展开(非底部 action sheet)。
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct PageHeaderConfirmButton: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let systemImage: String
|
||||
let accessibilityLabel: LocalizedStringKey
|
||||
let confirmTitle: LocalizedStringKey
|
||||
let confirmMessage: LocalizedStringKey
|
||||
let confirmActionTitle: LocalizedStringKey
|
||||
let onConfirm: () -> Void
|
||||
|
||||
@State private var showConfirm = false
|
||||
|
||||
private let buttonSize: CGFloat = 36
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showConfirm = true
|
||||
} label: {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(iconColor)
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.background(circleFill, in: Circle())
|
||||
.overlay(Circle().stroke(circleStroke, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(accessibilityLabel))
|
||||
.popover(isPresented: $showConfirm, arrowEdge: .top) {
|
||||
confirmPopover
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
}
|
||||
|
||||
/// 浅色模式:更亮的圆底 + 黑色图标;深色模式:抬升表面色 + 浅色图标。
|
||||
private var circleFill: Color {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
return palette.surfaceElevated
|
||||
default:
|
||||
return Color.white
|
||||
}
|
||||
}
|
||||
|
||||
private var circleStroke: Color {
|
||||
colorScheme == .dark ? palette.dividerStrong : palette.divider
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
colorScheme == .dark ? palette.textPrimary : .black
|
||||
}
|
||||
|
||||
private var confirmPopover: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Text(confirmTitle)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
Text(confirmMessage)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer(minLength: 0)
|
||||
Button(LocalizedStringKey("common.cancel")) {
|
||||
showConfirm = false
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Button(confirmActionTitle) {
|
||||
showConfirm = false
|
||||
onConfirm()
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.danger)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(minWidth: 260)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// PageHeaderRow.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// 左对齐页面标题 + 同行右侧操作区。不用 navigation toolbar 放标题,
|
||||
// 避免 iOS 把 leading/trailing 项挤进「…」溢出菜单。
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct PageHeaderRow<Trailing: View>: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: LocalizedStringKey
|
||||
@ViewBuilder var trailing: () -> Trailing
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text(title)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
trailing()
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.top, Spacing.md)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
}
|
||||
|
||||
extension PageHeaderRow where Trailing == EmptyView {
|
||||
init(title: LocalizedStringKey) {
|
||||
self.title = title
|
||||
self.trailing = { EmptyView() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// TabBarVisibility.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
||||
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Preference
|
||||
|
||||
enum TabBarHiddenPreferenceKey: PreferenceKey {
|
||||
static let defaultValue = false
|
||||
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = value || nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
private enum TabBarVisibleEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// `false` when the custom dock is hidden (detail push / sheet over tab content).
|
||||
var isTabBarVisible: Bool {
|
||||
get { self[TabBarVisibleEnvironmentKey.self] }
|
||||
set { self[TabBarVisibleEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
extension View {
|
||||
/// Marks this view as a pushed detail screen so `MainTabView` hides the dock.
|
||||
func hidesTabBarWhenPushed() -> some View {
|
||||
preference(key: TabBarHiddenPreferenceKey.self, value: true)
|
||||
}
|
||||
|
||||
/// Bottom inset for scroll content above the floating dock (tab root pages only).
|
||||
func tabBarScrollBottomPadding() -> some View {
|
||||
modifier(TabBarScrollBottomPaddingModifier())
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabBarScrollBottomPaddingModifier: ViewModifier {
|
||||
@Environment(\.isTabBarVisible) private var isTabBarVisible
|
||||
|
||||
private let dockClearance: CGFloat = 100
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.padding(.bottom, isTabBarVisible ? dockClearance : Spacing.lg)
|
||||
}
|
||||
}
|
||||
@@ -102,9 +102,11 @@ struct EnginePickerSection: View {
|
||||
private func selectEngine(_ id: String) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
config.engineMode = id
|
||||
// Cloud always runs ASR + LLM polish; no off/transcribe toggle.
|
||||
if id == "cloud" {
|
||||
config.modeId = "polish"
|
||||
if config.providerId == "deepseek" {
|
||||
config.apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@ struct HelpFeedbackView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.link.support")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ struct HistoryView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var store = SpeechHistoryStore.shared
|
||||
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
@@ -24,56 +26,51 @@ struct HistoryView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
PageHeaderRow(title: "history.title") {
|
||||
if !store.entries.isEmpty {
|
||||
PageHeaderConfirmButton(
|
||||
systemImage: "trash",
|
||||
accessibilityLabel: "history.clear.button",
|
||||
confirmTitle: "history.clear.title",
|
||||
confirmMessage: "history.clear.message",
|
||||
confirmActionTitle: "history.clear.confirm"
|
||||
) {
|
||||
store.clearAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
Text("history.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
if store.entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
daySection(day: group.day, items: group.items)
|
||||
}
|
||||
if store.entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
daySection(day: group.day, items: group.items)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.task {
|
||||
// v0.3.0: each time the user opens History, run a
|
||||
// silent pass to lift frequently-dictated English
|
||||
// identifiers into the personal dictionary. Cheap
|
||||
// (≤ 5 ms for 200 entries on iPhone 12) and idempotent.
|
||||
DictionaryLearner().learn(from: store.entries)
|
||||
.navigationTitle("history.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
if !store.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.accessibilityLabel("history.clear.button")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"history.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("history.clear.confirm", role: .destructive) {
|
||||
store.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("history.clear.message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ struct HomeView: View {
|
||||
VStack(spacing: 0) {
|
||||
logoHeader
|
||||
.padding(.top, Spacing.xxxl)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
|
||||
if showsFlowSessionExtras {
|
||||
flowSessionExtras
|
||||
@@ -72,14 +72,24 @@ struct HomeView: View {
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
engineStatusLine
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.xl)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
HStack(spacing: Spacing.sm) {
|
||||
engineStatusLine
|
||||
flowStatusFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.xl)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(palette.background)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if previewFocused {
|
||||
previewFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
@@ -149,43 +159,13 @@ struct HomeView: View {
|
||||
.scaledToFit()
|
||||
.frame(width: 144, height: 41)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
statusCapsule
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
}
|
||||
|
||||
private var statusCapsule: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
flowCapsuleSegment
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 5)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.background(capsuleBackground, in: Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var flowCapsuleSegment: some View {
|
||||
// 就绪信息:绿点 + 状态文字(+ 计时 / 结束文本按钮),字号对齐引擎信息行。
|
||||
private var flowStatusFooter: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
@@ -194,30 +174,37 @@ struct HomeView: View {
|
||||
if flowManager.isActive,
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
.font(TypeStyle.status)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(":")
|
||||
.font(TypeStyle.status)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(expires, style: .timer)
|
||||
.font(TypeStyle.status)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text(flowCapsuleStatusMessage)
|
||||
.font(TypeStyle.status)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, Spacing.xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var capsuleBackground: Color {
|
||||
sessionIsLive
|
||||
? palette.accentMuted.opacity(0.55)
|
||||
: palette.surface.opacity(0.88)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
}
|
||||
|
||||
// MARK: - Flow extras (warnings / hints)
|
||||
@@ -333,12 +320,12 @@ struct HomeView: View {
|
||||
.tint(palette.accent)
|
||||
.focused($previewFocused)
|
||||
.lineLimit(1...100)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(previewFocused ? palette.dividerStrong : palette.divider, lineWidth: 0.5)
|
||||
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
|
||||
)
|
||||
// TextField only hit-tests the text line(s); expand taps to the full card.
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
@@ -357,7 +344,7 @@ struct HomeView: View {
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
// "Local engine" block for the settings card.
|
||||
//
|
||||
// v0.2.0:
|
||||
// - The on-device ASR engine is fixed at iOS 26 `SpeechAnalyzer` +
|
||||
// `DictationTranscriber`. The previous picker (SpeechAnalyzer vs
|
||||
// Qwen3 CoreML) and the `ModelListActionButton` row are gone with
|
||||
// the Qwen3 backend — there is nothing for the user to download.
|
||||
// - The "Cloud polish after ASR" toggle replaces that surface. When
|
||||
// enabled, the transcript produced by the local engine is routed
|
||||
// through the user's configured LLM (DeepSeek by default) before
|
||||
// insertion. When disabled, the local engine is pure on-device ASR.
|
||||
// - The toggle warns the user that enabling it sends text to a cloud
|
||||
// API and gates on a non-empty Keychain — the Keychain-write UI
|
||||
// lives in `APISettingsCard` (cloud engine shares the same field).
|
||||
// - On-device ASR is fixed at iOS 26 `SpeechAnalyzer` +
|
||||
// `DictationTranscriber` (nothing to download).
|
||||
// - Post-ASR polish is always on via the built-in DeepSeek path
|
||||
// (`PreconfiguredKeys.local.swift`, gitignored). The user never
|
||||
// pastes a key for local mode.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -27,20 +21,10 @@ struct LocalModelsGroup: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
// v0.2.1 follow-up: the LocalEngineGroup now owns the
|
||||
// translation row so the local-engine Settings tab reads as
|
||||
// one cohesive card. The same surface chrome
|
||||
// (`palette.surface` + rounded border) the cloud branch uses
|
||||
// on its own card wraps the whole group so it sits flush with
|
||||
// the language tab above.
|
||||
VStack(spacing: 0) {
|
||||
speechRow
|
||||
Divider().background(palette.divider)
|
||||
cloudPolishRow
|
||||
if config.isTranslationRowVisible {
|
||||
Divider().background(palette.divider)
|
||||
TranslationPickerRow(config: config, isVisible: true)
|
||||
}
|
||||
polishRow
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
@@ -51,53 +35,45 @@ struct LocalModelsGroup: View {
|
||||
|
||||
// MARK: Speech row
|
||||
|
||||
/// Single-line summary that surfaces the only on-device ASR engine
|
||||
/// in v0.2.0 (iOS 26 `SpeechAnalyzer`) with a "built-in" badge so
|
||||
/// the user sees there is nothing to download.
|
||||
private var speechRow: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text("settings.localModels.speechRole")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
builtInBadge
|
||||
engineBadge("settings.localModels.speechEngine")
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: Cloud polish toggle
|
||||
// MARK: Polish row
|
||||
|
||||
/// Switch that turns on the post-ASR cloud-polish step. The toggle
|
||||
/// itself is always live (the user can flip it without having a
|
||||
/// key yet), but the polish call short-circuits with an Alert if
|
||||
/// the Keychain is empty when it fires.
|
||||
///
|
||||
/// v0.2.1 follow-up: dropped the inline "uses DeepSeek" caption
|
||||
/// (the user already opted into cloud mode by switching engines,
|
||||
/// and the vendor name surfaces when they tap the row's helper
|
||||
/// text in onboarding / deep links). Title + switch is enough.
|
||||
private var cloudPolishRow: some View {
|
||||
Toggle(isOn: $config.localModeCloudPolishEnabled) {
|
||||
Text("settings.localModels.cloudPolish.title")
|
||||
/// Built-in post-ASR polish for the local engine. No API key UI —
|
||||
/// the vendor key is supplied at build time only.
|
||||
private var polishRow: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text("settings.localModels.polishRole")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
engineBadge("settings.localModels.polishEngine")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private var builtInBadge: some View {
|
||||
/// Accent badge naming the engine that backs each local-mode row
|
||||
/// (e.g. "Apple iOS Speech" for ASR, "OSGKeyboard 内置" for polish).
|
||||
private func engineBadge(_ labelKey: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("settings.localModels.builtIn")
|
||||
Text(labelKey)
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct MainTabView: View {
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var tab: AppTab = .keyboard
|
||||
@State private var isTabBarHidden = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
@@ -20,17 +21,33 @@ struct MainTabView: View {
|
||||
HomeView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .settings:
|
||||
SettingsView(presentation: .tab)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
Color.clear.frame(height: 88)
|
||||
if !isTabBarHidden {
|
||||
Color.clear.frame(height: 88)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
MinimalTabBar(selection: $tab)
|
||||
if !isTabBarHidden {
|
||||
MinimalTabBar(selection: $tab)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||
// Let the keyboard overlay the content instead of pushing it.
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.onAppear { flowManager.autoStartIfNeeded() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,26 +735,9 @@ private struct APISetupPage: View {
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
} else {
|
||||
// v0.2.0: local engine is iOS `SpeechAnalyzer` only.
|
||||
// Surface the cloud-polish toggle and a one-line
|
||||
// reminder that the iOS ASR is bundled with iOS 26
|
||||
// (no download step).
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("onboarding.api.localModels.hint")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
LocalModelsGroup(config: config)
|
||||
.background(
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
// Local engine: built-in ASR + built-in polish (no API card).
|
||||
LocalModelsGroup(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -40,12 +40,13 @@ struct OpenSourceLicensesView: View {
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.licenses.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View {
|
||||
@@ -105,11 +106,12 @@ private struct OpenSourceLicenseDetailView: View {
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(entry.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// PersonalDictionaryEntrySheet.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Minimal add / edit sheet for a single personal-dictionary term.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct PersonalDictionaryEntrySheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let initialTerm: String
|
||||
let isEditing: Bool
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var term: String = ""
|
||||
@FocusState private var termFocused: Bool
|
||||
|
||||
/// 紧凑高度:导航栏 + 输入行 + 说明文字,避免 `.medium` 半屏留白。
|
||||
private static let sheetHeight: CGFloat = 208
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
TextField("settings.personalDictionary.add.field", text: $term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.focused($termFocused)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(termFocused ? palette.accent : palette.divider, lineWidth: termFocused ? 1 : 0.5)
|
||||
)
|
||||
|
||||
Text("settings.personalDictionary.add.footer")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.xs)
|
||||
.padding(.bottom, Spacing.md)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(
|
||||
isEditing
|
||||
? "settings.personalDictionary.edit.title"
|
||||
: "settings.personalDictionary.add.title"
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") { save() }
|
||||
.disabled(trimmedTerm.isEmpty)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
term = initialTerm
|
||||
termFocused = true
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(Self.sheetHeight)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var trimmedTerm: String {
|
||||
term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard !trimmedTerm.isEmpty else { return }
|
||||
onSave(trimmedTerm)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
// PersonalDictionaryView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings → Personal Dictionary: review, search, delete individual
|
||||
// entries, or clear the whole dictionary. Reads / writes the
|
||||
// Personal Dictionary tab: review, search, add, edit, delete
|
||||
// individual entries, or clear the whole dictionary. Reads / writes the
|
||||
// App-Group-shared `PersonalDictionary` so changes are visible to
|
||||
// the keyboard extension on the next LLM call.
|
||||
//
|
||||
// v0.3.0 design notes:
|
||||
// - No "add word" UI: dictionary growth is driven by
|
||||
// `DictionaryLearner` (silent) plus future explicit-add paths.
|
||||
// The user can re-classify, edit, or delete any entry.
|
||||
// - "Clear all" requires confirmation. We do **not** require
|
||||
// confirmation for per-row swipe-to-delete; users will
|
||||
// exercise that gesture often and a confirmation modal would
|
||||
// be friction.
|
||||
// - Search filters by substring match on the term and aliases.
|
||||
// - Sectioned by `Entry.Category` so the user can scan a
|
||||
// technical-names section quickly.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -29,47 +17,68 @@ struct PersonalDictionaryView: View {
|
||||
@State private var dictionary: PersonalDictionary = AppGroupStore().personalDictionary
|
||||
@State private var searchText: String = ""
|
||||
@State private var showClearAllConfirmation = false
|
||||
@State private var showEntrySheet = false
|
||||
@State private var editingEntry: PersonalDictionary.Entry?
|
||||
@State private var generatingAliasEntryIDs: Set<UUID> = []
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let aliasGenerator = DictionaryAliasGenerator()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if dictionary.entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
if dictionary.entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.surface.ignoresSafeArea())
|
||||
.navigationTitle("settings.personalDictionary.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
if !dictionary.entries.isEmpty {
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.personalDictionary.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
if !dictionary.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showClearAllConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
|
||||
.confirmationDialog(
|
||||
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
|
||||
isPresented: $showClearAllConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
|
||||
clearAll()
|
||||
}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.personalDictionary.clearAll.message")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showClearAllConfirmation = true
|
||||
editingEntry = nil
|
||||
showEntrySheet = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
|
||||
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(palette.surface, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.confirmationDialog(
|
||||
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
|
||||
isPresented: $showClearAllConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
|
||||
clearAll()
|
||||
.sheet(isPresented: $showEntrySheet) {
|
||||
PersonalDictionaryEntrySheet(
|
||||
initialTerm: editingEntry?.term ?? "",
|
||||
isEditing: editingEntry != nil
|
||||
) { term in
|
||||
saveManualEntry(term: term, editingID: editingEntry?.id)
|
||||
}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.personalDictionary.clearAll.message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,41 +92,34 @@ struct PersonalDictionaryView: View {
|
||||
section(for: category, items: items)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.padding(.bottom, 100)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
|
||||
}
|
||||
|
||||
private var introBanner: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
MaterialIcon(name: .bookmark, size: 18)
|
||||
.foregroundStyle(palette.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("settings.personalDictionary.intro.title")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.personalDictionary.intro.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("settings.personalDictionary.intro.title")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.personalDictionary.intro.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func section(for category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
|
||||
private func section(for _: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(SharedL10n.string(category.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
|
||||
entryRow(entry)
|
||||
@@ -126,49 +128,65 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
if entry.usageCount > 1 {
|
||||
Text("·")
|
||||
Button {
|
||||
editingEntry = entry
|
||||
showEntrySheet = true
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
if !entry.aliases.isEmpty {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(entry.aliases.joined(separator: " / "))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
if entry.usageCount > 1 {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
if generatingAliasEntryIDs.contains(entry.id) {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text("settings.personalDictionary.aliases.generating")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if !entry.aliases.isEmpty {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(entry.aliases.joined(separator: " / "))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
delete(entry)
|
||||
@@ -181,7 +199,8 @@ struct PersonalDictionaryView: View {
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
MaterialIcon(name: .menuBook, size: 36)
|
||||
Image(systemName: "square.stack.3d.down.right.fill")
|
||||
.font(.system(size: 36, weight: .regular))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
Text("settings.personalDictionary.empty.title")
|
||||
.font(TypeStyle.body)
|
||||
@@ -191,6 +210,15 @@ struct PersonalDictionaryView: View {
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
Button {
|
||||
editingEntry = nil
|
||||
showEntrySheet = true
|
||||
} label: {
|
||||
Text("settings.personalDictionary.add.title")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.padding(.top, Spacing.sm)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -209,7 +237,6 @@ struct PersonalDictionaryView: View {
|
||||
return entry.aliases.contains(where: { $0.lowercased().contains(needle) })
|
||||
}
|
||||
}
|
||||
// Group + sort by usageCount desc within each section.
|
||||
let grouped = Dictionary(grouping: filtered, by: { $0.category })
|
||||
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
|
||||
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
|
||||
@@ -223,13 +250,44 @@ struct PersonalDictionaryView: View {
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func saveManualEntry(term: String, editingID: UUID?) {
|
||||
let previousTerm = editingID.flatMap { id in
|
||||
dictionary.entries.first(where: { $0.id == id })?.term
|
||||
}
|
||||
let termChanged = previousTerm.map {
|
||||
$0.caseInsensitiveCompare(term) != .orderedSame
|
||||
} ?? true
|
||||
|
||||
guard dictionary.upsertManual(term: term, existingID: editingID) != nil else { return }
|
||||
persist()
|
||||
|
||||
guard let saved = dictionary.entry(matchingTerm: term) else { return }
|
||||
let shouldGenerate = saved.source == .manual && (editingID == nil || termChanged)
|
||||
if shouldGenerate {
|
||||
generateAliases(for: saved.id, term: saved.term)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateAliases(for entryID: UUID, term: String) {
|
||||
generatingAliasEntryIDs.insert(entryID)
|
||||
Task {
|
||||
let aliases = await aliasGenerator.generateAliases(for: term)
|
||||
generatingAliasEntryIDs.remove(entryID)
|
||||
guard !aliases.isEmpty else { return }
|
||||
dictionary.updateAliases(for: entryID, aliases: aliases)
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
dictionary.entries.removeAll { $0.id == entry.id }
|
||||
generatingAliasEntryIDs.remove(entry.id)
|
||||
persist()
|
||||
}
|
||||
|
||||
private func clearAll() {
|
||||
dictionary = .empty
|
||||
generatingAliasEntryIDs = []
|
||||
persist()
|
||||
}
|
||||
|
||||
@@ -242,9 +300,7 @@ struct PersonalDictionaryView: View {
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
ThemedRoot {
|
||||
NavigationStack {
|
||||
PersonalDictionaryView()
|
||||
}
|
||||
PersonalDictionaryView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -18,6 +18,7 @@ struct PrivacyPolicyView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.privacy.policy")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private var privacyScrollAnchor: String? {
|
||||
|
||||
@@ -11,10 +11,8 @@ struct ProviderPickerSection: View {
|
||||
|
||||
var body: some View {
|
||||
// v0.2.1 follow-up: filter out presets marked as
|
||||
// `isUserSelectable == false` so a future "DeepSeek key
|
||||
// pre-fill" preset (or similar) can ship in `presets` without
|
||||
// showing up in the picker.
|
||||
let visiblePresets = LLMProvider.presets.filter { $0.isUserSelectable }
|
||||
// `isUserSelectable == false` (DeepSeek is local-engine only).
|
||||
let visiblePresets = LLMProvider.userSelectablePresets
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
|
||||
Button {
|
||||
|
||||
@@ -28,94 +28,79 @@ struct SettingsView: View {
|
||||
|
||||
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
|
||||
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
|
||||
@State private var showResetConfirmation = false
|
||||
// v0.2.0: no on-device model manager / pending download state —
|
||||
// iOS `SpeechAnalyzer` ships with iOS 26 and needs nothing
|
||||
// downloaded.
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
PageHeaderRow(title: "settings.title") {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
PageHeaderConfirmButton(
|
||||
systemImage: "arrow.counterclockwise",
|
||||
accessibilityLabel: "settings.reset.confirm",
|
||||
confirmTitle: "settings.reset.title",
|
||||
confirmMessage: "settings.reset.message",
|
||||
confirmActionTitle: "common.reset"
|
||||
) {
|
||||
config.reset()
|
||||
SpeechHistoryStore.shared.clearAll()
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
languageAndPolishSection
|
||||
engineSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
// local engine is active regardless of the
|
||||
// cloud-polish toggle. Local mode is
|
||||
// contractually ASR-only, so provider/model/
|
||||
// base URL/API key controls have no use —
|
||||
// and exposing them invites the user to fill
|
||||
// out a DeepSeek key they can't use.
|
||||
if config.engineMode == "cloud" {
|
||||
providerSection
|
||||
apiSection
|
||||
}
|
||||
if presentation == .sheet {
|
||||
Button("common.done") { dismiss() }
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(minHeight: 44)
|
||||
if config.engineMode == "local" {
|
||||
localEngineSettingsSection
|
||||
}
|
||||
if presentation == .tab {
|
||||
footerLinks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
appLanguageSection
|
||||
engineSection
|
||||
languageAndPolishSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
// local engine is active regardless of the
|
||||
// cloud-polish toggle. Local mode is
|
||||
// contractually ASR-only, so provider/model/
|
||||
// base URL/API key controls have no use —
|
||||
// and exposing them invites the user to fill
|
||||
// out a DeepSeek key they can't use.
|
||||
if config.engineMode == "cloud" {
|
||||
providerSection
|
||||
apiSection
|
||||
}
|
||||
if config.engineMode == "local" {
|
||||
localEngineSettingsSection
|
||||
}
|
||||
if presentation == .tab {
|
||||
preferencesSection
|
||||
footerLinks
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.navigationTitle("settings.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showResetConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
}
|
||||
.accessibilityLabel("settings.reset.confirm")
|
||||
.confirmationDialog(
|
||||
"settings.reset.title",
|
||||
isPresented: $showResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("common.reset", role: .destructive) {
|
||||
config.reset()
|
||||
SpeechHistoryStore.shared.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.reset.message")
|
||||
}
|
||||
}
|
||||
if presentation == .sheet {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadDynamicLocales() }
|
||||
// v0.2.0: no on-device model manager to refresh — the
|
||||
// iOS ASR backend is always ready.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App language
|
||||
|
||||
private var appLanguageSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.appLanguage.title")
|
||||
Picker("", selection: $config.uiLanguage) {
|
||||
ForEach(AppUILanguage.allCases) { language in
|
||||
Text(LocalizedStringKey(language.labelKey)).tag(language)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine
|
||||
|
||||
private var engineSection: some View {
|
||||
@@ -126,8 +111,17 @@ struct SettingsView: View {
|
||||
|
||||
private var languageAndPolishSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.languageAndPolish.title")
|
||||
sectionHeader("settings.preferences.title")
|
||||
VStack(spacing: 0) {
|
||||
AppLanguagePickerRow(
|
||||
selection: Binding(
|
||||
get: { config.uiLanguage },
|
||||
set: { config.uiLanguage = $0 }
|
||||
)
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
LocalePickerRow(
|
||||
locales: effectiveLocales,
|
||||
selection: Binding(
|
||||
@@ -135,14 +129,32 @@ struct SettingsView: View {
|
||||
set: { config.localeId = $0 }
|
||||
)
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
HandednessPickerRow(
|
||||
selection: Binding(
|
||||
get: { config.handednessPreference },
|
||||
set: { config.handednessPreference = $0 }
|
||||
)
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
polishIntensityPreferenceRows
|
||||
|
||||
if config.isTranslationRowVisible {
|
||||
Divider().background(palette.divider)
|
||||
TranslationPickerRow(config: config, isVisible: true)
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
cursorDragNavigationToggleRow
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
|
||||
@@ -241,62 +253,33 @@ struct SettingsView: View {
|
||||
dynamicLocales = entries
|
||||
}
|
||||
|
||||
// MARK: - Preferences (tab settings only)
|
||||
|
||||
private var preferencesSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.preferences.title")
|
||||
VStack(spacing: 0) {
|
||||
HandednessPickerRow(
|
||||
selection: Binding(
|
||||
get: { config.handednessPreference },
|
||||
set: { config.handednessPreference = $0 }
|
||||
)
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
polishIntensityPreferenceRows
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
NavigationLink {
|
||||
PersonalDictionaryView()
|
||||
} label: {
|
||||
personalDictionaryPreferenceRow
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
// MARK: - Preference row helpers
|
||||
|
||||
private var polishIntensityPreferenceRows: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("settings.polishIntensity.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.top, Spacing.sm)
|
||||
|
||||
Picker("", selection: $config.polishIntensity) {
|
||||
ForEach(PolishIntensity.allCases, id: \.self) { intensity in
|
||||
Text(SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
|
||||
.tag(intensity)
|
||||
// 与「惯用手」一致的右侧下拉菜单行样式,保持偏好设置卡片内各行风格统一。
|
||||
PickerRow(
|
||||
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
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var personalDictionaryPreferenceRow: some View {
|
||||
footerNavigationRow(title: "settings.personalDictionary.title")
|
||||
private var cursorDragNavigationToggleRow: some View {
|
||||
Toggle(isOn: $config.cursorDragNavigationEnabled) {
|
||||
Text("settings.cursorDragNavigation.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: - Footer links (tab settings only)
|
||||
@@ -412,6 +395,45 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab dock bottom padding (tab root only)
|
||||
|
||||
private struct SettingsScrollBottomPadding: ViewModifier {
|
||||
let presentation: SettingsPresentation
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if presentation == .tab {
|
||||
content.tabBarScrollBottomPadding()
|
||||
} else {
|
||||
content.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App language picker row
|
||||
|
||||
private 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 {
|
||||
PickerRow(
|
||||
title: AppL10n.string("settings.appLanguage.title"),
|
||||
options: options,
|
||||
selection: Binding(
|
||||
get: { selection.rawValue },
|
||||
set: { newValue in
|
||||
selection = AppUILanguage(rawValue: newValue) ?? .auto
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handedness picker row
|
||||
|
||||
private struct HandednessPickerRow: View {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"onboarding.enable.step3.suffix" = "and select OSGKeyboard";
|
||||
"onboarding.enable.openSettings" = "Open Settings";
|
||||
"onboarding.api.title" = "Choose Engine";
|
||||
"onboarding.api.localModels.hint" = "Download the on-device CoreML ASR model below before you finish setup (only needed when using Qwen3-ASR).";
|
||||
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed.";
|
||||
"settings.onboarding.replay" = "Restart permission setup";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -39,6 +39,7 @@
|
||||
"common.continue" = "Continue";
|
||||
"common.reset" = "Reset";
|
||||
"common.cancel" = "Cancel";
|
||||
"common.save" = "Save";
|
||||
"common.clear" = "Clear";
|
||||
"common.delete" = "Delete";
|
||||
"common.space" = "Space";
|
||||
@@ -119,7 +120,10 @@
|
||||
"settings.languageModels.title" = "Language & models";
|
||||
"settings.localModels.title" = "On-device models";
|
||||
"settings.localModels.speechRole" = "Speech";
|
||||
"settings.localModels.polishRole" = "Polish";
|
||||
"settings.localModels.builtIn" = "Built-in";
|
||||
"settings.localModels.speechEngine" = "Apple iOS Speech";
|
||||
"settings.localModels.polishEngine" = "OSGKeyboard Built-in";
|
||||
"settings.localModels.allReady" = "Ready";
|
||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld ready";
|
||||
"settings.localModels.cloudPolish.title" = "Cloud polish after ASR";
|
||||
@@ -139,6 +143,7 @@
|
||||
"settings.handedness.title" = "Handedness";
|
||||
"settings.handedness.left" = "Left hand";
|
||||
"settings.handedness.right" = "Right hand";
|
||||
"settings.cursorDragNavigation.title" = "Drag beside mic to move cursor";
|
||||
"settings.systemPrompt.reset" = "Reset";
|
||||
"settings.asrLocale" = "ASR locale";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
|
||||
@@ -308,6 +313,7 @@
|
||||
/* Tabs */
|
||||
"tab.keyboard" = "Keyboard";
|
||||
"tab.history" = "History";
|
||||
"tab.dictionary" = "Dictionary";
|
||||
"tab.settings" = "Settings";
|
||||
|
||||
/* History */
|
||||
@@ -335,12 +341,17 @@
|
||||
"settings.personalDictionary.sectionTitle" = "Personal dictionary";
|
||||
"settings.personalDictionary.title" = "Personal dictionary";
|
||||
"settings.personalDictionary.summary" = "Words the LLM must preserve verbatim when polishing.";
|
||||
"settings.personalDictionary.intro.title" = "How words get added";
|
||||
"settings.personalDictionary.intro.body" = "Words you dictate repeatedly are auto-learned. You can also edit or delete any entry here.";
|
||||
"settings.personalDictionary.intro.title" = "About your dictionary";
|
||||
"settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit.";
|
||||
"settings.personalDictionary.empty.title" = "No words yet";
|
||||
"settings.personalDictionary.empty.body" = "Words you dictate often will appear here. You can also delete any entry from this screen.";
|
||||
"settings.personalDictionary.empty.body" = "Add words you want protected during speech correction.";
|
||||
"settings.personalDictionary.search.prompt" = "Search words";
|
||||
"settings.personalDictionary.usageCount" = "%lld uses";
|
||||
"settings.personalDictionary.add.title" = "Add word";
|
||||
"settings.personalDictionary.edit.title" = "Edit word";
|
||||
"settings.personalDictionary.add.field" = "Word";
|
||||
"settings.personalDictionary.add.footer" = "Common speech-recognition mishearings are generated automatically after you save.";
|
||||
"settings.personalDictionary.aliases.generating" = "Generating aliases…";
|
||||
"settings.personalDictionary.clearAll" = "Clear dictionary";
|
||||
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
|
||||
"settings.personalDictionary.clearAll.message" = "This removes every word the LLM was protecting. This cannot be undone.";
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
|
||||
"onboarding.enable.openSettings" = "去设置";
|
||||
"onboarding.api.title" = "选择语音转文字 AI 引擎";
|
||||
"onboarding.api.localModels.hint" = "使用 Qwen3-ASR 时需先下载下方 CoreML 语音识别模型,完成后才能开始使用。";
|
||||
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。";
|
||||
"settings.onboarding.replay" = "重新开始权限引导";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -39,6 +39,7 @@
|
||||
"common.continue" = "继续";
|
||||
"common.reset" = "重置";
|
||||
"common.cancel" = "取消";
|
||||
"common.save" = "保存";
|
||||
"common.clear" = "清空";
|
||||
"common.delete" = "删除";
|
||||
"common.space" = "空格";
|
||||
@@ -119,7 +120,10 @@
|
||||
"settings.languageModels.title" = "语言与模型";
|
||||
"settings.localModels.title" = "本地模型";
|
||||
"settings.localModels.speechRole" = "语音识别";
|
||||
"settings.localModels.polishRole" = "润色";
|
||||
"settings.localModels.builtIn" = "内置";
|
||||
"settings.localModels.speechEngine" = "Apple iOS Speech";
|
||||
"settings.localModels.polishEngine" = "OSGKeyboard 内置";
|
||||
"settings.localModels.allReady" = "已就绪";
|
||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
|
||||
"settings.localModels.cloudPolish.title" = "识别后云端润色";
|
||||
@@ -139,6 +143,7 @@
|
||||
"settings.handedness.title" = "握持偏好";
|
||||
"settings.handedness.left" = "左手";
|
||||
"settings.handedness.right" = "右手";
|
||||
"settings.cursorDragNavigation.title" = "麦克风旁拖动移动光标";
|
||||
"settings.systemPrompt.reset" = "重置";
|
||||
"settings.asrLocale" = "识别语言";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
|
||||
@@ -307,6 +312,7 @@
|
||||
/* Tabs */
|
||||
"tab.keyboard" = "键盘";
|
||||
"tab.history" = "历史";
|
||||
"tab.dictionary" = "词库";
|
||||
"tab.settings" = "设置";
|
||||
|
||||
/* History */
|
||||
@@ -332,14 +338,19 @@
|
||||
|
||||
/* v0.3.0: 个性化词库 */
|
||||
"settings.personalDictionary.sectionTitle" = "个性化词库";
|
||||
"settings.personalDictionary.title" = "个性化词库";
|
||||
"settings.personalDictionary.title" = "个性词库";
|
||||
"settings.personalDictionary.summary" = "AI 润色时必须原样保留的词汇。";
|
||||
"settings.personalDictionary.intro.title" = "词库如何积累";
|
||||
"settings.personalDictionary.intro.body" = "你反复说出的词会自动学习。你也可以在这里编辑或删除任何词条。";
|
||||
"settings.personalDictionary.intro.title" = "关于词库";
|
||||
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
|
||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||
"settings.personalDictionary.empty.body" = "你反复说出的词会出现在这里。你也可以在此页删除任何词条。";
|
||||
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。";
|
||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
||||
"settings.personalDictionary.add.title" = "添加词条";
|
||||
"settings.personalDictionary.edit.title" = "编辑词条";
|
||||
"settings.personalDictionary.add.field" = "词条";
|
||||
"settings.personalDictionary.add.footer" = "保存后会自动用 DeepSeek 生成常见误识别写法,用于 ASR 纠错。";
|
||||
"settings.personalDictionary.aliases.generating" = "正在生成别名…";
|
||||
"settings.personalDictionary.clearAll" = "清空词库";
|
||||
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
|
||||
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
|
||||
|
||||
Reference in New Issue
Block a user