checkpoint before checking out main
This commit is contained in:
@@ -63,7 +63,7 @@ jobs:
|
||||
run: ./Scripts/generate-xcodeproj.sh
|
||||
- name: Run PR test preset
|
||||
run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
|
||||
- name: Run account and purchase UI regression
|
||||
- name: Run simulator-stable UI regressions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
xcodebuild \
|
||||
@@ -71,6 +71,8 @@ jobs:
|
||||
-scheme OSGKeyboardUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:OSGKeyboardUITests/AccountCenterUITests \
|
||||
-only-testing:OSGKeyboardUITests/AssistantKeyboardUITests \
|
||||
-only-testing:OSGKeyboardUITests/EditPagerUITests \
|
||||
-onlyUsePackageVersionsFromResolvedFile \
|
||||
test
|
||||
- name: Build Release
|
||||
|
||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### Version format
|
||||
|
||||
The current source-of-truth version is **2.0.1 (build 89)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **2.0.3 (build 94)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Monthly usage calendar**: replace the seven-day Home and Mac Dashboard chart with a full current-month calendar whose date-circle opacity reflects each day's dictation characters, placing the Home calendar between its four metrics and Personal Dictionary. / **月度用量日历**:以完整的当月日历替换首页与 Mac 总览的近七天图表,并通过日期圆形背景透明度表示每天的听写字数;首页日历位于四项统计指标与个性词库之间。
|
||||
- **Home credits and navigation**: move the credit and invitation card from Settings to Home above the usage metrics, explain free signup and referral rewards before sign-in, and open History or Personal Dictionary directly from their metric cards. / **首页积分与导航**:将积分与邀请卡片从设置迁移到首页统计指标上方,登录前明确说明免费注册与邀请奖励,并支持从听写字数和词库指标卡片直接进入历史记录或个性词库。
|
||||
- **Personal style threshold**: allow generating a learned speaking style after 2,500 effective dictation characters instead of 5,000. / **专属风格门槛**:生成学习型说话风格所需的有效听写字符由 5,000 降至 2,500。
|
||||
|
||||
### Fixed
|
||||
- **Recommended hotword details**: label Home recommendations by their actual source, render suggestion chips in adaptive monochrome, remove internal usage counts from dictionary rows, and anchor the iPhone confirmation popover to the selected hotword. / **推荐热词详情**:按真实来源标记首页推荐词,以自适应黑白配色显示推荐标签,移除词库列表中的内部使用次数,并让 iPhone 确认弹窗指向所选热词。
|
||||
|
||||
## [2.0.3] - 2026-08-24
|
||||
|
||||
### Changed
|
||||
- **English personal-term suggestions**: learn repeated rare English words alongside Chinese typing habits, preserve existing Rime history, and ask before adding either language to the Personal Dictionary. / **英文个性词推荐**:在中文输入习惯之外学习重复使用的英文低频词,保留现有 Rime 历史,并在将任一语言加入个性词库前由用户确认。
|
||||
- **Interface consistency**: standardize borderless card spacing, compact settings typography, native menu alignment, account and purchase-history rows, and tab-bar transitions between primary and detail pages. / **界面一致性**:统一无描边卡片间距、紧凑设置字体、原生菜单对齐、账号与购买记录行,以及一级与详情页面之间的标签栏过渡。
|
||||
|
||||
### Fixed
|
||||
- **Apple account continuity**: retry transient secure-session restoration after reinstall or foreground activation, and show a consistent loading state while Apple sign-in finishes. / **Apple 账号连续性**:在重装或重新进入前台后重试瞬时失败的安全会话恢复,并在 Apple 登录完成期间统一显示加载状态。
|
||||
- **Cross-app typing candidates**: isolate document-scoped candidate state from the reusable keyboard engine so English and Chinese candidates recover reliably after switching apps without leaking the previous field. / **跨 App 输入候选词**:将文档级候选状态与可复用键盘引擎分离,使中英文候选词在切换 App 后可靠恢复,且不会泄漏上一个输入框的状态。
|
||||
|
||||
## [2.0.2] - 2026-08-24
|
||||
|
||||
### Added
|
||||
- **Phone-number clipboard actions**: recognize a single copied phone number locally, then offer system calling or a new-contact form with the number prefilled. / **电话号码剪贴板操作**:在本地识别复制内容中的唯一电话号码,并提供系统拨号或预填号码的新建联系人表单。
|
||||
- **Link-aware clipboard skills**: recognize a single copied web link locally, offer direct opening in the default browser, and securely extract bounded public webpage text in the host app before generating a summary. / **链接感知剪贴板技能**:在本地识别复制内容中的唯一网页链接,提供默认浏览器直接打开入口,并由主 App 安全、限量提取公开网页正文后生成总结。
|
||||
@@ -15,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Learned speaking styles**: unlock user-initiated style generation after 5,000 effective dictation characters, prioritize recurring native speech and explicit user edits, use historical polish prompts only to subtract AI-added style, and require review before saving. / **学习说话风格**:累积 5,000 个有效听写字符后,可优先根据反复出现的原生口述与用户明确编辑生成个人风格,历史润色 Prompt 仅用于排除 AI 附加风格,并在保存前强制检查。
|
||||
|
||||
### Changed
|
||||
- **System semantic skills**: hide Reply, Decline Invitation, Empathy Reply, Accept Invitation, Call, Create Contact, and Clarify from the host Skills catalog while keeping them available for automatic keyboard recommendations. / **系统语义技能**:在主 App 技能目录中隐藏“回复、委婉拒绝、共情回复、接受邀约、拨打电话、新建联系人、澄清与追问”,同时保留键盘按语义自动推荐这些操作的能力。
|
||||
- **Required default clipboard skills**: install Reply, Translate, Summarize, Decline Invitation, Clarify, Empathy Reply, and Organize List once for existing layouts, with system-action management visibility handled separately. / **必备默认剪贴板技能**:为现有布局一次性补装“回复、翻译、总结要点、委婉拒绝、澄清与追问、共情回复、整理清单”,系统动作是否显示在技能管理中由独立策略控制。
|
||||
- **Focused clipboard actions**: translate foreign clipboard text into the device's primary system language, consolidate overlapping reply, summary, and clarification skills with legacy-layout migration, distinguish webpage summaries, and avoid playful suggestions for negative messages. / **聚焦剪贴板操作**:将外语剪贴板文本翻译为设备首选系统语言,兼容迁移并整合重复的回复、总结与澄清技能,区分网页总结,并避免为负面消息推荐俏皮回复。
|
||||
- **Personal dictionary empty state**: remove the Rime explanation from the Home card and shorten its empty message to “No suggestions yet.” / **个性词库空状态**:移除首页卡片中的 Rime 说明,并将空状态精简为“暂无推荐词”。
|
||||
- **Skill catalog layout**: replace the two-column cards with scroll-safe installed and uninstalled lists; selecting a row now opens a full detail page where installation, Shortcut setup, and custom-skill editing are managed. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑。
|
||||
@@ -22,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。
|
||||
|
||||
### Fixed
|
||||
- **Personal dictionary aliases**: generate common speech-recognition mistakes for Home term suggestions through the same concurrency-safe save path used by manual entries. / **个性词库易错词**:首页推荐词现在与手动词条共用并发安全的保存流程,并自动生成常见语音误识别写法。
|
||||
- **Home service setup guidance**: explain whether user-owned API keys are missing, link directly to the relevant provider settings, and offer OSG credits as the no-key alternative. / **首页服务配置引导**:明确说明缺少哪类自备 API Key,直接跳转到对应服务商设置,并提供无需填写 Key 的 OSG 积分方案。
|
||||
- **Live keyboard configuration guidance**: apply speech-language changes to an already open keyboard and explain when clipboard capture is paused because Full Access is off. / **键盘实时配置引导**:识别语言变更会同步到已打开的键盘,并在“允许完全访问”关闭导致剪贴板采集暂停时给出说明。
|
||||
- **Clipboard skill fallback**: keep Reply available for every accepted clipboard text, including while local semantic analysis is still running or finds no specialized action. / **剪贴板技能兜底**:任何已接收的剪贴板文本都会保留“回复”,包括本地语义分析尚未完成或没有识别出专用操作时。
|
||||
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
||||
- **Managed hotword answers**: run keyboard hotwords with the same DeepSeek thinking policy as hold-to-ask AI, require online search for time-sensitive cards, preserve hotword usage attribution, and distinguish credit, provider, timeout, and gateway failures. / **积分热词回答**:键盘热词现在采用与长按问 AI 相同的 DeepSeek 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"image-name" : "OSGKeyboard Icon2.png",
|
||||
"name" : "OSGKeyboard Icon2",
|
||||
"position" : {
|
||||
"scale" : 0.505,
|
||||
"scale" : 0.508,
|
||||
"translation-in-points" : [
|
||||
8,
|
||||
6
|
||||
|
||||
@@ -9,6 +9,10 @@ import Foundation
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
private enum SkillRoute: Hashable {
|
||||
case detail(String)
|
||||
}
|
||||
|
||||
struct AIAgentSkillsView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@@ -20,20 +24,21 @@ struct AIAgentSkillsView: View {
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@State private var showPasteAccessSuccess = false
|
||||
@State private var path = NavigationPath()
|
||||
private var skillCardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $path) {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
CardPageContent {
|
||||
if showsClipboardAccessGuide {
|
||||
clipboardAccessGuide
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
installedSection
|
||||
if !store.availableSkills.isEmpty {
|
||||
if !store.skillManagementAvailableSkills.isEmpty {
|
||||
uninstalledSection
|
||||
}
|
||||
}
|
||||
@@ -42,6 +47,19 @@ struct AIAgentSkillsView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("skills.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(for: SkillRoute.self) { route in
|
||||
switch route {
|
||||
case .detail(let skillID):
|
||||
if let skill = store.mergedCatalog.first(where: { $0.id == skillID }) {
|
||||
skillDetailView(skill)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"skills.installed.empty",
|
||||
systemImage: "wand.and.sparkles"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
@@ -53,6 +71,7 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
.sheet(item: $editingDraft) { draft in
|
||||
SkillEditorSheet(
|
||||
draft: draft,
|
||||
@@ -94,11 +113,7 @@ struct AIAgentSkillsView: View {
|
||||
private var clipboardAccessGuide: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
Image(systemName: clipboardGuideIcon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(clipboardGuideTint)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(clipboardGuideTint.opacity(0.12), in: Circle())
|
||||
skillIcon(systemImage: clipboardGuideIcon)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(clipboardGuideTitle)
|
||||
@@ -126,7 +141,6 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("skills.clipboard.guide")
|
||||
}
|
||||
|
||||
@@ -138,7 +152,8 @@ struct AIAgentSkillsView: View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 22)
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.body)
|
||||
@@ -194,10 +209,6 @@ struct AIAgentSkillsView: View {
|
||||
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
|
||||
}
|
||||
|
||||
private var clipboardGuideTint: Color {
|
||||
pasteAccessNeedsRecovery ? palette.warning : palette.accent
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTitle: LocalizedStringKey {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
@@ -288,53 +299,36 @@ struct AIAgentSkillsView: View {
|
||||
title: AppL10n.format(
|
||||
"skills.installed.section",
|
||||
language: config.uiLanguage,
|
||||
store.enabledSkills.count
|
||||
store.skillManagementEnabledSkills.count
|
||||
)
|
||||
) {
|
||||
if store.enabledSkills.isEmpty {
|
||||
if store.skillManagementEnabledSkills.isEmpty {
|
||||
Text("skills.installed.empty")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.enabledSkills) { skill in
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(store.skillManagementEnabledSkills) { skill in
|
||||
skillListItem(skill)
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var uninstalledSection: some View {
|
||||
CardSection("skills.uninstalled.section") {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.availableSkills) { skill in
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(store.skillManagementAvailableSkills) { skill in
|
||||
skillListItem(skill)
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
|
||||
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
|
||||
NavigationLink {
|
||||
SkillDetailView(
|
||||
store: store,
|
||||
skill: skill,
|
||||
onInstall: addOrWarn,
|
||||
onConfirmInstall: confirmShortcutInstall,
|
||||
onEdit: {
|
||||
guard let user = store.userSkill(id: skill.id) else { return }
|
||||
editingDraft = .from(user)
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
NavigationLink(value: SkillRoute.detail(skill.id)) {
|
||||
skillListRow(skill)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -349,14 +343,7 @@ struct AIAgentSkillsView: View {
|
||||
|
||||
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
))
|
||||
skillIcon(systemImage: skill.systemImage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skillTitle(skill))
|
||||
@@ -378,16 +365,36 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.surface)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 38 + Spacing.md * 2)
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.clipShape(skillCardShape)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func skillIcon(systemImage: String) -> some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
private func skillDetailView(_ skill: AIClipboardSkill) -> some View {
|
||||
SkillDetailView(
|
||||
store: store,
|
||||
skill: skill,
|
||||
onInstall: addOrWarn,
|
||||
onConfirmInstall: confirmShortcutInstall,
|
||||
onEdit: {
|
||||
guard let user = store.userSkill(id: skill.id) else { return }
|
||||
editingDraft = .from(user)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func skillTitle(_ skill: AIClipboardSkill) -> String {
|
||||
if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty {
|
||||
@@ -505,7 +512,6 @@ private struct SkillDetailView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent(
|
||||
spacing: Spacing.md,
|
||||
topPadding: Spacing.md,
|
||||
bottomPadding: Spacing.xl
|
||||
) {
|
||||
@@ -555,9 +561,12 @@ private struct SkillDetailView: View {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(palette.accentMuted, in: Circle())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(headerTitle)
|
||||
.font(TypeStyle.title3)
|
||||
@@ -660,6 +669,7 @@ private struct SkillDetailView: View {
|
||||
prominent: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
Button(action: action) {
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -667,11 +677,7 @@ private struct SkillDetailView: View {
|
||||
.foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
|
||||
.background(
|
||||
prominent ? palette.accent : palette.surfaceElevated,
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5)
|
||||
in: shape
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -25,25 +25,56 @@ struct AccountAppleAuthorizationButton: View {
|
||||
@State private var rawNonce: String?
|
||||
|
||||
var body: some View {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
)
|
||||
Group {
|
||||
if purpose == .signIn, coordinator.operation == .signingIn {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(authorizationButtonForeground)
|
||||
Text("account.signIn.loading")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
}
|
||||
.foregroundStyle(authorizationButtonForeground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(authorizationButtonBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("account.signIn.loading")
|
||||
} else {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
)
|
||||
.accessibilityIdentifier(
|
||||
purpose == .signIn
|
||||
? "account.signIn.apple"
|
||||
: "account.delete.reauthenticate"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var authorizationButtonBackground: Color {
|
||||
colorScheme == .dark ? .white : .black
|
||||
}
|
||||
|
||||
private var authorizationButtonForeground: Color {
|
||||
colorScheme == .dark ? .black : .white
|
||||
}
|
||||
|
||||
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
|
||||
|
||||
@@ -12,13 +12,20 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct AccountCenterUITestHarness: View {
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
private let startsSignIn: Bool
|
||||
|
||||
init() {
|
||||
let startsSignIn = ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-signin-loading"
|
||||
)
|
||||
let service = AccountCenterUITestService(
|
||||
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-snapshot-failure"
|
||||
)
|
||||
),
|
||||
startsSignedOut: startsSignIn,
|
||||
signInDelay: startsSignIn ? .seconds(5) : .zero
|
||||
)
|
||||
self.startsSignIn = startsSignIn
|
||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||
_coordinator = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
@@ -41,6 +48,15 @@ struct AccountCenterUITestHarness: View {
|
||||
}
|
||||
.task {
|
||||
await coordinator.restoreIfNeeded()
|
||||
if startsSignIn {
|
||||
await coordinator.signIn(
|
||||
with: AppleAuthorizationPayload(
|
||||
identityToken: "ui-test-identity",
|
||||
authorizationCode: "ui-test-authorization",
|
||||
nonce: "ui-test-nonce"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,17 +112,28 @@ private actor AccountCenterUITestService:
|
||||
displayName: "UI Test Account"
|
||||
)
|
||||
private let shouldFailSnapshot: Bool
|
||||
private let startsSignedOut: Bool
|
||||
private let signInDelay: Duration
|
||||
|
||||
init(shouldFailSnapshot: Bool = false) {
|
||||
init(
|
||||
shouldFailSnapshot: Bool = false,
|
||||
startsSignedOut: Bool = false,
|
||||
signInDelay: Duration = .zero
|
||||
) {
|
||||
self.shouldFailSnapshot = shouldFailSnapshot
|
||||
self.startsSignedOut = startsSignedOut
|
||||
self.signInDelay = signInDelay
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
account
|
||||
startsSignedOut ? nil : account
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
_ = payload
|
||||
if signInDelay > .zero {
|
||||
try await Task.sleep(for: signInDelay)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.navigationTitle("account.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task(id: coordinator.accountID) {
|
||||
guard let accountID = coordinator.accountID else {
|
||||
coordinator.creditPurchases.reset()
|
||||
@@ -116,11 +115,6 @@ struct AccountCenterView: View {
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.operation == .signingIn {
|
||||
ProgressView("account.signIn.loading")
|
||||
.tint(palette.accent)
|
||||
}
|
||||
|
||||
if coordinator.pendingReferralCode != nil {
|
||||
Label("account.referral.pendingAfterSignIn", systemImage: "link")
|
||||
.font(.footnote)
|
||||
@@ -273,7 +267,6 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("account.summary")
|
||||
}
|
||||
|
||||
@@ -954,9 +947,12 @@ private struct AccountActionRow: View {
|
||||
let showsProgress: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(titleKey, systemImage: systemImage)
|
||||
.font(.body)
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.frame(width: 20)
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if showsProgress {
|
||||
ProgressView()
|
||||
|
||||
@@ -8,6 +8,10 @@ import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AccountPurchaseHistoryView: View {
|
||||
private enum Layout {
|
||||
static let purchaseRowMinHeight: CGFloat = 64
|
||||
}
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||
@@ -56,7 +60,6 @@ struct AccountPurchaseHistoryView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.purchaseHistory.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task {
|
||||
await manager.loadPurchaseHistory(accountID: accountID)
|
||||
}
|
||||
@@ -69,6 +72,14 @@ struct AccountPurchaseHistoryView: View {
|
||||
ForEach(records) { record in
|
||||
purchaseRow(record)
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowInsets(
|
||||
EdgeInsets(
|
||||
top: 0,
|
||||
leading: Spacing.md,
|
||||
bottom: 0,
|
||||
trailing: Spacing.md
|
||||
)
|
||||
)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
.task {
|
||||
guard record.id == records.last?.id else { return }
|
||||
@@ -111,8 +122,8 @@ struct AccountPurchaseHistoryView: View {
|
||||
private func purchaseRow(
|
||||
_ record: AccountCreditPurchaseRecord
|
||||
) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||
Text("+")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -121,27 +132,32 @@ struct AccountPurchaseHistoryView: View {
|
||||
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Text(purchaseDateText(record.purchasedAt))
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(balanceAfterText(record.balanceAfter))
|
||||
.font(TypeStyle.caption.monospacedDigit())
|
||||
.font(TypeStyle.body.monospacedDigit())
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text("account.purchaseHistory.status.credited")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.success)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Layout.purchaseRowMinHeight,
|
||||
alignment: .center
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("account.purchaseHistory.row.\(record.transactionID)")
|
||||
}
|
||||
|
||||
private func purchaseDateText(_ date: Date) -> String {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// AccountRewardsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Compact Home account surface. Signed-in users see the same credit progress
|
||||
// and invitation controls formerly shown in Settings; signed-out users see
|
||||
// the concrete free-credit paths before authenticating.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AccountRewardsCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch coordinator.sessionPhase {
|
||||
case .restoring:
|
||||
loadingContent("account.settings.restoring")
|
||||
case .signedOut:
|
||||
signedOutContent
|
||||
case .signedIn:
|
||||
signedInContent
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.task(id: coordinator.accountID) {
|
||||
guard coordinator.isSignedIn else { return }
|
||||
await coordinator.refreshAccountData()
|
||||
}
|
||||
.alert("account.error.title", isPresented: operationErrorBinding) {
|
||||
Button("common.done") {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signedOutContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
Image(systemName: "gift")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("home.accountRewards.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("home.accountRewards.signedOut.summary")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private var signedInContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch coordinator.snapshotPhase {
|
||||
case let .loaded(snapshot):
|
||||
creditSummary(snapshot)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("account.error.load")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .idle, .loading:
|
||||
loadingContent("account.loading.center")
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: coordinator.referralProfile)
|
||||
}
|
||||
}
|
||||
|
||||
private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View {
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
return Button(action: onOpenAccount) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private func loadingContent(_ key: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text(key)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
|
||||
let remaining = max(snapshot.credits.balance, 0)
|
||||
let used = max(snapshot.credits.usedCredits, 0)
|
||||
let (total, overflow) = remaining.addingReportingOverflow(used)
|
||||
return overflow ? Int64.max : total
|
||||
}
|
||||
|
||||
private func creditRemainingText(_ remaining: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.remainingCompact",
|
||||
language: config.uiLanguage,
|
||||
max(remaining, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditUsedText(_ used: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.usedCompact",
|
||||
language: config.uiLanguage,
|
||||
max(used, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditTotalText(_ total: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.totalValueCompact",
|
||||
language: config.uiLanguage,
|
||||
total.formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountReferralLinkView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var viewModel: ReferralProfileViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.state {
|
||||
case .idle:
|
||||
retryRow(
|
||||
messageKey: "account.referral.loadLink",
|
||||
actionKey: "account.referral.loadLink"
|
||||
)
|
||||
case .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.referral.loadingLink")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
retryRow(messageKey: messageKey, actionKey: "account.retry")
|
||||
case .loaded(let profile):
|
||||
loadedContent(profile)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("account.referral.profile")
|
||||
}
|
||||
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text("account.referral.equalRewardDescription")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(invitationURL: profile.code.inviteURL)
|
||||
}
|
||||
|
||||
if viewModel.isRefreshing {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("account.referral.refreshingLink")
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if let refreshErrorKey = viewModel.refreshErrorKey {
|
||||
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: LocalizedStringKey
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button(actionKey) {
|
||||
Task { await viewModel.refresh() }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,13 +139,21 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.restore"
|
||||
)
|
||||
if error as? AccountIntegrationError == .unavailable {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
} else {
|
||||
// Keychain protection and transport availability can be
|
||||
// transient during launch. Keep the restoring state and allow
|
||||
// the next foreground activation to retry instead of turning
|
||||
// a retained, valid session into a signed-out session.
|
||||
didAttemptRestore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,26 +213,47 @@ private actor LiveAccountService:
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
guard let cachedSession = try await apiClient.currentSession() else {
|
||||
OSGDiag.log("session restore status=not-found", category: "account")
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
}
|
||||
OSGDiag.log("session restore status=keychain-found", category: "account")
|
||||
let account: OSGAccount?
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
OSGDiag.log(
|
||||
"session restore status=rejected "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
default:
|
||||
OSGDiag.log(
|
||||
"session restore status=cached-fallback "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
account = nil
|
||||
}
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"session restore status=cached-fallback "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
account = nil
|
||||
}
|
||||
let session = try await apiClient.currentSession() ?? cachedSession
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
await synchronizeManagedGrant()
|
||||
OSGDiag.log(
|
||||
"session restore status=restored accountValidated=\(account == nil ? 0 : 1)",
|
||||
category: "account"
|
||||
)
|
||||
return uiSession(
|
||||
session,
|
||||
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
||||
|
||||
@@ -174,7 +174,20 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.skillTipText = "Skill failed"
|
||||
case .skills:
|
||||
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||
// Keep the pagination assertion independent from production catalog
|
||||
// ordering: Navigate is guaranteed to appear after one swipe on both
|
||||
// four- and five-item pages.
|
||||
let navigate = AIClipboardSkillCatalog.catalog.first {
|
||||
$0.id == AIClipboardSkillCatalog.navigateID
|
||||
}
|
||||
let leading = AIClipboardSkillCatalog.catalog
|
||||
.filter { $0.id != AIClipboardSkillCatalog.navigateID }
|
||||
.prefix(5)
|
||||
var previewSkills = Array(leading)
|
||||
if let navigate {
|
||||
previewSkills.append(navigate)
|
||||
}
|
||||
AIKeyboardView.debugPreviewSkills = previewSkills
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
case .search:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Observes usage + dictionary counts and feeds the shared
|
||||
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header`
|
||||
// (e.g. glass preview field) sits on the 7-day chart card.
|
||||
// (e.g. glass preview field) sits on the monthly calendar card.
|
||||
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
@@ -12,6 +12,9 @@ import SwiftUI
|
||||
struct HomeUsageStatsSection<Header: View>: View {
|
||||
let layout: UsageStatsClusterLayout
|
||||
var compact: Bool = false
|
||||
var content: UsageStatsClusterContent = .all
|
||||
var onOpenHistory: (() -> Void)?
|
||||
var onOpenDictionary: (() -> Void)?
|
||||
@ViewBuilder var header: () -> Header
|
||||
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@@ -23,12 +26,15 @@ struct HomeUsageStatsSection<Header: View>: View {
|
||||
UsageStatsCluster(
|
||||
layout: layout,
|
||||
language: config.uiLanguage,
|
||||
points: stats.last7Days,
|
||||
points: stats.currentMonth,
|
||||
dictationCharacterCount: stats.dictationCharacterCount,
|
||||
dictationDurationSeconds: stats.dictationDurationSeconds,
|
||||
translationCharacterCount: stats.translationCharacterCount,
|
||||
dictionaryTermCount: dictionaryCount,
|
||||
compact: compact,
|
||||
content: content,
|
||||
onOpenHistory: onOpenHistory,
|
||||
onOpenDictionary: onOpenDictionary,
|
||||
header: header
|
||||
)
|
||||
.onAppear(perform: refreshDictionaryCount)
|
||||
@@ -49,8 +55,21 @@ struct HomeUsageStatsSection<Header: View>: View {
|
||||
}
|
||||
|
||||
extension HomeUsageStatsSection where Header == EmptyView {
|
||||
init(layout: UsageStatsClusterLayout, compact: Bool = false) {
|
||||
self.init(layout: layout, compact: compact, header: { EmptyView() })
|
||||
init(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool = false,
|
||||
content: UsageStatsClusterContent = .all,
|
||||
onOpenHistory: (() -> Void)? = nil,
|
||||
onOpenDictionary: (() -> Void)? = nil
|
||||
) {
|
||||
self.init(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: content,
|
||||
onOpenHistory: onOpenHistory,
|
||||
onOpenDictionary: onOpenDictionary,
|
||||
header: { EmptyView() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OSGAnimatedMeshBackground.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// A slowly-flowing MeshGradient background (iOS 18+, deployment target iOS 26).
|
||||
// System-native Metal acceleration, no third-party dependencies, 0 KB of assets.
|
||||
//
|
||||
// Design notes:
|
||||
// - 3×3 grid keeps render cost low; corner points stay fixed (no clipping at edges).
|
||||
// - Only the 4 inner control points + 4 mid-edge points drift; this avoids the
|
||||
// gradient "snapping" at the edges of the parent view.
|
||||
// - `TimelineView(.animation(minimumInterval: 1.0/30.0))` caps redraw at 30 fps
|
||||
// (backgrounds do not need 120 Hz) — roughly halves GPU vs. uncapped TimelineView.
|
||||
// - `accessibilityReduceMotion` → static frame at phase = 0 (Apple HIG).
|
||||
// - Palette is the same near-black base used across the rest of the app
|
||||
// (`Color(red: 0.06, green: 0.06, blue: 0.07)`) with cool-warm accents that
|
||||
// sit in the "premium, calm, late-night" register — fits the Typeless vibe.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct OSGAnimatedMeshBackground: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Animation period in seconds for a full color cycle. Longer = calmer.
|
||||
var period: Double = 14.0
|
||||
|
||||
/// Optional palette override. Defaults to a deep indigo / amber / teal set.
|
||||
var palette: AnimatedMeshPalette = .aurora
|
||||
|
||||
/// Pause the animation at the current frame. Useful for App Store
|
||||
/// screenshot capture or for "preview" hosts that want a still image.
|
||||
var paused: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if reduceMotion || paused {
|
||||
MeshGradient(
|
||||
width: 3,
|
||||
height: 3,
|
||||
points: Self.staticPoints,
|
||||
colors: palette.colors,
|
||||
smoothsColors: true,
|
||||
colorSpace: .perceptual
|
||||
)
|
||||
} else {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { context in
|
||||
let phase = context.date.timeIntervalSinceReferenceDate / period
|
||||
MeshGradient(
|
||||
width: 3,
|
||||
height: 3,
|
||||
points: Self.animatedPoints(phase: phase),
|
||||
colors: Self.animatedColors(base: palette.colors, phase: phase),
|
||||
smoothsColors: true,
|
||||
colorSpace: .perceptual
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
private extension OSGAnimatedMeshBackground {
|
||||
/// 9 SIMD2 control points for a 3×3 mesh, top-left → bottom-right, row-major.
|
||||
/// Corner points (0, 3, 6, 8) stay fixed; the rest drift on a slow sinusoid.
|
||||
static let staticPoints: [SIMD2<Float>] = [
|
||||
SIMD2(0.0, 0.0), SIMD2(0.5, 0.0), SIMD2(1.0, 0.0),
|
||||
SIMD2(0.0, 0.5), SIMD2(0.5, 0.5), SIMD2(1.0, 0.5),
|
||||
SIMD2(0.0, 1.0), SIMD2(0.5, 1.0), SIMD2(1.0, 1.0)
|
||||
]
|
||||
|
||||
static func animatedPoints(phase: Double) -> [SIMD2<Float>] {
|
||||
// Use distinct incommensurable frequencies so the loop never visibly
|
||||
// repeats inside a normal viewing session.
|
||||
let p = Float(phase)
|
||||
let cos1 = cos(p * 0.27) * 0.18
|
||||
let cos2 = cos(p * 0.41 + 1.7) * 0.16
|
||||
let sin1 = sin(p * 0.33 + 0.4) * 0.18
|
||||
let sin2 = sin(p * 0.51 + 2.1) * 0.14
|
||||
return [
|
||||
SIMD2(0.0, 0.0), // top-left (fixed)
|
||||
SIMD2(0.5 + cos1, 0.0), // top-mid
|
||||
SIMD2(1.0, 0.0), // top-right (fixed)
|
||||
SIMD2(0.0, 0.5 + sin1), // left-mid
|
||||
SIMD2(0.5 + cos2, 0.5 + sin2), // center
|
||||
SIMD2(1.0, 0.5 + cos1 * 0.7), // right-mid
|
||||
SIMD2(0.0, 1.0), // bottom-left (fixed)
|
||||
SIMD2(0.5 + sin2, 1.0), // bottom-mid
|
||||
SIMD2(1.0, 1.0) // bottom-right (fixed)
|
||||
]
|
||||
}
|
||||
|
||||
/// Slowly cycle each color's hue by a tiny amount so the gradient looks
|
||||
/// alive without ever crossing into "screensaver" territory.
|
||||
static func animatedColors(base: [Color], phase: Double) -> [Color] {
|
||||
base.enumerated().map { index, color in
|
||||
let shift = cos(phase + Double(index) * 0.31) * 0.025
|
||||
return color.shiftedHue(by: shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palettes
|
||||
|
||||
/// Named color sets. Each is 9 colors arranged to read as a smooth
|
||||
/// "calm cloud" — low saturation, low brightness, no obvious focal point.
|
||||
struct AnimatedMeshPalette: Sendable {
|
||||
var colors: [Color]
|
||||
|
||||
/// Deep indigo / amber / teal. Default — works for the dark home screen.
|
||||
static let aurora = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.06, green: 0.07, blue: 0.13), // top-left
|
||||
Color(red: 0.10, green: 0.08, blue: 0.20), // top-mid
|
||||
Color(red: 0.05, green: 0.10, blue: 0.18), // top-right
|
||||
Color(red: 0.12, green: 0.09, blue: 0.16), // left-mid
|
||||
Color(red: 0.18, green: 0.11, blue: 0.22), // center ← deepest violet
|
||||
Color(red: 0.08, green: 0.12, blue: 0.20), // right-mid
|
||||
Color(red: 0.16, green: 0.10, blue: 0.10), // bottom-left (amber hint)
|
||||
Color(red: 0.10, green: 0.08, blue: 0.18), // bottom-mid
|
||||
Color(red: 0.05, green: 0.09, blue: 0.14) // bottom-right
|
||||
])
|
||||
|
||||
/// Cooler / more "Apple" feel — slate blue, steel, ice.
|
||||
static let polar = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.05, green: 0.07, blue: 0.10),
|
||||
Color(red: 0.09, green: 0.12, blue: 0.18),
|
||||
Color(red: 0.07, green: 0.09, blue: 0.14),
|
||||
Color(red: 0.10, green: 0.13, blue: 0.18),
|
||||
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||
Color(red: 0.08, green: 0.10, blue: 0.16),
|
||||
Color(red: 0.06, green: 0.10, blue: 0.16),
|
||||
Color(red: 0.09, green: 0.11, blue: 0.18),
|
||||
Color(red: 0.05, green: 0.07, blue: 0.12)
|
||||
])
|
||||
|
||||
/// Warm "candlelight" — fits evening writing session vibes.
|
||||
static let ember = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.08, green: 0.06, blue: 0.05),
|
||||
Color(red: 0.14, green: 0.09, blue: 0.06),
|
||||
Color(red: 0.10, green: 0.07, blue: 0.06),
|
||||
Color(red: 0.16, green: 0.10, blue: 0.07),
|
||||
Color(red: 0.22, green: 0.13, blue: 0.08),
|
||||
Color(red: 0.12, green: 0.08, blue: 0.07),
|
||||
Color(red: 0.10, green: 0.06, blue: 0.05),
|
||||
Color(red: 0.15, green: 0.09, blue: 0.06),
|
||||
Color(red: 0.08, green: 0.06, blue: 0.05)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Color {
|
||||
/// Shift the color's hue by a small amount (-1.0 ... 1.0 wraps the wheel).
|
||||
/// Used purely for subtle gradient breathing — keep `amount` ≤ 0.05.
|
||||
func shiftedHue(by amount: Double) -> Color {
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
|
||||
hue += CGFloat(amount)
|
||||
hue = hue.truncatingRemainder(dividingBy: 1.0)
|
||||
if hue < 0 { hue += 1 }
|
||||
return Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(alpha))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Aurora (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .aurora)
|
||||
Text("aurora")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Polar (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .polar)
|
||||
Text("polar")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Ember (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .ember)
|
||||
Text("ember")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -26,9 +26,5 @@ struct PrivacyInfoCard: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
// TabBarVisibility.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。
|
||||
// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。
|
||||
// NavigationStack 根路径统一驱动底部系统 tab 栏显隐。
|
||||
// 显隐状态在 push / pop 开始时更新,使原生 Dock 与页面过渡同步。
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
private struct NavigationStackTabBarVisibilityModifier: ViewModifier {
|
||||
let isRoot: Bool
|
||||
|
||||
@State private var visibility: Visibility = .visible
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbarVisibility(visibility, for: .tabBar)
|
||||
.onAppear {
|
||||
visibility = isRoot ? .visible : .hidden
|
||||
}
|
||||
.onChange(of: isRoot) { _, newValue in
|
||||
withAnimation(Motion.soft) {
|
||||
visibility = newValue ? .visible : .hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks this view as a pushed detail screen so the native tab bar hides.
|
||||
func hidesTabBarWhenPushed() -> some View {
|
||||
toolbar(.hidden, for: .tabBar)
|
||||
/// Keeps the native tab dock visible only at a NavigationStack root.
|
||||
func navigationStackTabBarVisibility(isRoot: Bool) -> some View {
|
||||
modifier(NavigationStackTabBarVisibilityModifier(isRoot: isRoot))
|
||||
}
|
||||
|
||||
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
|
||||
|
||||
@@ -37,8 +37,5 @@ struct WideCard<Content: View>: View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,5 @@ struct HelpFeedbackView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.link.support")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ struct HistoryView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("history.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.toolbar {
|
||||
if !store.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -105,7 +104,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.contentMargins(.top, Spacing.md, for: .scrollContent)
|
||||
|
||||
+246
-201
@@ -1,10 +1,11 @@
|
||||
// HomeView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Home: logo, transient Flow connection status, usage stats, history +
|
||||
// dictionary entry card. History/dictionary
|
||||
// open via push (system back) rather than bottom-tab destinations.
|
||||
// Home: logo, transient Flow connection status, usage stats, account rewards,
|
||||
// and dictionary suggestions. History/dictionary/account destinations open
|
||||
// via push (system back) rather than bottom-tab destinations.
|
||||
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -12,6 +13,7 @@ import UIKit
|
||||
private enum HomeRoute: Hashable {
|
||||
case history
|
||||
case dictionary
|
||||
case account
|
||||
}
|
||||
|
||||
enum FlowHomePiPStatusDescriptor: Equatable {
|
||||
@@ -97,22 +99,44 @@ enum FlowHomePiPStatusPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeServiceSetupPolicy {
|
||||
static func apiKeyMessageKey(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
) -> String {
|
||||
if isLocalEngine || isASRConfigured {
|
||||
return "home.setup.polishKeyMissing"
|
||||
}
|
||||
return "home.setup.cloudIncomplete"
|
||||
}
|
||||
|
||||
static func apiKeyDeepLink(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
) -> SettingsDeepLink {
|
||||
if isLocalEngine || isASRConfigured {
|
||||
return .textPolish
|
||||
}
|
||||
return .speechRecognition
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
@State private var path = NavigationPath()
|
||||
@State private var dictionarySuggestions: [RimeFrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: RimeFrequentTerm?
|
||||
@State private var dictionarySuggestions: [FrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: FrequentTerm?
|
||||
|
||||
private let appGroupStore = AppGroupStore()
|
||||
private let rimeFrequentTermStore = RimeFrequentTermStore()
|
||||
private let dictionaryEntryService = PersonalDictionaryEntryService()
|
||||
private let frequentTermStore = FrequentTermStore()
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -130,6 +154,22 @@ struct HomeView: View {
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var apiKeySetupMessageKey: LocalizedStringKey {
|
||||
LocalizedStringKey(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var apiKeySettingsDeepLink: SettingsDeepLink {
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
}
|
||||
@@ -188,9 +228,12 @@ struct HomeView: View {
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .account:
|
||||
AccountCenterView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionarySuggestions()
|
||||
@@ -211,32 +254,6 @@ struct HomeView: View {
|
||||
guard count == 0 else { return }
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.alert(
|
||||
pendingDictionarySuggestion.map {
|
||||
AppL10n.format("home.card.dictionary.confirm.title", $0.term)
|
||||
} ?? "",
|
||||
isPresented: Binding(
|
||||
get: { pendingDictionarySuggestion != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: pendingDictionarySuggestion
|
||||
) { suggestion in
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
addSuggestedTerm(suggestion)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: { suggestion in
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.confirm.message",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
@@ -247,7 +264,6 @@ struct HomeView: View {
|
||||
let isCompact = geo.size.height < 700
|
||||
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
sessionHeaderGradient(height: gradientHeight)
|
||||
@@ -265,14 +281,10 @@ struct HomeView: View {
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, extrasBottomPadding)
|
||||
.padding(.bottom, CardLayoutMetrics.sectionSpacing)
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
homeLibrarySection
|
||||
homeContentSections(layout: .stacked, compact: isCompact)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
}
|
||||
@@ -288,6 +300,9 @@ struct HomeView: View {
|
||||
private var scrollStatusFooter: some View {
|
||||
setupGuidanceCard {
|
||||
flowStatusFooter
|
||||
if needsAPIKeySetup {
|
||||
apiKeySetupGuidance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,16 +310,14 @@ struct HomeView: View {
|
||||
|
||||
private var wideBody: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
wideHeroHeader
|
||||
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(layout: .split)
|
||||
|
||||
homeLibrarySection
|
||||
homeContentSections(layout: .split)
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
@@ -329,47 +342,38 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - History / dictionary cards
|
||||
// MARK: - Usage, account, and dictionary
|
||||
|
||||
/// Two independent cards; each header is an accent icon + uppercase label
|
||||
/// and the body grows with its rows.
|
||||
private var homeLibrarySection: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
dictionaryCard
|
||||
historyCard
|
||||
}
|
||||
}
|
||||
|
||||
private var historyCard: some View {
|
||||
let entries = Array(speechHistory.entries.prefix(Self.libraryPreviewLimit))
|
||||
return homeLibraryCard(
|
||||
titleKey: "history.title",
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
route: .history
|
||||
) {
|
||||
if entries.isEmpty {
|
||||
libraryEmptyLine("home.card.history.empty")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { libraryRowDivider }
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.sm) {
|
||||
Text(entry.text)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Text(Self.previewTimeFormatter.string(from: entry.createdAt))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
/// Keep the Home information hierarchy stable on both phone and iPad:
|
||||
/// account rewards → four metrics → calendar → personal dictionary.
|
||||
private func homeContentSections(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool = false
|
||||
) -> some View {
|
||||
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
AccountRewardsCard {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: .metrics,
|
||||
onOpenHistory: {
|
||||
path.append(HomeRoute.history)
|
||||
},
|
||||
onOpenDictionary: {
|
||||
path.append(HomeRoute.dictionary)
|
||||
}
|
||||
)
|
||||
|
||||
HomeUsageStatsSection(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: .calendar
|
||||
)
|
||||
|
||||
dictionaryCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,8 +385,8 @@ struct HomeView: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
Text("settings.personalDictionary.title")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.6)
|
||||
@@ -398,50 +402,71 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if dictionarySuggestions.isEmpty {
|
||||
libraryEmptyLine("home.card.dictionary.smart.empty")
|
||||
Text("home.card.dictionary.smart.empty")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
|
||||
} else {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm)
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: Spacing.sm
|
||||
) {
|
||||
ForEach(dictionarySuggestions) { suggestion in
|
||||
Button {
|
||||
pendingDictionarySuggestion = suggestion
|
||||
} label: {
|
||||
Text(suggestion.term)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: 38)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.16),
|
||||
palette.accent.opacity(0.07)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(palette.accent.opacity(0.22), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.smart.accessibilityHint",
|
||||
suggestion.commitCount
|
||||
VStack(spacing: Spacing.sm) {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(
|
||||
.adaptive(minimum: 88, maximum: 160),
|
||||
spacing: CardLayoutMetrics.compactItemSpacing
|
||||
)
|
||||
)
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: CardLayoutMetrics.compactItemSpacing
|
||||
) {
|
||||
ForEach(dictionarySuggestions) { suggestion in
|
||||
Button {
|
||||
pendingDictionarySuggestion = suggestion
|
||||
} label: {
|
||||
Text(suggestion.term)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(
|
||||
isPresented: Binding(
|
||||
get: {
|
||||
pendingDictionarySuggestion?.id == suggestion.id
|
||||
},
|
||||
set: { isPresented in
|
||||
if !isPresented,
|
||||
pendingDictionarySuggestion?.id == suggestion.id {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
attachmentAnchor: .rect(.bounds),
|
||||
arrowEdge: .bottom
|
||||
) {
|
||||
dictionarySuggestionConfirmation(suggestion)
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
.accessibilityHint(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.smart.accessibilityHint",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("home.card.dictionary.smart.addHint")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,55 +475,39 @@ struct HomeView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
|
||||
private func homeLibraryCard<Content: View>(
|
||||
titleKey: LocalizedStringKey,
|
||||
systemImage: String,
|
||||
route: HomeRoute,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
Button {
|
||||
path.append(route)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(titleKey)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
content()
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.surfaceCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
private func dictionarySuggestionConfirmation(_ suggestion: FrequentTerm) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Text(AppL10n.format("home.card.dictionary.confirm.title", suggestion.term))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
private var libraryRowDivider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
|
||||
private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View {
|
||||
Text(key)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.confirm.message",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button("common.cancel") {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
addSuggestedTerm(suggestion)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(width: 300)
|
||||
.background(palette.surface)
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
@@ -508,32 +517,29 @@ struct HomeView: View {
|
||||
|
||||
private func refreshDictionarySuggestions() {
|
||||
let dictionary = appGroupStore.personalDictionary
|
||||
let suggestions = rimeFrequentTermStore.suggestions(
|
||||
let suggestions = frequentTermStore.suggestions(
|
||||
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
|
||||
limit: Self.dictionarySuggestionLimit
|
||||
)
|
||||
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
|
||||
? suggestions
|
||||
let visibleSuggestions = Array(suggestions.prefix(Self.dictionarySuggestionLimit))
|
||||
dictionarySuggestions = visibleSuggestions.count >= Self.minimumDictionarySuggestionCount
|
||||
? visibleSuggestions
|
||||
: []
|
||||
}
|
||||
|
||||
private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) {
|
||||
var dictionary = appGroupStore.personalDictionary
|
||||
guard let saved = dictionary.upsertManual(term: suggestion.term),
|
||||
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
|
||||
private func addSuggestedTerm(_ suggestion: FrequentTerm) {
|
||||
guard let saved = dictionaryEntryService.saveEntry(
|
||||
term: suggestion.term,
|
||||
source: .history,
|
||||
minimumUsageCount: suggestion.commitCount
|
||||
) else {
|
||||
return
|
||||
}
|
||||
dictionary.entries[index].usageCount = max(
|
||||
dictionary.entries[index].usageCount,
|
||||
suggestion.commitCount
|
||||
)
|
||||
dictionary.version += 1
|
||||
appGroupStore.setPersonalDictionary(dictionary)
|
||||
pendingDictionarySuggestion = nil
|
||||
refreshDictionarySuggestions()
|
||||
|
||||
Task {
|
||||
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
|
||||
await dictionaryEntryService.finishSaving(saved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +554,11 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(_ deepLink: SettingsDeepLink) {
|
||||
SettingsDeepLink.setPending(deepLink)
|
||||
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Top gradient
|
||||
|
||||
private func sessionHeaderGradient(height: CGFloat) -> some View {
|
||||
@@ -651,6 +662,8 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, Spacing.xs)
|
||||
} else if needsAPIKeySetup {
|
||||
// The explanatory copy and both setup paths sit below this
|
||||
// compact status row so they remain readable on narrow phones.
|
||||
EmptyView()
|
||||
} else if canRetryPiP {
|
||||
Button {
|
||||
@@ -701,6 +714,51 @@ struct HomeView: View {
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
}
|
||||
|
||||
private var apiKeySetupGuidance: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(apiKeySetupMessageKey)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("home.setup.credits.alternative")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button {
|
||||
openSettings(apiKeySettingsDeepLink)
|
||||
} label: {
|
||||
Label("home.setup.byok.configure", systemImage: "key")
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
openSettings(.aiService)
|
||||
} label: {
|
||||
Label("home.setup.credits.open", systemImage: "sparkles")
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
content()
|
||||
@@ -708,10 +766,6 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
@@ -750,15 +804,6 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows shown inside the history / dictionary preview cards.
|
||||
private static let libraryPreviewLimit = 3
|
||||
private static let minimumDictionarySuggestionCount = 3
|
||||
private static let dictionarySuggestionLimit = 5
|
||||
|
||||
private static let previewTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
private static let dictionarySuggestionLimit = 6
|
||||
}
|
||||
|
||||
@@ -97,10 +97,6 @@ struct KeyboardPreviewSheet: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
|
||||
@@ -66,7 +66,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.surface, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -84,7 +83,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.modeChip.cycle"))
|
||||
@@ -101,7 +99,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.localeChip.cycle"))
|
||||
|
||||
@@ -201,6 +201,12 @@ struct MainAppRoot: View {
|
||||
return
|
||||
}
|
||||
refreshOfficialSkillCatalog(reason: "scenePhase.active")
|
||||
Task {
|
||||
// A protected Keychain can be temporarily unavailable during
|
||||
// launch. The coordinator no-ops after a terminal restore, so
|
||||
// this only retries a previously interrupted attempt.
|
||||
await accountSession.restoreIfNeeded()
|
||||
}
|
||||
if config.hasCompletedOnboarding {
|
||||
activateForegroundServices(reason: "scenePhase.active")
|
||||
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
|
||||
|
||||
@@ -15,14 +15,18 @@ struct MainTabView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
ZStack {
|
||||
palette.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
tab = .settings
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// OSGAnimatedBackgroundPreviewView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Standalone preview host for `OSGAnimatedMeshBackground`.
|
||||
// Open from anywhere with `OSGAnimatedBackgroundPreviewView()`.
|
||||
//
|
||||
// Tapping the screen swaps the palette; long-press pauses/resumes the animation
|
||||
// so you can capture a still frame for App Store screenshots.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct OSGAnimatedBackgroundPreviewView: View {
|
||||
private enum PaletteChoice: String, CaseIterable, Identifiable {
|
||||
case aurora, polar, ember
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .aurora: return "Aurora"
|
||||
case .polar: return "Polar"
|
||||
case .ember: return "Ember"
|
||||
}
|
||||
}
|
||||
var palette: AnimatedMeshPalette {
|
||||
switch self {
|
||||
case .aurora: return .aurora
|
||||
case .polar: return .polar
|
||||
case .ember: return .ember
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var choice: PaletteChoice = .aurora
|
||||
@State private var paused: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: choice.palette, paused: paused)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
Text(choice.label)
|
||||
.font(.system(size: 56, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
Text(paused ? "paused — long-press to resume" : "tap to switch · long-press to pause")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
Spacer().frame(height: 12)
|
||||
paletteDots
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
.preferredColorScheme(.dark)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
let all = PaletteChoice.allCases
|
||||
let next = all.firstIndex(of: choice).map { all[($0 + 1) % all.count] } ?? choice
|
||||
choice = next
|
||||
}
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.5) {
|
||||
paused.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private var paletteDots: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(PaletteChoice.allCases) { item in
|
||||
Circle()
|
||||
.fill(item == choice ? Color.white : Color.white.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Aurora · polar · ember") {
|
||||
OSGAnimatedBackgroundPreviewView()
|
||||
}
|
||||
#endif
|
||||
@@ -334,10 +334,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.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: - Permissions
|
||||
@@ -416,10 +412,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.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: - Keyboard setup
|
||||
@@ -489,10 +481,6 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.top, Spacing.xxl)
|
||||
|
||||
TextField(
|
||||
@@ -762,10 +750,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(keyboardReady ? palette.accent.opacity(0.42) : palette.divider, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var practiceVoiceSample: some View {
|
||||
@@ -822,10 +806,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
private var practiceTitle: LocalizedStringKey {
|
||||
@@ -918,7 +898,7 @@ struct OnboardingExperienceView: View {
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.opacity(accountSession.isSignedIn ? 0 : 1)
|
||||
.disabled(accountSession.isSignedIn)
|
||||
.disabled(accountSession.isSignedIn || accountSession.operation != nil)
|
||||
.accessibilityHidden(accountSession.isSignedIn)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -927,10 +907,6 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.top, Spacing.xxl)
|
||||
.accessibilityIdentifier("onboarding.loginReward.card")
|
||||
}
|
||||
@@ -1483,10 +1459,6 @@ private extension View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1584,9 +1556,5 @@ private struct KeyboardSettingsPreview: View {
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,10 +295,6 @@ struct OnboardingView: View {
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -42,7 +42,6 @@ struct OpenSourceLicensesView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.licenses.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View {
|
||||
@@ -105,6 +104,5 @@ private struct OpenSourceLicenseDetailView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(entry.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct PersonalDictionaryView: View {
|
||||
@State private var generatingAliasEntryIDs: Set<UUID> = []
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let aliasGenerator = DictionaryAliasGenerator()
|
||||
private let dictionaryEntryService = PersonalDictionaryEntryService()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -37,7 +37,6 @@ struct PersonalDictionaryView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.personalDictionary.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.toolbar {
|
||||
if !dictionary.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -76,7 +75,11 @@ struct PersonalDictionaryView: View {
|
||||
initialTerm: editingEntry?.term ?? "",
|
||||
isEditing: editingEntry != nil
|
||||
) { term in
|
||||
saveManualEntry(term: term, editingID: editingEntry?.id)
|
||||
saveEntry(
|
||||
term: term,
|
||||
editingID: editingEntry?.id,
|
||||
source: editingEntry?.source ?? .manual
|
||||
)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -110,7 +113,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
// 让搜索栏与首个词条之间留出呼吸空间,视觉更透气。
|
||||
@@ -138,14 +141,6 @@ struct PersonalDictionaryView: View {
|
||||
Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
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)
|
||||
@@ -231,32 +226,25 @@ 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
|
||||
private func saveEntry(
|
||||
term: String,
|
||||
editingID: UUID?,
|
||||
source: PersonalDictionary.Entry.Source
|
||||
) {
|
||||
guard let saved = dictionaryEntryService.saveEntry(
|
||||
term: term,
|
||||
existingID: editingID,
|
||||
source: source
|
||||
) else {
|
||||
return
|
||||
}
|
||||
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)
|
||||
dictionary = saved.dictionary
|
||||
if saved.shouldGenerateAliases {
|
||||
generatingAliasEntryIDs.insert(saved.entryID)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
dictionary = await dictionaryEntryService.finishSaving(saved)
|
||||
generatingAliasEntryIDs.remove(saved.entryID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@ struct PolishStylesView: View {
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm)
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
CardPageContent {
|
||||
styleLearningCard
|
||||
packGridSection(
|
||||
title: "polishStyles.builtin.section",
|
||||
@@ -114,12 +114,12 @@ struct PolishStylesView: View {
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.accentMuted,
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
|
||||
@@ -191,13 +191,6 @@ struct PolishStylesView: View {
|
||||
isActionAvailable ? palette.accent : palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(
|
||||
isActionAvailable ? Color.clear : palette.dividerStrong,
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -221,7 +214,7 @@ struct PolishStylesView: View {
|
||||
packs: [PolishStylePack]
|
||||
) -> some View {
|
||||
CardSection(title) {
|
||||
LazyVGrid(columns: columns, spacing: Spacing.sm) {
|
||||
LazyVGrid(columns: columns, spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(packs) { pack in
|
||||
packCard(pack)
|
||||
}
|
||||
@@ -274,13 +267,6 @@ struct PolishStylesView: View {
|
||||
isSelected ? palette.accentMuted : palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(
|
||||
isSelected ? palette.accent : palette.divider,
|
||||
lineWidth: isSelected ? 1.5 : 0.5
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.contextMenu {
|
||||
Button("polishStyles.duplicate") {
|
||||
|
||||
@@ -18,7 +18,6 @@ struct PrivacyPolicyView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.privacy.policy")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private var privacyScrollAnchor: String? {
|
||||
|
||||
@@ -30,28 +30,43 @@ struct ProviderPickerSection: View {
|
||||
var body: some View {
|
||||
// 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时
|
||||
// 把整行 label 一起隐藏,导致左侧「供应商」文字消失。
|
||||
SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
|
||||
HStack(spacing: Spacing.lg) {
|
||||
Text(AppL10n.string("settings.provider.supplier"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
Menu {
|
||||
ForEach(visiblePresets, id: \.id) { provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
let name = ProviderDisplayName.name(for: provider.id)
|
||||
if provider.id == selectedProviderId {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
Picker("", selection: providerSelection) {
|
||||
ForEach(visiblePresets, id: \.id) { provider in
|
||||
Text(ProviderDisplayName.name(for: provider.id))
|
||||
.tag(provider.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
providerChip
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.settingsListRow()
|
||||
.surfaceCard(enabled: showsSurface)
|
||||
}
|
||||
|
||||
private var providerSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { selectedProviderId },
|
||||
set: { providerID in
|
||||
guard let provider = visiblePresets.first(where: { $0.id == providerID }) else {
|
||||
return
|
||||
}
|
||||
select(provider)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
switch role {
|
||||
@@ -63,14 +78,13 @@ struct ProviderPickerSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。
|
||||
/// 收起状态展示:当前所选供应商名称、能力标记与展开箭头。
|
||||
private var providerChip: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
providerMark(selectedProvider)
|
||||
|
||||
Text(ProviderDisplayName.name(for: selectedProvider.id))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if selectedProvider.supportsPersonalDictionaryCloudASR {
|
||||
personalDictionaryBadge
|
||||
@@ -79,36 +93,14 @@ struct ProviderPickerSection: View {
|
||||
streamingBadge
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerMark(_ provider: LLMProvider) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 32, height: 32)
|
||||
if let asset = ProviderLogo.assetName(for: provider.id) {
|
||||
Image(asset)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundStyle(palette.accent)
|
||||
} else {
|
||||
Text(String(provider.name.prefix(1)))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通义千问 / 智谱 GLM 等支持云端 ASR 热词 API 的提供商。
|
||||
private var personalDictionaryBadge: some View {
|
||||
Text("settings.provider.personalDictionaryBadge")
|
||||
@@ -117,6 +109,7 @@ struct ProviderPickerSection: View {
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
/// Bailian / Volcengine / OpenAI Realtime — utterance-level true streaming.
|
||||
@@ -127,5 +120,6 @@ struct ProviderPickerSection: View {
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct SettingsICloudSyncRow: View {
|
||||
private let store = AppGroupStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.appSettings.iCloudSync.title")
|
||||
@@ -35,43 +35,50 @@ struct SettingsICloudSyncRow: View {
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
.settingsListRow(alignment: .leading)
|
||||
|
||||
if isEnabled {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(syncButtonTitleKey)
|
||||
.font(TypeStyle.caption)
|
||||
Spacer(minLength: 0)
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
} else if showSyncedConfirmation {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(syncButtonTitleKey)
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(
|
||||
systemName: showSyncedConfirmation
|
||||
? "checkmark"
|
||||
: "arrow.triangle.2.circlepath"
|
||||
)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
// Fill the row so the whole strip is tappable, not just
|
||||
// the caption glyphs (previously easy to miss).
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow(alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.settingsListRow(alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
|
||||
|
||||
@@ -180,17 +180,13 @@ struct DefaultInputModePickerRow: View {
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Menu {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selection = DefaultInputMode(rawValue: option.id) ?? .voice
|
||||
} label: {
|
||||
if option.id == selection.rawValue {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
Picker("", selection: defaultInputModeSelection) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Text(option.label)
|
||||
.tag(option.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(currentLabel)
|
||||
@@ -205,6 +201,13 @@ struct DefaultInputModePickerRow: View {
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private var defaultInputModeSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { selection.rawValue },
|
||||
set: { selection = DefaultInputMode(rawValue: $0) ?? .voice }
|
||||
)
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
options.first(where: { $0.id == selection.rawValue })?.label ?? "—"
|
||||
}
|
||||
@@ -248,17 +251,13 @@ struct SettingsMenuPickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(options, id: \.id) { o in
|
||||
Button {
|
||||
selection = o.id
|
||||
} label: {
|
||||
if o.id == selection {
|
||||
Label(o.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(o.label)
|
||||
}
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Text(option.label)
|
||||
.tag(option.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(currentLabel)
|
||||
@@ -294,22 +293,13 @@ struct LocalePickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(locales, id: \.id) { locale in
|
||||
Button {
|
||||
selection = locale.id
|
||||
} label: {
|
||||
// iOS Menu converts SwiftUI Label to UIAction (title + image).
|
||||
// Using Label keeps checkmark + on-device icon both visible.
|
||||
let name = label(for: locale.id)
|
||||
if locale.id == selection {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else if locale.onDevice {
|
||||
Label(name, systemImage: "iphone")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(locales, id: \.id) { locale in
|
||||
Text(label(for: locale.id))
|
||||
.tag(locale.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
// On-device badge for the currently selected locale.
|
||||
|
||||
@@ -106,10 +106,6 @@ struct SettingsCredentialRow: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
@@ -184,20 +180,13 @@ struct SettingsModelPickerRow: View {
|
||||
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(models, id: \.self) { modelId in
|
||||
Button {
|
||||
invalidateRequest()
|
||||
model = modelId
|
||||
message = AppL10n.format("settings.provider.modelSelected", modelId)
|
||||
failed = false
|
||||
} label: {
|
||||
if modelId == model {
|
||||
Label(modelId, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(modelId)
|
||||
}
|
||||
Picker("", selection: modelSelection) {
|
||||
ForEach(models, id: \.self) { modelID in
|
||||
Text(modelID)
|
||||
.tag(modelID)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
@@ -211,6 +200,18 @@ struct SettingsModelPickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var modelSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { model },
|
||||
set: { modelID in
|
||||
invalidateRequest()
|
||||
model = modelID
|
||||
message = AppL10n.format("settings.provider.modelSelected", modelID)
|
||||
failed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
runFetchModels()
|
||||
@@ -227,10 +228,6 @@ struct SettingsModelPickerRow: View {
|
||||
}
|
||||
.frame(width: controlHeight, height: controlHeight)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isRunning)
|
||||
@@ -345,10 +342,9 @@ struct SettingsProviderToolsRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: 34)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -146,18 +146,13 @@ struct AppleASRPickerRow: View {
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: usesAppleRecognition) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "apple.logo")
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.apple.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.apple.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.apple.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.apple.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
@@ -213,7 +208,6 @@ struct SpeechRecognitionSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.speechRecognition.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +232,6 @@ struct TextPolishSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.textPolish.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +315,6 @@ struct GeneralSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.general.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +338,6 @@ struct AIAgentSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,34 +359,34 @@ struct ClipboardSettingsView: View {
|
||||
CardSection("settings.clipboard.section") {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: $config.clipboardHistoryEnabled) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.history.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.history.footer")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Toggle(isOn: clipboardCandidateBinding) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.candidate.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.candidate.footer")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(!config.clipboardHistoryEnabled)
|
||||
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
@@ -406,7 +397,7 @@ struct ClipboardSettingsView: View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.paste.body")
|
||||
.font(.footnote)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if pasteAccessVerified {
|
||||
@@ -420,8 +411,7 @@ struct ClipboardSettingsView: View {
|
||||
.accessibilityIdentifier("settings.clipboard.paste.verified")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
if !pasteAccessVerified {
|
||||
Divider().background(palette.divider)
|
||||
@@ -463,24 +453,25 @@ struct ClipboardSettingsView: View {
|
||||
CardSection("settings.clipboard.storage.section") {
|
||||
VStack(spacing: 0) {
|
||||
Text("settings.clipboard.storage.body")
|
||||
.font(.footnote)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.clear.button")
|
||||
Spacer()
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.foregroundStyle(palette.danger)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -494,7 +485,6 @@ struct ClipboardSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.confirmationDialog(
|
||||
"settings.clipboard.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
@@ -574,12 +564,15 @@ struct AboutSettingsView: View {
|
||||
CardSection("settings.analytics.section") {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Toggle(
|
||||
"settings.analytics.title",
|
||||
isOn: Binding(
|
||||
get: { analytics.isEnabled },
|
||||
set: { analytics.setEnabled($0) }
|
||||
)
|
||||
)
|
||||
) {
|
||||
Text("settings.analytics.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
|
||||
@@ -660,7 +653,6 @@ struct AboutSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.about.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.onAppear {
|
||||
analytics.refreshEnabledState()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ enum SettingsPresentation {
|
||||
|
||||
/// Routes pushed from Settings home. Value-based navigation keeps
|
||||
/// destinations out of the root view tree until push — important so
|
||||
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
|
||||
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||
/// destination toolbar preferences do not leak onto the home screen
|
||||
/// (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||
private enum SettingsRoute: Hashable {
|
||||
case account
|
||||
case speechRecognition
|
||||
@@ -41,50 +41,42 @@ 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
|
||||
@State private var path = NavigationPath()
|
||||
@State private var settingsScrollTarget: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountEntrySection
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
if presentation == .tab {
|
||||
moreEntriesSection
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountEntrySection
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
.id(SettingsDeepLink.aiService.rawValue)
|
||||
if presentation == .tab {
|
||||
moreEntriesSection
|
||||
}
|
||||
}
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
.onChange(of: settingsScrollTarget) { _, target in
|
||||
guard target == SettingsDeepLink.aiService.rawValue else { return }
|
||||
Task { @MainActor in
|
||||
await Task.yield()
|
||||
withAnimation(Motion.soft) {
|
||||
proxy.scrollTo(SettingsDeepLink.aiService.rawValue, anchor: .top)
|
||||
}
|
||||
settingsScrollTarget = nil
|
||||
}
|
||||
}
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.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) {
|
||||
// Keep `dismiss` off the Settings root — pairing it
|
||||
@@ -98,25 +90,35 @@ struct SettingsView: View {
|
||||
}
|
||||
.task { await loadDynamicLocales() }
|
||||
.onAppear {
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
consumeSettingsDeepLinkIfNeeded()
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
Task { await accountSession.refreshAccountData() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
consumeSettingsDeepLinkIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
}
|
||||
|
||||
private func consumeClipboardDeepLinkIfNeeded() {
|
||||
guard SettingsDeepLink.consumePending() == .clipboard else { return }
|
||||
private func consumeSettingsDeepLinkIfNeeded() {
|
||||
guard let link = SettingsDeepLink.consumePending() else { return }
|
||||
if !path.isEmpty {
|
||||
path = NavigationPath()
|
||||
}
|
||||
path.append(SettingsRoute.clipboard)
|
||||
switch link {
|
||||
case .aiService:
|
||||
settingsScrollTarget = SettingsDeepLink.aiService.rawValue
|
||||
case .speechRecognition:
|
||||
path.append(SettingsRoute.speechRecognition)
|
||||
case .textPolish:
|
||||
path.append(SettingsRoute.textPolish)
|
||||
case .clipboard:
|
||||
path.append(SettingsRoute.clipboard)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeAccountDeepLinkIfNeeded() {
|
||||
@@ -151,188 +153,76 @@ struct SettingsView: View {
|
||||
|
||||
private var accountEntrySection: some View {
|
||||
CardSection("account.settings.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Group {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.settings.restoring")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.settingsListRow()
|
||||
case .signedOut:
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(accountSession.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case let .signedIn(session):
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
AccountAvatarView(
|
||||
accountID: session.accountID,
|
||||
displayName: session.displayName,
|
||||
size: 44
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(
|
||||
session.displayName
|
||||
?? AppL10n.string(
|
||||
"account.profile.fallbackName",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(accountSettingsSubtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Group {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.settings.restoring")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.settingsListRow()
|
||||
case .signedOut:
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(accountSession.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case let .signedIn(session):
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
AccountAvatarView(
|
||||
accountID: session.accountID,
|
||||
displayName: session.displayName,
|
||||
size: 44
|
||||
)
|
||||
|
||||
if accountSession.isSignedIn {
|
||||
accountRewardsEntrySection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var accountRewardsEntrySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch accountSession.snapshotPhase {
|
||||
case let .loaded(snapshot):
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
Text(
|
||||
session.displayName
|
||||
?? AppL10n.string(
|
||||
"account.profile.fallbackName",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
Text(accountSettingsSubtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||
)
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("account.error.load")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Task { await accountSession.refreshAccountData(force: true) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case .idle, .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.loading.center")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: accountSession.referralProfile)
|
||||
.surfaceCard()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
|
||||
let remaining = max(snapshot.credits.balance, 0)
|
||||
let used = max(snapshot.credits.usedCredits, 0)
|
||||
let (total, overflow) = remaining.addingReportingOverflow(used)
|
||||
return overflow ? Int64.max : total
|
||||
}
|
||||
|
||||
private func creditRemainingText(_ remaining: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.remainingCompact",
|
||||
language: config.uiLanguage,
|
||||
max(remaining, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditUsedText(_ used: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.usedCompact",
|
||||
language: config.uiLanguage,
|
||||
max(used, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditTotalText(_ total: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.totalValueCompact",
|
||||
language: config.uiLanguage,
|
||||
total.formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private var accountSettingsSubtitle: String {
|
||||
@@ -462,81 +352,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountReferralLinkView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var viewModel: ReferralProfileViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.state {
|
||||
case .idle:
|
||||
retryRow(
|
||||
messageKey: "account.referral.loadLink",
|
||||
actionKey: "account.referral.loadLink"
|
||||
)
|
||||
case .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.referral.loadingLink")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
retryRow(messageKey: messageKey, actionKey: "account.retry")
|
||||
case .loaded(let profile):
|
||||
loadedContent(profile)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("account.referral.profile")
|
||||
}
|
||||
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text("account.referral.equalRewardDescription")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(invitationURL: profile.code.inviteURL)
|
||||
}
|
||||
|
||||
if viewModel.isRefreshing {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("account.referral.refreshingLink")
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if let refreshErrorKey = viewModel.refreshErrorKey {
|
||||
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: LocalizedStringKey
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button(actionKey) {
|
||||
Task { await viewModel.refresh() }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet dismiss (isolated from Settings root)
|
||||
|
||||
private struct SettingsSheetDismissButton: View {
|
||||
|
||||
@@ -38,17 +38,13 @@ struct TranslationPickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
apply(language)
|
||||
} label: {
|
||||
if currentSelectionId == language.id {
|
||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: language))
|
||||
}
|
||||
Picker("", selection: translationSelection) {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Text(displayLabel(for: language))
|
||||
.tag(language.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(currentLabel)
|
||||
@@ -79,6 +75,15 @@ struct TranslationPickerRow: View {
|
||||
TranslationLanguageCatalog.isOff(currentSelectionId)
|
||||
}
|
||||
|
||||
private var translationSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { currentSelectionId },
|
||||
set: { selectionID in
|
||||
apply(TranslationLanguageCatalog.resolve(selectionID))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return AppL10n.string("settings.translation.off")
|
||||
|
||||
@@ -18,23 +18,28 @@ struct TypingInputSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) {
|
||||
Section {
|
||||
Picker(
|
||||
AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage),
|
||||
selection: $configuration.schema
|
||||
) {
|
||||
ForEach(TypingInputSchema.allCases) { schema in
|
||||
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
.tag(schema)
|
||||
}
|
||||
} label: {
|
||||
Text(AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(PinyinFuzzyPair.allCases) { pair in
|
||||
Toggle(
|
||||
pair.displayName,
|
||||
isOn: Binding(
|
||||
get: { configuration.fuzzyPairs.contains(pair) },
|
||||
set: { enabled in
|
||||
@@ -42,23 +47,30 @@ struct TypingInputSettingsView: View {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
)
|
||||
)
|
||||
) {
|
||||
Text(pair.displayName)
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
} footer: {
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage)) {
|
||||
Section {
|
||||
HStack {
|
||||
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
Spacer()
|
||||
if isDeploying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(
|
||||
hasDeploymentError ? palette.danger : palette.textSecondary
|
||||
)
|
||||
@@ -68,19 +80,26 @@ struct TypingInputSettingsView: View {
|
||||
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.disabled(isDeploying)
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) {
|
||||
showClearHabitsConfirmation = true
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.disabled(isDeploying)
|
||||
.foregroundStyle(palette.danger)
|
||||
} footer: {
|
||||
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
}
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"settings.keyboardHaptic.off" = "Off";
|
||||
"settings.keyboardHaptic.light" = "Light";
|
||||
"settings.keyboardHaptic.strong" = "Strong";
|
||||
"settings.cursorDragNavigation.title" = "Drag empty area to move cursor";
|
||||
"settings.systemPrompt.reset" = "Reset";
|
||||
"settings.asrLocale" = "ASR locale";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
|
||||
@@ -495,8 +494,11 @@
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
"home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work.";
|
||||
"home.setup.permission.request" = "Next";
|
||||
"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key in Settings to enable AI polish. Without it, raw ASR text is inserted.";
|
||||
"home.setup.cloudIncomplete" = "Cloud recognition and AI polish need API keys.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key to enable AI polish. Without it, local dictation still inserts raw recognized text.";
|
||||
"home.setup.byok.configure" = "Configure API Key";
|
||||
"home.setup.credits.alternative" = "Or use OSG credits for transcription and AI without managing API keys.";
|
||||
"home.setup.credits.open" = "Use Credits";
|
||||
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
||||
"home.setup.keyboardHint.dismiss" = "Got it";
|
||||
"home.flow.starting" = "Starting voice session…";
|
||||
@@ -514,11 +516,12 @@
|
||||
"home.preview.placeholder" = "Tap to type and test…";
|
||||
"home.card.history.empty" = "No voice transcripts yet";
|
||||
"home.card.dictionary.empty" = "No words yet";
|
||||
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing";
|
||||
"home.card.dictionary.smart.empty" = "No suggestions yet";
|
||||
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your typing";
|
||||
"home.card.dictionary.smart.empty" = "No recommended terms yet";
|
||||
"home.card.dictionary.smart.addHint" = "Tap to add frequently used terms to your personal dictionary";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary.";
|
||||
"home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?";
|
||||
"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
|
||||
"home.card.dictionary.confirm.message" = "You’ve used this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
|
||||
"home.card.dictionary.confirm.add" = "Add to dictionary";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
@@ -562,11 +565,11 @@
|
||||
"skills.action.install" = "Install";
|
||||
"skills.action.uninstall" = "Uninstall";
|
||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||
"skills.reply.name" = "Reply in source language";
|
||||
"skills.reply.name" = "Reply";
|
||||
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
|
||||
"skills.playfulReply.name" = "Playful reply";
|
||||
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
|
||||
"skills.replyInSourceLanguage.name" = "Reply in source language";
|
||||
"skills.replyInSourceLanguage.name" = "Reply";
|
||||
"skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
|
||||
"skills.summarize.name" = "Summarize key points";
|
||||
"skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present.";
|
||||
@@ -660,7 +663,7 @@
|
||||
"polishStyles.learn.generating" = "Generating…";
|
||||
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
|
||||
"polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
"polishStyles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
|
||||
@@ -724,7 +727,6 @@
|
||||
"settings.personalDictionary.empty.title" = "No words yet";
|
||||
"settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud";
|
||||
"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";
|
||||
@@ -830,6 +832,8 @@
|
||||
"account.signedOut.title" = "Your OSG account";
|
||||
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
|
||||
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
||||
"home.accountRewards.signedOut.title" = "Sign in for free credits";
|
||||
"home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits.";
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
"account.signIn.loading" = "Signing in…";
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"settings.keyboardHaptic.off" = "关";
|
||||
"settings.keyboardHaptic.light" = "轻";
|
||||
"settings.keyboardHaptic.strong" = "强";
|
||||
"settings.cursorDragNavigation.title" = "触摸空白区域移动光标";
|
||||
"settings.systemPrompt.reset" = "重置";
|
||||
"settings.asrLocale" = "识别语言";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
|
||||
@@ -494,8 +493,11 @@
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.request" = "下一步";
|
||||
"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。";
|
||||
"home.setup.polishKeyMissing" = "请先在「设置」填写 API Key 才能润色;未填写时会直接插入原始识别结果。";
|
||||
"home.setup.cloudIncomplete" = "云端识别和 AI 润色需要配置 API Key。";
|
||||
"home.setup.polishKeyMissing" = "配置 API Key 后可启用 AI 润色;未配置时,本地听写仍会插入原始识别文本。";
|
||||
"home.setup.byok.configure" = "配置 API Key";
|
||||
"home.setup.credits.alternative" = "也可以使用 OSG 积分完成语音转写和 AI,无需管理 API Key。";
|
||||
"home.setup.credits.open" = "使用积分";
|
||||
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
||||
"home.setup.keyboardHint.dismiss" = "知道了";
|
||||
"home.flow.starting" = "正在启动语音会话…";
|
||||
@@ -513,11 +515,12 @@
|
||||
"home.preview.placeholder" = "点这里试试键盘";
|
||||
"home.card.history.empty" = "还没有语音识别记录";
|
||||
"home.card.dictionary.empty" = "还没有词条";
|
||||
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "暂无推荐词";
|
||||
"home.card.dictionary.smart.subtitle" = "从中英文输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "暂无推荐热词";
|
||||
"home.card.dictionary.smart.addHint" = "点击添加常用词到个性词库";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
|
||||
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
|
||||
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
"home.card.dictionary.confirm.message" = "这个词已使用 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
"home.card.dictionary.confirm.add" = "加入词库";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
@@ -561,11 +564,11 @@
|
||||
"skills.action.install" = "安装";
|
||||
"skills.action.uninstall" = "卸载";
|
||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||
"skills.reply.name" = "原语言回复";
|
||||
"skills.reply.name" = "回复";
|
||||
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
|
||||
"skills.playfulReply.name" = "俏皮回复";
|
||||
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
|
||||
"skills.replyInSourceLanguage.name" = "用原语言回复";
|
||||
"skills.replyInSourceLanguage.name" = "回复";
|
||||
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
|
||||
"skills.summarize.name" = "总结要点";
|
||||
"skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。";
|
||||
@@ -659,7 +662,7 @@
|
||||
"polishStyles.learn.generating" = "生成中…";
|
||||
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
@@ -723,7 +726,6 @@
|
||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
|
||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
||||
"settings.personalDictionary.add.title" = "添加词条";
|
||||
"settings.personalDictionary.edit.title" = "编辑词条";
|
||||
"settings.personalDictionary.add.field" = "词条";
|
||||
@@ -829,6 +831,8 @@
|
||||
"account.signedOut.title" = "OSG 账号";
|
||||
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
|
||||
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||
"home.accountRewards.signedOut.title" = "登录领免费积分";
|
||||
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。";
|
||||
"account.signIn.apple" = "通过 Apple 登录";
|
||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||
"account.signIn.loading" = "正在登录…";
|
||||
|
||||
@@ -68,6 +68,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
/// One random ID per keyboard presentation. The repository splits this ID
|
||||
/// into independent UTC-day fragments when a presentation crosses midnight.
|
||||
private var keyboardUsageSessionID = UUID()
|
||||
/// UIKit can reuse this controller across host apps. Keep document-scoped
|
||||
/// candidate state separate from the heavy typing-engine lifetime.
|
||||
private var isKeyboardPresentationActive = false
|
||||
private var isTypingSurfaceActive = false
|
||||
private var typingDocumentPresentationID: UInt64?
|
||||
|
||||
private var memoryTelemetryContext: String {
|
||||
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||||
@@ -208,6 +213,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
isKeyboardPresentationActive = false
|
||||
typingDocumentPresentationID = nil
|
||||
typingSessionStorage?.endDocumentPresentation()
|
||||
AnalyticsExtensionService.shared.keyboardWillDisappear()
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = nil
|
||||
@@ -243,8 +251,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if !preserve {
|
||||
prepareSurfaceForNextPresentation()
|
||||
}
|
||||
if state.surface == .typing {
|
||||
typingSession.leaveTypingMode()
|
||||
if state.surface != .typing {
|
||||
deactivateTypingSurface()
|
||||
}
|
||||
if preserve {
|
||||
return
|
||||
@@ -255,6 +263,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
isKeyboardPresentationActive = true
|
||||
OSGDiag.log(
|
||||
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
|
||||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||||
@@ -263,6 +272,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
recordMemory("KVC.viewWillAppear.begin")
|
||||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||
configureDictationBehavior()
|
||||
state.hasFullAccess = hasFullAccess
|
||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||
// Refresh only Flow/config state; edit targets come from verified OSG insertions.
|
||||
flowCoordinator.refreshSessionState()
|
||||
@@ -278,8 +288,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
recordMemory("KVC.viewWillAppear.afterHaptics")
|
||||
if state.surface == .typing {
|
||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
activateTypingPresentationIfNeeded()
|
||||
recordMemory("KVC.viewWillAppear.afterTypingEnter")
|
||||
}
|
||||
clipboardCapture.keyboardDidAppear()
|
||||
@@ -322,6 +331,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
heightPhase = .presented
|
||||
lockPresentedKeyboardHeight()
|
||||
refreshReturnKeyRole()
|
||||
scheduleTypingDocumentSynchronization()
|
||||
// Presentation math is locked before arming the PiP handoff.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.heightPhase == .presented else { return }
|
||||
@@ -357,7 +367,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func textDidChange(_ textInput: UITextInput?) {
|
||||
super.textDidChange(textInput)
|
||||
refreshReturnKeyRole()
|
||||
typingSession.synchronizeEnglishDocumentContext()
|
||||
synchronizeTypingDocumentContext()
|
||||
textInserter?.refreshEditingAvailability()
|
||||
lastInputEditCoordinator?.refreshContext()
|
||||
}
|
||||
@@ -365,7 +375,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func selectionDidChange(_ textInput: UITextInput?) {
|
||||
super.selectionDidChange(textInput)
|
||||
refreshReturnKeyRole()
|
||||
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
synchronizeTypingDocumentContext(caretMoved: true)
|
||||
textInserter?.refreshEditingAvailability()
|
||||
lastInputEditCoordinator?.refreshContext()
|
||||
}
|
||||
@@ -395,10 +405,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
)
|
||||
flowCoordinator.cancelPipelineUnlessAwaitingResult()
|
||||
if state.surface == .typing {
|
||||
typingSession.leaveTypingMode()
|
||||
applySurface(.voice)
|
||||
} else {
|
||||
typingSession.leaveTypingMode()
|
||||
deactivateTypingSurface()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,9 +432,15 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.insertTextIntoDocument(text, source: source)
|
||||
},
|
||||
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||
contextBeforeInput: { [weak self] in
|
||||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||||
return self.textDocumentProxy.documentContextBeforeInput
|
||||
},
|
||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
||||
selectedText: { [weak self] in
|
||||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||||
return self.textDocumentProxy.selectedText
|
||||
},
|
||||
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
|
||||
editHintScheduler: editHintScheduler
|
||||
)
|
||||
@@ -439,6 +454,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
onConfigChanged: { [weak self] in
|
||||
// Only an already-live typing session can be showing the setup
|
||||
// error; never force-create one just to retry.
|
||||
self?.typingSessionStorage?.reloadPersonalDictionaryTerms()
|
||||
self?.typingSessionStorage?.retryPrepareAfterResourceDeployment()
|
||||
}
|
||||
)
|
||||
@@ -502,7 +518,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||
clipboardCapture.configure(
|
||||
isSecure: { [weak self] in
|
||||
self?.textDocumentProxy.isSecureTextEntry ?? false
|
||||
self?.textDocumentProxy.isSecureTextEntry ?? true
|
||||
},
|
||||
hasFullAccess: { [weak self] in
|
||||
self?.hasFullAccess ?? false
|
||||
@@ -666,8 +682,14 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
state.$surface
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshKeyboardHeight()
|
||||
.sink { [weak self] surface in
|
||||
guard let self else { return }
|
||||
if surface == .typing {
|
||||
self.activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
self.deactivateTypingSurface()
|
||||
}
|
||||
self.refreshKeyboardHeight()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
@@ -685,6 +707,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
return
|
||||
}
|
||||
guard state.surface != surface else {
|
||||
if surface == .typing {
|
||||
activateTypingPresentationIfNeeded()
|
||||
}
|
||||
refreshKeyboardHeight()
|
||||
return
|
||||
}
|
||||
@@ -695,14 +720,68 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.surface = surface
|
||||
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
|
||||
if surface == .typing {
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
activateTypingPresentationIfNeeded()
|
||||
} else {
|
||||
typingSession.leaveTypingMode()
|
||||
deactivateTypingSurface()
|
||||
}
|
||||
refreshKeyboardHeight()
|
||||
}
|
||||
|
||||
private func activateTypingPresentationIfNeeded() {
|
||||
guard isKeyboardPresentationActive,
|
||||
state.surface == .typing,
|
||||
typingDocumentPresentationID == nil else {
|
||||
return
|
||||
}
|
||||
if !isTypingSurfaceActive {
|
||||
typingSession.enterTypingMode()
|
||||
isTypingSurfaceActive = true
|
||||
}
|
||||
typingDocumentPresentationID = typingSession.beginDocumentPresentation()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
if heightPhase == .presented {
|
||||
refreshReturnKeyRole()
|
||||
scheduleTypingDocumentSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateTypingSurface() {
|
||||
guard isTypingSurfaceActive || typingDocumentPresentationID != nil else { return }
|
||||
typingDocumentPresentationID = nil
|
||||
typingSessionStorage?.leaveTypingMode()
|
||||
isTypingSurfaceActive = false
|
||||
}
|
||||
|
||||
private func scheduleTypingDocumentSynchronization() {
|
||||
guard let presentationID = typingDocumentPresentationID else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.isKeyboardPresentationActive,
|
||||
self.state.surface == .typing,
|
||||
self.typingDocumentPresentationID == presentationID else {
|
||||
return
|
||||
}
|
||||
self.synchronizeTypingDocumentContext(
|
||||
caretMoved: true,
|
||||
presentationID: presentationID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeTypingDocumentContext(
|
||||
caretMoved: Bool = false,
|
||||
presentationID: UInt64? = nil
|
||||
) {
|
||||
guard let activeID = presentationID ?? typingDocumentPresentationID,
|
||||
typingDocumentPresentationID == activeID else {
|
||||
return
|
||||
}
|
||||
typingSessionStorage?.synchronizeEnglishDocumentContext(
|
||||
caretMoved: caretMoved,
|
||||
presentationID: activeID
|
||||
)
|
||||
}
|
||||
|
||||
private func applyPreferredSurfaceOnOpen() {
|
||||
let preference = TypingInputConfiguration.preferredOpenPreference()
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
@@ -716,10 +795,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
|
||||
category: "boot"
|
||||
)
|
||||
applySurface(resolved)
|
||||
if resolved == .typing, let language = preference.typingLanguage {
|
||||
_ = typingSession.setLanguage(language)
|
||||
}
|
||||
applySurface(resolved)
|
||||
}
|
||||
|
||||
/// When not remembering, snap to the static open preference while hidden
|
||||
@@ -842,24 +921,31 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = nil
|
||||
refreshAssistantFieldAction()
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||
// An unavailable trait is not proof that the new host field is safe.
|
||||
// Fail closed until UIKit supplies an explicit non-secure value.
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? true
|
||||
state.setSecureTextEntry(isSecure)
|
||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||
// Secure fields must not run English autocomplete / autocorrect / learning.
|
||||
typingSession.suggestionsEnabled = !isSecure
|
||||
typingSession.syncAutocapitalization()
|
||||
typingSessionStorage?.suggestionsEnabled = !isSecure
|
||||
if !isSecure {
|
||||
typingSessionStorage?.syncAutocapitalization()
|
||||
}
|
||||
}
|
||||
|
||||
private func installTypingContextProviders() {
|
||||
typingSession.precedingTextProvider = { [weak self] in
|
||||
self?.textDocumentProxy.documentContextBeforeInput
|
||||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||||
return self.textDocumentProxy.documentContextBeforeInput
|
||||
}
|
||||
typingSession.followingTextProvider = { [weak self] in
|
||||
self?.textDocumentProxy.documentContextAfterInput
|
||||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil }
|
||||
return self.textDocumentProxy.documentContextAfterInput
|
||||
}
|
||||
typingSession.autocapitalizationModeProvider = { [weak self] in
|
||||
Self.typingAutocapitalizationMode(
|
||||
for: self?.textDocumentProxy.autocapitalizationType ?? .sentences
|
||||
guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return .none }
|
||||
return Self.typingAutocapitalizationMode(
|
||||
for: self.textDocumentProxy.autocapitalizationType ?? .sentences
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1123,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// MARK: - App context
|
||||
|
||||
private func detectAndStoreAppContext() {
|
||||
guard textDocumentProxy.isSecureTextEntry == false else { return }
|
||||
let preceding = textDocumentProxy.documentContextBeforeInput
|
||||
let store = AppGroupStore()
|
||||
let detector = AppContextDetector()
|
||||
@@ -1048,9 +1135,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func captureFieldContext() -> FlowFieldContext {
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||
let preceding = textDocumentProxy.documentContextBeforeInput
|
||||
let following = textDocumentProxy.documentContextAfterInput
|
||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? true
|
||||
let preceding = isSecure ? nil : textDocumentProxy.documentContextBeforeInput
|
||||
let following = isSecure ? nil : textDocumentProxy.documentContextAfterInput
|
||||
let isAvailable = preceding != nil || following != nil
|
||||
let isEmpty = isAvailable && (preceding ?? "").isEmpty && (following ?? "").isEmpty
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
state.localeId = store.localeId
|
||||
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
|
||||
until: protectTranslationUntil
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ struct CandidateExpandGridView: UIViewRepresentable {
|
||||
var candidates: [TypingCandidate]
|
||||
var textColor: UIColor
|
||||
var dividerColor: UIColor
|
||||
var onSelect: (Int) -> Void
|
||||
var onSelect: (TypingCandidate) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSelect: onSelect)
|
||||
@@ -73,10 +73,10 @@ struct CandidateExpandGridView: UIViewRepresentable {
|
||||
var candidates: [TypingCandidate] = []
|
||||
var textColor: UIColor = .label
|
||||
var dividerColor: UIColor = .separator
|
||||
var onSelect: (Int) -> Void
|
||||
var onSelect: (TypingCandidate) -> Void
|
||||
weak var collectionView: UICollectionView?
|
||||
|
||||
init(onSelect: @escaping (Int) -> Void) {
|
||||
init(onSelect: @escaping (TypingCandidate) -> Void) {
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ struct CandidateExpandGridView: UIViewRepresentable {
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.deselectItem(at: indexPath, animated: false)
|
||||
onSelect(indexPath.item)
|
||||
guard candidates.indices.contains(indexPath.item) else { return }
|
||||
onSelect(candidates[indexPath.item])
|
||||
}
|
||||
|
||||
private func isLastRow(index: Int) -> Bool {
|
||||
|
||||
@@ -82,11 +82,6 @@ struct KeyboardSurfaceRoot: View {
|
||||
// lingers (a slow Universal Clipboard read, a system alert).
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.animation(.easeInOut(duration: 0.15), value: state.surface)
|
||||
.onChange(of: state.surface) { _, newSurface in
|
||||
if newSurface != .typing {
|
||||
typing.leaveTypingMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wrappedInsert(_ text: String) {
|
||||
@@ -124,7 +119,9 @@ struct KeyboardSurfaceRoot: View {
|
||||
onClear: state.clearClipboardHistory,
|
||||
onInsert: { state.insertClipboardText($0) },
|
||||
onDelete: { state.deleteClipboardHistoryEntry($0) },
|
||||
pastePermissionHint: nil
|
||||
pastePermissionHint: state.needsClipboardFullAccessHint
|
||||
? ExtL10n.string("keyboard.clipboard.panel.fullAccessHint")
|
||||
: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ struct TypingRootView: View {
|
||||
typingKeySurface
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
.allowsHitTesting(!expanded)
|
||||
.accessibilityHidden(expanded)
|
||||
.allowsHitTesting(!expanded && canAcceptTypingKeys)
|
||||
.accessibilityHidden(expanded || !canAcceptTypingKeys)
|
||||
|
||||
if expanded || candidatePanelMounted {
|
||||
expandedCandidatePanel
|
||||
@@ -141,6 +141,14 @@ struct TypingRootView: View {
|
||||
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
|
||||
}
|
||||
|
||||
private var isChineseEnginePreparing: Bool {
|
||||
typing.language == .chinese && typing.isPreparingEngine
|
||||
}
|
||||
|
||||
private var canAcceptTypingKeys: Bool {
|
||||
typing.language == .english || typing.engineReady
|
||||
}
|
||||
|
||||
private var idleTopBar: some View {
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
@@ -157,6 +165,10 @@ struct TypingRootView: View {
|
||||
|
||||
if let err = typing.lastError {
|
||||
typingErrorLabel(err)
|
||||
} else if isChineseEnginePreparing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.typing.preparingA11y"))
|
||||
}
|
||||
|
||||
// iOS-style editing cluster (undo / redo / copy / cut) — iPad only,
|
||||
@@ -238,16 +250,17 @@ struct TypingRootView: View {
|
||||
.fill(palette.dividerStrong)
|
||||
.frame(width: 1, height: 18)
|
||||
}
|
||||
englishQuickTypeSlot(candidate, index: index)
|
||||
englishQuickTypeSlot(candidate)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
private func englishQuickTypeSlot(_ candidate: TypingCandidate, index: Int) -> some View {
|
||||
private func englishQuickTypeSlot(_ candidate: TypingCandidate) -> some View {
|
||||
let label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text
|
||||
let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular
|
||||
let candidateRevision = typing.candidateRevision
|
||||
return Text(label)
|
||||
.font(.system(size: 17, weight: weight))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -256,20 +269,32 @@ struct TypingRootView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
apply(typing.selectCandidate(at: index))
|
||||
apply(
|
||||
typing.selectCandidate(
|
||||
id: candidate.id,
|
||||
candidateRevision: candidateRevision
|
||||
)
|
||||
)
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(candidate.text)
|
||||
}
|
||||
|
||||
private var chineseCandidateStrip: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
let candidateRevision = typing.candidateRevision
|
||||
return ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if typing.composition.candidates.isEmpty {
|
||||
selectedCandidateLabel(text: typing.composition.preedit)
|
||||
} else if typing.isCandidatePanelExpanded {
|
||||
candidateChip(text: typing.composition.candidates[0].text) {
|
||||
apply(typing.selectCandidate(at: 0))
|
||||
} else if typing.isCandidatePanelExpanded,
|
||||
let candidate = typing.composition.candidates.first {
|
||||
candidateChip(text: candidate.text) {
|
||||
apply(
|
||||
typing.selectCandidate(
|
||||
id: candidate.id,
|
||||
candidateRevision: candidateRevision
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(
|
||||
@@ -282,7 +307,12 @@ struct TypingRootView: View {
|
||||
) { index, candidate in
|
||||
if index == 0 {
|
||||
candidateChip(text: candidate.text) {
|
||||
apply(typing.selectCandidate(at: index))
|
||||
apply(
|
||||
typing.selectCandidate(
|
||||
id: candidate.id,
|
||||
candidateRevision: candidateRevision
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(candidate.text)
|
||||
@@ -292,7 +322,12 @@ struct TypingRootView: View {
|
||||
.frame(height: 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
apply(typing.selectCandidate(at: index))
|
||||
apply(
|
||||
typing.selectCandidate(
|
||||
id: candidate.id,
|
||||
candidateRevision: candidateRevision
|
||||
)
|
||||
)
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(candidate.text)
|
||||
@@ -393,15 +428,21 @@ struct TypingRootView: View {
|
||||
|
||||
/// UIKit-recycled labels — no SwiftUI Button per candidate.
|
||||
private var expandedCandidatePanel: some View {
|
||||
CandidateExpandGridView(
|
||||
let candidateRevision = typing.candidateRevision
|
||||
return CandidateExpandGridView(
|
||||
candidates: typing.composition.candidates,
|
||||
textColor: UIColor(keyTextColor),
|
||||
dividerColor: UIColor(palette.dividerStrong),
|
||||
onSelect: { index in
|
||||
onSelect: { candidate in
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
apply(typing.selectCandidate(at: index))
|
||||
apply(
|
||||
typing.selectCandidate(
|
||||
id: candidate.id,
|
||||
candidateRevision: candidateRevision
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -646,6 +687,7 @@ struct TypingRootView: View {
|
||||
}
|
||||
|
||||
private func commitTypingKey(_ key: TypingKeyHitTarget) {
|
||||
guard canAcceptTypingKeys else { return }
|
||||
switch key.id {
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
|
||||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||||
|
||||
@@ -581,10 +581,12 @@ struct AIKeyboardView: View {
|
||||
if let expectedSkillID = oobeExpectedSkillID {
|
||||
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||
}
|
||||
guard let newest = clipboardHistory.newestEntry,
|
||||
let snapshot = semanticRanking.snapshot,
|
||||
guard let newest = clipboardHistory.newestEntry else { return [] }
|
||||
guard let snapshot = semanticRanking.snapshot,
|
||||
snapshot.entryID == newest.id else {
|
||||
return []
|
||||
return state.clipboardSkillCatalog.filter {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
}
|
||||
return ClipboardSkillSemanticRanker.recommended(
|
||||
skills: state.clipboardSkillCatalog,
|
||||
|
||||
@@ -152,11 +152,10 @@
|
||||
"keyboard.redoA11y" = "Redo";
|
||||
"keyboard.copyA11y" = "Copy";
|
||||
"keyboard.cutA11y" = "Cut";
|
||||
"keyboard.cursorDrag.hint" = "Hold and drag to move the cursor";
|
||||
"keyboard.cursorDrag.centerHint" = "Drag to move the cursor";
|
||||
"keyboard.nextKeyboardA11y" = "Next keyboard";
|
||||
"keyboard.nextKeyboardA11yHint" = "Tap to switch to the next keyboard. Touch and hold to see all keyboards.";
|
||||
"keyboard.typing.setupA11yHint" = "Open OSGKeyboard to finish input method setup.";
|
||||
"keyboard.typing.preparingA11y" = "Preparing Chinese input";
|
||||
|
||||
/* Flow session (keyboard) */
|
||||
"keyboard.flow.sessionInactive" = "Voice session off";
|
||||
@@ -211,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "Open Settings";
|
||||
"keyboard.clipboard.panel.title" = "Clipboard";
|
||||
"keyboard.clipboard.panel.empty" = "No history yet";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "New copies are paused because Allow Full Access is off. Existing history remains available.";
|
||||
"keyboard.clipboard.panel.delete" = "Delete";
|
||||
"keyboard.clipboard.panel.close" = "Close clipboard";
|
||||
"keyboard.clipboard.panel.closeHint" = "Return to the keyboard.";
|
||||
@@ -319,9 +319,9 @@
|
||||
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
|
||||
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
|
||||
"keyboard.ai.skill.reply" = "Same-language reply";
|
||||
"keyboard.ai.skill.reply" = "Reply";
|
||||
"keyboard.ai.skill.playfulReply" = "Playful";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "Same language";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "Reply";
|
||||
"keyboard.ai.skill.summarize" = "Key points";
|
||||
"keyboard.ai.skill.openLink" = "Open";
|
||||
"keyboard.ai.skill.summarizeWebPage" = "Web summary";
|
||||
|
||||
@@ -152,11 +152,10 @@
|
||||
"keyboard.redoA11y" = "重做";
|
||||
"keyboard.copyA11y" = "拷贝";
|
||||
"keyboard.cutA11y" = "剪切";
|
||||
"keyboard.cursorDrag.hint" = "按住并拖动以移动光标";
|
||||
"keyboard.cursorDrag.centerHint" = "拖动移动光标";
|
||||
"keyboard.nextKeyboardA11y" = "切换键盘";
|
||||
"keyboard.nextKeyboardA11yHint" = "轻点切换到下一个键盘;长按查看全部键盘。";
|
||||
"keyboard.typing.setupA11yHint" = "打开 OSGKeyboard 完成输入法初始化。";
|
||||
"keyboard.typing.preparingA11y" = "正在准备中文输入";
|
||||
|
||||
/* Flow session (keyboard) */
|
||||
"keyboard.flow.sessionInactive" = "语音会话未启动";
|
||||
@@ -211,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "去设置开启";
|
||||
"keyboard.clipboard.panel.title" = "剪贴板";
|
||||
"keyboard.clipboard.panel.empty" = "暂无历史记录";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。";
|
||||
"keyboard.clipboard.panel.delete" = "删除";
|
||||
"keyboard.clipboard.panel.close" = "关闭剪贴板";
|
||||
"keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。";
|
||||
@@ -319,9 +319,9 @@
|
||||
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
|
||||
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
|
||||
"keyboard.ai.skill.reply" = "原语言回复";
|
||||
"keyboard.ai.skill.reply" = "回复";
|
||||
"keyboard.ai.skill.playfulReply" = "俏皮回复";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "回复";
|
||||
"keyboard.ai.skill.summarize" = "总结要点";
|
||||
"keyboard.ai.skill.openLink" = "打开";
|
||||
"keyboard.ai.skill.summarizeWebPage" = "总结网页";
|
||||
|
||||
@@ -84,6 +84,69 @@ final class CandidatePanelExpandTests: XCTestCase {
|
||||
XCTAssertFalse(typing.isCandidatePanelExpanded)
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
func testEndingDocumentPresentationClearsChineseStateWithoutTeardown() {
|
||||
let engine = StubRimeEngine()
|
||||
let typing = TypingSessionController(engine: { engine })
|
||||
_ = typing.beginDocumentPresentation()
|
||||
_ = typing.handleKey("n")
|
||||
XCTAssertFalse(typing.composition.candidates.isEmpty)
|
||||
|
||||
typing.endDocumentPresentation()
|
||||
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
XCTAssertEqual(engine.teardownCallCount, 0)
|
||||
XCTAssertEqual(engine.clearCompositionCallCount, 1)
|
||||
}
|
||||
|
||||
func testCandidateSelectionRejectsReplacedSnapshot() {
|
||||
let engine = StubRimeEngine()
|
||||
let typing = TypingSessionController(engine: { engine })
|
||||
_ = typing.handleKey("n")
|
||||
guard let staleCandidateID = typing.composition.candidates.first?.id else {
|
||||
return XCTFail("expected candidate")
|
||||
}
|
||||
let staleRevision = typing.candidateRevision
|
||||
|
||||
_ = typing.handleKey("i")
|
||||
let output = typing.selectCandidate(
|
||||
id: staleCandidateID,
|
||||
candidateRevision: staleRevision
|
||||
)
|
||||
|
||||
XCTAssertEqual(output, .none)
|
||||
XCTAssertFalse(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
func testPrepareContinuesWhenHostBecomesHeavyBeforeTaskStarts() async {
|
||||
let hostState = HostHeavyState()
|
||||
let engine = StubRimeEngine()
|
||||
let prepared = expectation(description: "engine prepared")
|
||||
engine.onPrepare = { prepared.fulfill() }
|
||||
let typing = TypingSessionController(
|
||||
engine: { engine },
|
||||
hostHeavyProvider: { hostState.isHeavy }
|
||||
)
|
||||
|
||||
typing.enterTypingMode()
|
||||
hostState.isHeavy = true
|
||||
await Task.yield()
|
||||
XCTAssertTrue(typing.isPreparingEngine)
|
||||
XCTAssertEqual(engine.prepareCallCount, 0)
|
||||
|
||||
hostState.isHeavy = false
|
||||
await fulfillment(of: [prepared], timeout: 1)
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(engine.prepareCallCount, 1)
|
||||
XCTAssertTrue(typing.engineReady)
|
||||
XCTAssertFalse(typing.isPreparingEngine)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class HostHeavyState {
|
||||
var isHeavy = false
|
||||
}
|
||||
|
||||
// Minimal engine that returns a two-candidate snapshot after any letter.
|
||||
@@ -94,9 +157,17 @@ private final class StubRimeEngine: RimeEngineBridging {
|
||||
var schema: TypingInputSchema = .fullPinyin
|
||||
/// Optional override applied on the next processBackspace.
|
||||
var nextComposition: TypingComposition?
|
||||
var teardownCallCount = 0
|
||||
var clearCompositionCallCount = 0
|
||||
var prepareCallCount = 0
|
||||
var onPrepare: (() -> Void)?
|
||||
|
||||
func prepare() async throws {}
|
||||
func prepare() async throws {
|
||||
prepareCallCount += 1
|
||||
onPrepare?()
|
||||
}
|
||||
func teardown() {
|
||||
teardownCallCount += 1
|
||||
composition = .empty
|
||||
}
|
||||
|
||||
@@ -116,8 +187,8 @@ private final class StubRimeEngine: RimeEngineBridging {
|
||||
composition = TypingComposition(
|
||||
preedit: String(character),
|
||||
candidates: [
|
||||
TypingCandidate(text: "你"),
|
||||
TypingCandidate(text: "泥")
|
||||
TypingCandidate(id: "first", text: "你"),
|
||||
TypingCandidate(id: "second", text: "泥")
|
||||
]
|
||||
)
|
||||
return nil
|
||||
@@ -158,6 +229,7 @@ private final class StubRimeEngine: RimeEngineBridging {
|
||||
}
|
||||
|
||||
func clearComposition() {
|
||||
clearCompositionCallCount += 1
|
||||
composition = .empty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,79 @@ final class EnglishTypingTests: XCTestCase {
|
||||
XCTAssertEqual(typing.composition.preedit, "h")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNewDocumentPresentationRehydratesEnglishContext() {
|
||||
var preceding = "hel"
|
||||
let typing = makeTypingSession()
|
||||
typing.precedingTextProvider = { preceding }
|
||||
typing.followingTextProvider = { "" }
|
||||
typing.enterTypingMode()
|
||||
|
||||
let firstPresentation = typing.beginDocumentPresentation()
|
||||
typing.synchronizeEnglishDocumentContext(
|
||||
caretMoved: true,
|
||||
presentationID: firstPresentation
|
||||
)
|
||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "hel")
|
||||
XCTAssertFalse(typing.composition.candidates.isEmpty)
|
||||
|
||||
typing.endDocumentPresentation()
|
||||
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
|
||||
preceding = "wor"
|
||||
let secondPresentation = typing.beginDocumentPresentation()
|
||||
typing.synchronizeEnglishDocumentContext(
|
||||
caretMoved: true,
|
||||
presentationID: firstPresentation
|
||||
)
|
||||
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
|
||||
typing.synchronizeEnglishDocumentContext(
|
||||
caretMoved: true,
|
||||
presentationID: secondPresentation
|
||||
)
|
||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "wor")
|
||||
XCTAssertFalse(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEndingDocumentPresentationClearsLocalEnglishShadow() {
|
||||
var preceding = ""
|
||||
let typing = makeTypingSession()
|
||||
typing.precedingTextProvider = { preceding }
|
||||
typing.followingTextProvider = { "" }
|
||||
typing.enterTypingMode()
|
||||
_ = typing.beginDocumentPresentation()
|
||||
|
||||
_ = typing.handleKey("h")
|
||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "h")
|
||||
|
||||
typing.endDocumentPresentation()
|
||||
preceding = "new"
|
||||
let nextPresentation = typing.beginDocumentPresentation()
|
||||
typing.synchronizeEnglishDocumentContext(
|
||||
caretMoved: true,
|
||||
presentationID: nextPresentation
|
||||
)
|
||||
|
||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "new")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDisablingSuggestionsImmediatelyClearsEnglishCandidates() {
|
||||
let typing = makeTypingSession()
|
||||
typing.enterTypingMode()
|
||||
_ = typing.handleKey("h")
|
||||
XCTAssertFalse(typing.composition.candidates.isEmpty)
|
||||
|
||||
typing.suggestionsEnabled = false
|
||||
|
||||
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||
}
|
||||
|
||||
func testEnglishTypingHotwordsFilterOutChineseTerms() {
|
||||
var dictionary = PersonalDictionary()
|
||||
_ = dictionary.upsertManual(term: "张三")
|
||||
@@ -541,6 +614,70 @@ final class EnglishTypingTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRepeatedRareEnglishCommitBecomesDictionarySuggestion() {
|
||||
let suite = "english.frequent.term.test.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let frequentStore = FrequentTermStore(defaults: defaults)
|
||||
let typing = makeTypingSession(frequentTermStore: frequentStore)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for _ in 0..<2 {
|
||||
for character in "OpenAI" {
|
||||
_ = typing.handleKey(String(character))
|
||||
}
|
||||
let output = typing.handleSpace()
|
||||
XCTAssertEqual(output.deleteCount, 0)
|
||||
}
|
||||
|
||||
let storedData = defaults.data(forKey: FrequentTermStore.defaultsKey)
|
||||
let storedTerms = storedData.flatMap { try? JSONDecoder().decode([FrequentTerm].self, from: $0) }
|
||||
XCTAssertEqual(storedTerms?.first?.commitCount, 2)
|
||||
let suggestion = frequentStore.suggestions(excludingPersonalTerms: []).first
|
||||
XCTAssertEqual(suggestion?.term, "OpenAI")
|
||||
XCTAssertEqual(suggestion?.commitCount, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCommonEnglishCommitDoesNotBecomeDictionarySuggestion() {
|
||||
let suite = "english.frequent.common.test.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let frequentStore = FrequentTermStore(defaults: defaults)
|
||||
let typing = makeTypingSession(frequentTermStore: frequentStore)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for _ in 0..<2 {
|
||||
for character in "the" {
|
||||
_ = typing.handleKey(String(character))
|
||||
}
|
||||
_ = typing.handleSpace()
|
||||
}
|
||||
|
||||
XCTAssertTrue(frequentStore.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSecureEnglishCommitDoesNotBecomeDictionarySuggestion() {
|
||||
let suite = "english.frequent.secure.test.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let frequentStore = FrequentTermStore(defaults: defaults)
|
||||
let typing = makeTypingSession(frequentTermStore: frequentStore)
|
||||
typing.enterTypingMode()
|
||||
typing.suggestionsEnabled = false
|
||||
|
||||
for _ in 0..<2 {
|
||||
for character in "OpenAI" {
|
||||
_ = typing.handleKey(String(character))
|
||||
}
|
||||
_ = typing.handleSpace()
|
||||
}
|
||||
|
||||
XCTAssertTrue(frequentStore.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
}
|
||||
|
||||
func testQWERTYNeighborsIncludeDiagonals() {
|
||||
let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true)
|
||||
XCTAssertTrue(aroundG.contains("t"))
|
||||
@@ -570,6 +707,7 @@ final class EnglishTypingTests: XCTestCase {
|
||||
@MainActor
|
||||
private func makeTypingSession(
|
||||
learningStore: EnglishLearningStore? = nil,
|
||||
frequentTermStore: FrequentTermStore? = nil,
|
||||
language: TypingInputLanguage = .english
|
||||
) -> TypingSessionController {
|
||||
let store = learningStore ?? EnglishLearningStore(
|
||||
@@ -577,7 +715,10 @@ final class EnglishTypingTests: XCTestCase {
|
||||
)
|
||||
let typing = TypingSessionController(
|
||||
engine: { NoopRimeEngine() },
|
||||
learningStore: store
|
||||
learningStore: store,
|
||||
frequentTermStore: frequentTermStore ?? FrequentTermStore(
|
||||
defaults: UserDefaults(suiteName: "frequent.tests.\(UUID().uuidString)")!
|
||||
)
|
||||
)
|
||||
typing.suggestionsEnabled = true
|
||||
_ = typing.setLanguage(language)
|
||||
|
||||
@@ -36,6 +36,7 @@ final class KeyboardStateTests: XCTestCase {
|
||||
XCTAssertEqual(s.level, 0)
|
||||
XCTAssertFalse(s.onDeviceSupported)
|
||||
XCTAssertFalse(s.undoAvailable)
|
||||
XCTAssertFalse(s.hasFullAccess)
|
||||
}
|
||||
|
||||
func testPhaseTransitionsIdleToRequestingPermissionsAndBack() {
|
||||
@@ -145,6 +146,20 @@ final class KeyboardStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
||||
}
|
||||
|
||||
func testClipboardFullAccessHintTracksCaptureAvailability() {
|
||||
let state = KeyboardState()
|
||||
state.clipboardHistoryEnabled = true
|
||||
|
||||
XCTAssertTrue(state.needsClipboardFullAccessHint)
|
||||
|
||||
state.hasFullAccess = true
|
||||
XCTAssertFalse(state.needsClipboardFullAccessHint)
|
||||
|
||||
state.hasFullAccess = false
|
||||
state.clipboardHistoryEnabled = false
|
||||
XCTAssertFalse(state.needsClipboardFullAccessHint)
|
||||
}
|
||||
|
||||
func testResolvedSkillCopyPublishesWhenIDsStayStable() {
|
||||
let state = KeyboardState()
|
||||
state.enabledClipboardSkillIDs = ["official.rewrite"]
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// MonthlyUsageCalendar.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Current-month dictation heat map. Every date remains visible; the accent
|
||||
// circle's opacity communicates that day's character count relative to the
|
||||
// busiest day in the same month.
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public struct MonthlyUsageCalendar: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public let points: [UsageStatisticsStore.DailyUsagePoint]
|
||||
public let language: AppUILanguage
|
||||
public var compact: Bool
|
||||
public var embedsInCard: Bool
|
||||
|
||||
public init(
|
||||
points: [UsageStatisticsStore.DailyUsagePoint],
|
||||
language: AppUILanguage,
|
||||
compact: Bool = false,
|
||||
embedsInCard: Bool = true
|
||||
) {
|
||||
self.points = points
|
||||
self.language = language
|
||||
self.compact = compact
|
||||
self.embedsInCard = embedsInCard
|
||||
}
|
||||
|
||||
private var displayLocale: Locale {
|
||||
Locale(identifier: language.resolvedLanguageCode())
|
||||
}
|
||||
|
||||
private var displayCalendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = displayLocale
|
||||
return calendar
|
||||
}
|
||||
|
||||
private var total: Int {
|
||||
points.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
private var maximumValue: Int {
|
||||
max(points.map(\.value).max() ?? 0, 1)
|
||||
}
|
||||
|
||||
private var monthTitle: String {
|
||||
guard let date = points.first?.date else { return "" }
|
||||
return date.formatted(
|
||||
.dateTime
|
||||
.year()
|
||||
.month(.wide)
|
||||
.locale(displayLocale)
|
||||
)
|
||||
}
|
||||
|
||||
private var weekdaySymbols: [String] {
|
||||
let symbols = displayCalendar.veryShortStandaloneWeekdaySymbols
|
||||
let startIndex = max(0, min(displayCalendar.firstWeekday - 1, symbols.count - 1))
|
||||
return Array(symbols[startIndex...] + symbols[..<startIndex])
|
||||
}
|
||||
|
||||
private var leadingEmptyCellCount: Int {
|
||||
guard let firstDate = points.first?.date else { return 0 }
|
||||
let weekday = displayCalendar.component(.weekday, from: firstDate)
|
||||
return (weekday - displayCalendar.firstWeekday + 7) % 7
|
||||
}
|
||||
|
||||
private var calendarCells: [UsageStatisticsStore.DailyUsagePoint?] {
|
||||
Array(repeating: nil, count: leadingEmptyCellCount) + points.map(Optional.some)
|
||||
}
|
||||
|
||||
private var cellDiameter: CGFloat {
|
||||
compact ? 28 : 32
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if embedsInCard {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
calendarContent
|
||||
}
|
||||
} else {
|
||||
calendarContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
weekdayHeader
|
||||
dayGrid
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(monthTitle.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(SharedL10n.string("stat.monthChart.caption", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Text(UsageStatisticsStore.formatCount(total, language: language))
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: total)
|
||||
}
|
||||
}
|
||||
|
||||
private var weekdayHeader: some View {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in
|
||||
Text(symbol)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dayGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: compact ? 3 : 5) {
|
||||
ForEach(Array(calendarCells.enumerated()), id: \.offset) { _, point in
|
||||
if let point {
|
||||
dayCell(point)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: cellDiameter, height: cellDiameter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(
|
||||
repeating: GridItem(.flexible(minimum: cellDiameter), spacing: 4),
|
||||
count: 7
|
||||
)
|
||||
}
|
||||
|
||||
private func dayCell(_ point: UsageStatisticsStore.DailyUsagePoint) -> some View {
|
||||
let calendar = displayCalendar
|
||||
let day = calendar.component(.day, from: point.date)
|
||||
let isToday = calendar.isDateInToday(point.date)
|
||||
let isFuture = calendar.startOfDay(for: point.date) > calendar.startOfDay(for: Date())
|
||||
let opacity = fillOpacity(for: point.value, isFuture: isFuture)
|
||||
let usesLightText = opacity >= 0.48
|
||||
|
||||
return Text(day.formatted())
|
||||
.font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded))
|
||||
.foregroundStyle(
|
||||
isFuture
|
||||
? palette.textTertiary.opacity(0.45)
|
||||
: (usesLightText ? palette.textOnAccent : palette.textPrimary)
|
||||
)
|
||||
.frame(width: cellDiameter, height: cellDiameter)
|
||||
.background(palette.accent.opacity(opacity), in: Circle())
|
||||
.overlay {
|
||||
if isToday {
|
||||
Circle()
|
||||
.stroke(palette.accent, lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(
|
||||
Text(point.date.formatted(.dateTime.month().day()))
|
||||
)
|
||||
.accessibilityValue(
|
||||
Text(UsageStatisticsStore.formatCount(point.value, language: language))
|
||||
)
|
||||
}
|
||||
|
||||
private func fillOpacity(for value: Int, isFuture: Bool) -> Double {
|
||||
guard value > 0, !isFuture else { return 0 }
|
||||
let ratio = min(max(Double(value) / Double(maximumValue), 0), 1)
|
||||
return 0.14 + sqrt(ratio) * 0.62
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Monthly usage") {
|
||||
let calendar = Calendar.current
|
||||
let start = calendar.date(
|
||||
from: calendar.dateComponents([.year, .month], from: Date())
|
||||
) ?? Date()
|
||||
let points = (0..<31).compactMap { offset -> UsageStatisticsStore.DailyUsagePoint? in
|
||||
guard let date = calendar.date(byAdding: .day, value: offset, to: start) else {
|
||||
return nil
|
||||
}
|
||||
let value = offset.isMultiple(of: 5) ? offset * 120 : 0
|
||||
return UsageStatisticsStore.DailyUsagePoint(date: date, value: value)
|
||||
}
|
||||
|
||||
ThemedRoot {
|
||||
MonthlyUsageCalendar(
|
||||
points: points,
|
||||
language: .auto
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// UsageStatsCluster.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics.
|
||||
// Cross-platform home / dashboard stats: monthly calendar + cumulative metrics.
|
||||
// Callers observe their store and pass plain values — Shared stays unbound
|
||||
// from platform singletons.
|
||||
//
|
||||
// Optional `header` sits above the 7-day chart inside the same surface card
|
||||
// Optional `header` sits above the monthly calendar inside the same surface card
|
||||
// (iOS Home glass preview field). Mac / plain call sites keep `EmptyView`.
|
||||
|
||||
import SwiftUI
|
||||
@@ -14,15 +14,21 @@ import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public enum UsageStatsClusterLayout: Sendable, Equatable {
|
||||
/// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad).
|
||||
/// Monthly calendar left, 2×2 `UsageStatCard` grid right (Mac / iPad).
|
||||
case split
|
||||
/// Chart above a compact single-card 2×2 grid (iPhone).
|
||||
/// Monthly calendar above a compact single-card 2×2 grid (iPhone).
|
||||
case stacked
|
||||
|
||||
/// 手机端 2×2 统计网格的紧凑固定高度(沿用旧版 HomeStatsCard 数值)。
|
||||
public static let compactGridHeight: CGFloat = 166
|
||||
}
|
||||
|
||||
public enum UsageStatsClusterContent: Sendable, Equatable {
|
||||
case all
|
||||
case calendar
|
||||
case metrics
|
||||
}
|
||||
|
||||
public struct UsageStatsCluster<Header: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@@ -33,9 +39,12 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
public let dictationDurationSeconds: TimeInterval
|
||||
public let translationCharacterCount: Int
|
||||
public let dictionaryTermCount: Int
|
||||
/// 小屏(如 iPhone SE)收紧 stacked 图表高度,把空间让给下方的输入框。
|
||||
/// 小屏(如 iPhone SE)收紧日期圆形尺寸,把空间让给下方内容。
|
||||
public let compact: Bool
|
||||
public let content: UsageStatsClusterContent
|
||||
private let header: Header
|
||||
private let onOpenHistory: (() -> Void)?
|
||||
private let onOpenDictionary: (() -> Void)?
|
||||
|
||||
public init(
|
||||
layout: UsageStatsClusterLayout,
|
||||
@@ -46,6 +55,9 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
translationCharacterCount: Int,
|
||||
dictionaryTermCount: Int,
|
||||
compact: Bool = false,
|
||||
content: UsageStatsClusterContent = .all,
|
||||
onOpenHistory: (() -> Void)? = nil,
|
||||
onOpenDictionary: (() -> Void)? = nil,
|
||||
@ViewBuilder header: () -> Header
|
||||
) {
|
||||
self.layout = layout
|
||||
@@ -56,22 +68,33 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.dictionaryTermCount = dictionaryTermCount
|
||||
self.compact = compact
|
||||
self.content = content
|
||||
self.onOpenHistory = onOpenHistory
|
||||
self.onOpenDictionary = onOpenDictionary
|
||||
self.header = header()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
public var body: some View {
|
||||
switch layout {
|
||||
case .split:
|
||||
splitBody
|
||||
case .stacked:
|
||||
stackedBody
|
||||
switch content {
|
||||
case .all:
|
||||
switch layout {
|
||||
case .split:
|
||||
splitBody
|
||||
case .stacked:
|
||||
stackedBody
|
||||
}
|
||||
case .calendar:
|
||||
chartCard
|
||||
case .metrics:
|
||||
metricsBody
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split (Mac / iPad)
|
||||
|
||||
private var splitBody: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: splitSectionSpacing) {
|
||||
chartCard
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
splitStatGrid
|
||||
@@ -81,126 +104,200 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
}
|
||||
|
||||
private var splitStatGrid: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
UsageStatCard(
|
||||
VStack(spacing: splitItemSpacing) {
|
||||
HStack(spacing: splitItemSpacing) {
|
||||
splitCell(
|
||||
title: SharedL10n.string("stat.words", language: language),
|
||||
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
|
||||
caption: SharedL10n.string("stat.transcribed", language: language),
|
||||
systemImage: "text.alignleft",
|
||||
accent: true
|
||||
accent: true,
|
||||
action: onOpenHistory
|
||||
)
|
||||
UsageStatCard(
|
||||
splitCell(
|
||||
title: SharedL10n.string("stat.dictationTime", language: language),
|
||||
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
|
||||
caption: SharedL10n.string("stat.cumulativeDuration", language: language),
|
||||
systemImage: "waveform"
|
||||
)
|
||||
}
|
||||
HStack(spacing: Spacing.md) {
|
||||
UsageStatCard(
|
||||
HStack(spacing: splitItemSpacing) {
|
||||
splitCell(
|
||||
title: SharedL10n.string("stat.translation", language: language),
|
||||
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
|
||||
caption: SharedL10n.string("stat.cumulativeTranslation", language: language),
|
||||
systemImage: "character.bubble"
|
||||
)
|
||||
UsageStatCard(
|
||||
splitCell(
|
||||
title: SharedL10n.string("stat.dictionary", language: language),
|
||||
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
|
||||
caption: SharedL10n.string("stat.customTerms", language: language),
|
||||
systemImage: "character.book.closed"
|
||||
systemImage: "character.book.closed",
|
||||
action: onOpenDictionary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var metricsBody: some View {
|
||||
switch layout {
|
||||
case .split:
|
||||
splitStatGrid
|
||||
case .stacked:
|
||||
compactStatGrid
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func splitCell(
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
systemImage: String,
|
||||
accent: Bool = false,
|
||||
action: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
if let action {
|
||||
Button(action: action) {
|
||||
UsageStatCard(
|
||||
title: title,
|
||||
value: value,
|
||||
caption: caption,
|
||||
accent: accent
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
disclosureIndicator
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
UsageStatCard(
|
||||
title: title,
|
||||
value: value,
|
||||
caption: caption,
|
||||
systemImage: systemImage,
|
||||
accent: accent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stacked (iPhone)
|
||||
|
||||
private var stackedBody: some View {
|
||||
VStack(spacing: compact ? Spacing.sm : Spacing.md) {
|
||||
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
chartCard
|
||||
compactStatGrid
|
||||
}
|
||||
}
|
||||
|
||||
/// Chart surface; when `header` is present it sits above the bars in the same card.
|
||||
/// Calendar surface; when `header` is present it sits above the dates in the same card.
|
||||
@ViewBuilder
|
||||
private var chartCard: some View {
|
||||
if Header.self == EmptyView.self {
|
||||
SevenDayUsageChart(
|
||||
MonthlyUsageCalendar(
|
||||
points: points,
|
||||
language: language,
|
||||
chartMinHeight: compact ? 72 : 96,
|
||||
expands: layout == .split
|
||||
compact: compact
|
||||
)
|
||||
} else {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
SevenDayUsageChart(
|
||||
MonthlyUsageCalendar(
|
||||
points: points,
|
||||
language: language,
|
||||
chartMinHeight: compact ? 72 : 96,
|
||||
embedsInCard: false,
|
||||
expands: layout == .split
|
||||
compact: compact,
|
||||
embedsInCard: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone-friendly 2×2: value + label only, single card with hairline dividers.
|
||||
/// Phone-friendly 2×2: each metric has its own compact surface card.
|
||||
private var compactStatGrid: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
HStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
compactCell(
|
||||
systemImage: "waveform",
|
||||
systemImage: "waveform.badge.microphone",
|
||||
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
|
||||
label: SharedL10n.string("stat.dictationTime", language: language)
|
||||
)
|
||||
compactDivider
|
||||
compactCell(
|
||||
systemImage: "text.alignleft",
|
||||
systemImage: "text.quote",
|
||||
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
|
||||
label: SharedL10n.string("stat.words", language: language)
|
||||
label: SharedL10n.string("stat.words", language: language),
|
||||
action: onOpenHistory
|
||||
)
|
||||
}
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
HStack(spacing: 0) {
|
||||
.frame(maxHeight: .infinity)
|
||||
HStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
compactCell(
|
||||
systemImage: "character.bubble",
|
||||
systemImage: "translate",
|
||||
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
|
||||
label: SharedL10n.string("stat.translation", language: language)
|
||||
)
|
||||
compactDivider
|
||||
compactCell(
|
||||
systemImage: "character.book.closed",
|
||||
systemImage: "books.vertical",
|
||||
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
|
||||
label: SharedL10n.string("stat.dictionary", language: language)
|
||||
label: SharedL10n.string("stat.dictionary", language: language),
|
||||
action: onOpenDictionary
|
||||
)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
// 锁定紧凑固定高度(对齐旧版 HomeStatsCard 的 166pt),避免格子按内容撑高。
|
||||
.frame(height: UsageStatsClusterLayout.compactGridHeight)
|
||||
.background(palette.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var compactDivider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(width: 0.5)
|
||||
@ViewBuilder
|
||||
private func compactCell(
|
||||
systemImage: String,
|
||||
value: String,
|
||||
label: String,
|
||||
action: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
if let action {
|
||||
Button(action: action) {
|
||||
compactCellContent(
|
||||
systemImage: systemImage,
|
||||
value: value,
|
||||
label: label,
|
||||
showsDisclosure: true
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
compactCellContent(
|
||||
systemImage: systemImage,
|
||||
value: value,
|
||||
label: label,
|
||||
showsDisclosure: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func compactCell(systemImage: String, value: String, label: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
private func compactCellContent(
|
||||
systemImage: String,
|
||||
value: String,
|
||||
label: String,
|
||||
showsDisclosure: Bool
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
return ZStack(alignment: .bottomTrailing) {
|
||||
// Subtle watermark stays fully visible within the card edges.
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary.opacity(0.06))
|
||||
.frame(width: 32, height: 32, alignment: .center)
|
||||
.offset(x: Spacing.xs, y: Spacing.xs)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(value)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -214,14 +311,41 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.top, 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(Spacing.sm)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if showsDisclosure {
|
||||
disclosureIndicator
|
||||
.padding(Spacing.sm)
|
||||
}
|
||||
}
|
||||
.clipShape(shape)
|
||||
}
|
||||
|
||||
private var disclosureIndicator: some View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var splitSectionSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
Spacing.md
|
||||
#else
|
||||
CardLayoutMetrics.sectionSpacing
|
||||
#endif
|
||||
}
|
||||
|
||||
private var splitItemSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
Spacing.md
|
||||
#else
|
||||
CardLayoutMetrics.compactItemSpacing
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +358,10 @@ extension UsageStatsCluster where Header == EmptyView {
|
||||
dictationDurationSeconds: TimeInterval,
|
||||
translationCharacterCount: Int,
|
||||
dictionaryTermCount: Int,
|
||||
compact: Bool = false
|
||||
compact: Bool = false,
|
||||
content: UsageStatsClusterContent = .all,
|
||||
onOpenHistory: (() -> Void)? = nil,
|
||||
onOpenDictionary: (() -> Void)? = nil
|
||||
) {
|
||||
self.init(
|
||||
layout: layout,
|
||||
@@ -245,6 +372,9 @@ extension UsageStatsCluster where Header == EmptyView {
|
||||
translationCharacterCount: translationCharacterCount,
|
||||
dictionaryTermCount: dictionaryTermCount,
|
||||
compact: compact,
|
||||
content: content,
|
||||
onOpenHistory: onOpenHistory,
|
||||
onOpenDictionary: onOpenDictionary,
|
||||
header: { EmptyView() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Main-app-only storage for OSG account sessions and App Attest key state.
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
@@ -42,6 +43,11 @@ public actor HostPrivateAccountKeychain:
|
||||
AccountSessionVault,
|
||||
AppAttestKeyStateStoring,
|
||||
OOBEInstallationIDStoring {
|
||||
private static let logger = Logger(
|
||||
subsystem: HostPrivateAccountKeychainDescriptor.hostBundleIdentifier,
|
||||
category: "account"
|
||||
)
|
||||
|
||||
private enum Account {
|
||||
static let session = "account.session"
|
||||
static let appAttestKeyState = "integrity.app-attest-key-state"
|
||||
@@ -59,7 +65,16 @@ public actor HostPrivateAccountKeychain:
|
||||
}
|
||||
|
||||
public func loadSession() async throws -> AccountSession? {
|
||||
try read(AccountSession.self, account: Account.session)
|
||||
do {
|
||||
let session = try read(AccountSession.self, account: Account.session)
|
||||
Self.logger.info(
|
||||
"session keychain restore status=\(session == nil ? "not-found" : "found", privacy: .public)"
|
||||
)
|
||||
return session
|
||||
} catch {
|
||||
Self.logger.error("session keychain restore status=unavailable")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func saveSession(_ session: AccountSession) async throws {
|
||||
@@ -111,6 +126,9 @@ public actor HostPrivateAccountKeychain:
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
default:
|
||||
Self.logger.error(
|
||||
"keychain read failed account=\(account, privacy: .public) status=\(status, privacy: .public)"
|
||||
)
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@ struct DashboardView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Stats (7-day chart + cumulative grid — shared cluster)
|
||||
// MARK: - Stats (monthly calendar + cumulative grid — shared cluster)
|
||||
|
||||
private var statCluster: some View {
|
||||
UsageStatsCluster(
|
||||
layout: .split,
|
||||
language: lang,
|
||||
points: stats.last7Days,
|
||||
points: stats.currentMonth,
|
||||
dictationCharacterCount: stats.dictationCharacterCount,
|
||||
dictationDurationSeconds: stats.dictationDurationSeconds,
|
||||
translationCharacterCount: stats.translationCharacterCount,
|
||||
@@ -225,10 +225,6 @@ struct BottomDictationBar: View {
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
|
||||
@@ -133,7 +133,7 @@ private struct MacGlassSurface<S: Shape>: ViewModifier {
|
||||
// Flat, shadowless surface fill. We deliberately avoid `glassEffect`
|
||||
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every
|
||||
// card, which reads as visual noise for content containers. Hierarchy
|
||||
// is carried by the surface colour + hairline border instead.
|
||||
// is carried by the semantic surface colour instead.
|
||||
content
|
||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
||||
}
|
||||
@@ -361,10 +361,6 @@ struct MacSettingsIconButton: View {
|
||||
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
|
||||
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
@@ -381,9 +377,6 @@ struct MacSettingsToolButton: View {
|
||||
var fill: Color?
|
||||
/// Text color. Defaults to primary text (tertiary when disabled).
|
||||
var foreground: Color?
|
||||
/// Hairline divider border — drawn for the neutral variant, hidden for
|
||||
/// colored fills (delete / download) so the fill reads as the button.
|
||||
var showsBorder: Bool = true
|
||||
var disabled: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
@@ -396,11 +389,6 @@ struct MacSettingsToolButton: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: 34)
|
||||
.background(fill ?? palette.surfaceElevated, in: shape)
|
||||
.overlay {
|
||||
if showsBorder {
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
@@ -485,7 +473,6 @@ extension MacPageHeader where Trailing == EmptyView {
|
||||
|
||||
/// Elevated surface used for stat tiles and the dictation canvas.
|
||||
struct MacCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
var cornerRadius: CGFloat = Radius.medium
|
||||
@ViewBuilder var content: () -> Content
|
||||
@@ -496,10 +483,6 @@ struct MacCard<Content: View>: View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.macGlassSurface(in: shape, fillOpacity: 1)
|
||||
.overlay(
|
||||
shape
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ struct MacDictionaryView: View {
|
||||
@State private var showEntryEditor = false
|
||||
@State private var generatingAliasEntryIDs: Set<UUID> = []
|
||||
|
||||
private let aliasGenerator = DictionaryAliasGenerator()
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var entries: [PersonalDictionary.Entry] {
|
||||
@@ -235,29 +233,22 @@ struct MacDictionaryView: View {
|
||||
|
||||
private func saveManualEntry(term: String) {
|
||||
let store = AppGroupStore(defaults: viewModel.defaults)
|
||||
var dictionary = store.personalDictionary
|
||||
guard let saved = dictionary.upsertManual(term: term) else { return }
|
||||
dictionary.version += 1
|
||||
store.setPersonalDictionary(dictionary)
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
cloudPush: { dictionary in
|
||||
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary)
|
||||
}
|
||||
)
|
||||
guard let saved = service.saveEntry(term: term, source: .manual) else { return }
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
generatingAliasEntryIDs.insert(saved.id)
|
||||
if saved.shouldGenerateAliases {
|
||||
generatingAliasEntryIDs.insert(saved.entryID)
|
||||
}
|
||||
|
||||
Task {
|
||||
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary)
|
||||
let aliases = await aliasGenerator.generateAliases(for: saved.term)
|
||||
|
||||
generatingAliasEntryIDs.remove(saved.id)
|
||||
guard !aliases.isEmpty else { return }
|
||||
|
||||
var latest = store.personalDictionary
|
||||
guard latest.entries.contains(where: {
|
||||
$0.id == saved.id && $0.term == saved.term
|
||||
}) else { return }
|
||||
latest.updateAliases(for: saved.id, aliases: aliases)
|
||||
latest.version += 1
|
||||
store.setPersonalDictionary(latest)
|
||||
await service.finishSaving(saved)
|
||||
generatingAliasEntryIDs.remove(saved.entryID)
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,8 +377,7 @@ struct MacLocalASRModelSettingsView: View {
|
||||
MacSettingsToolButton(
|
||||
title: MacL10n.string("mac.localASR.delete", language: lang),
|
||||
fill: palette.danger.opacity(0.15),
|
||||
foreground: palette.danger,
|
||||
showsBorder: false
|
||||
foreground: palette.danger
|
||||
) {
|
||||
modelVM.deleteModel(model)
|
||||
}
|
||||
@@ -386,8 +385,7 @@ struct MacLocalASRModelSettingsView: View {
|
||||
MacSettingsToolButton(
|
||||
title: MacL10n.string("mac.localASR.download", language: lang),
|
||||
fill: palette.accent,
|
||||
foreground: .white,
|
||||
showsBorder: false
|
||||
foreground: .white
|
||||
) {
|
||||
modelVM.installModel(model)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,6 @@ struct MacOnboardingView: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
|
||||
@@ -368,7 +367,6 @@ struct MacOnboardingView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var enginePicker: some View {
|
||||
@@ -423,7 +421,6 @@ struct MacOnboardingView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface))
|
||||
.overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -461,7 +458,6 @@ struct MacOnboardingView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private var localModelPanel: some View {
|
||||
@@ -513,7 +509,6 @@ struct MacOnboardingView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
// MARK: Progress dots
|
||||
@@ -577,8 +572,8 @@ struct MacOnboardingView: View {
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(minHeight: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -403,12 +403,6 @@ private struct MacPolishStyleCard: View {
|
||||
isSelected ? palette.accentMuted : palette.surface,
|
||||
in: shape
|
||||
)
|
||||
.overlay(
|
||||
shape.stroke(
|
||||
isSelected ? palette.accent : hoverBorder,
|
||||
lineWidth: isSelected ? 1.5 : 0.5
|
||||
)
|
||||
)
|
||||
.clipShape(shape)
|
||||
.scaleEffect(isHovering ? 1.01 : 1)
|
||||
.animation(Motion.quick, value: isHovering)
|
||||
@@ -421,10 +415,6 @@ private struct MacPolishStyleCard: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hoverBorder: Color {
|
||||
isHovering ? palette.dividerStrong : palette.divider
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only prompt viewer for built-in styles (mirrors iOS).
|
||||
|
||||
@@ -13,7 +13,7 @@ public struct CardPageContent<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
public init(
|
||||
spacing: CGFloat = Spacing.md,
|
||||
spacing: CGFloat = CardLayoutMetrics.sectionSpacing,
|
||||
topPadding: CGFloat = Spacing.md,
|
||||
bottomPadding: CGFloat = Spacing.md,
|
||||
@ViewBuilder content: () -> Content
|
||||
@@ -95,12 +95,8 @@ public struct SurfaceCardModifier: ViewModifier {
|
||||
in: shape
|
||||
)
|
||||
// Clip child backgrounds as well as the card surface. Without
|
||||
// this, a full-width child can visually square off a corner
|
||||
// even though the shared background and border use Radius.xl.
|
||||
// this, a full-width child can visually square off a corner.
|
||||
.clipShape(shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
@@ -158,6 +158,14 @@ public enum Spacing {
|
||||
public static let hero: CGFloat = 48
|
||||
}
|
||||
|
||||
/// Semantic spacing for sibling card surfaces.
|
||||
public enum CardLayoutMetrics {
|
||||
/// Vertical gap between page-level cards or card sections.
|
||||
public static let sectionSpacing: CGFloat = 18
|
||||
/// Horizontal and vertical gap between compact cards or list-item cards.
|
||||
public static let compactItemSpacing: CGFloat = Spacing.xs
|
||||
}
|
||||
|
||||
// MARK: - Corner radius scale
|
||||
|
||||
public enum Radius {
|
||||
@@ -227,10 +235,6 @@ private struct CardSurfaceModifier: ViewModifier {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,10 +271,6 @@ private struct SecondaryButtonModifier: ViewModifier {
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Flat semantic surface used by home / dashboard stats on every platform.
|
||||
// Deliberately shadowless — hierarchy comes from fill + hairline border.
|
||||
// Deliberately shadowless — hierarchy comes from the semantic surface fill.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -30,8 +30,5 @@ public struct UsageSurfaceCard<Content: View>: View {
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.clipShape(shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var uiLanguage: AppUILanguage
|
||||
public var translationTargetLocaleId: String
|
||||
public var handednessPreference: HandednessPreference
|
||||
/// Decode-only compatibility field for cursor drag pads removed in 2.0.0.
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
/// Typing-grid haptic strength (off / light / strong).
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity
|
||||
@@ -300,7 +301,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
),
|
||||
cursorDragNavigationEnabled: {
|
||||
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
@@ -594,17 +595,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||
dictionary.entries[index].source = .manual
|
||||
}
|
||||
dictionary.version += 1
|
||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||
defaults.set(migrated, forKey: Keys.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
} catch {
|
||||
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .empty
|
||||
|
||||
@@ -39,12 +39,11 @@ public enum KeyboardChromeLayout {
|
||||
public static let horizontalInset: CGFloat = 8
|
||||
/// Voice-surface content column cap.
|
||||
///
|
||||
/// The voice surface is a sparse cluster — two cursor-drag pads flanking a
|
||||
/// fixed 121 pt mic — over a transparent background, so filling an iPad's
|
||||
/// width buys no visual width; it only parks delete/return at the screen
|
||||
/// edges and turns each drag pad into a ~450 pt runway. The typing surface
|
||||
/// has the opposite need (a key grid must fill the width to match the
|
||||
/// system keyboard), which is why it no longer shares this constant.
|
||||
/// The voice surface is a sparse control cluster over a transparent
|
||||
/// background, so filling an iPad's width buys no visual width and only
|
||||
/// parks delete/return at the screen edges. The typing surface has the
|
||||
/// opposite need (a key grid must fill the width to match the system
|
||||
/// keyboard), which is why it no longer shares this constant.
|
||||
public static let voiceContentMaxWidth: CGFloat = 700
|
||||
|
||||
/// Width at or above which the typing surface switches to wide-iPad
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Sources (mutually exclusive per entry):
|
||||
// - `.manual` user typed it in by hand
|
||||
// - `.history` legacy auto-learned entries (migrated to `.manual`)
|
||||
// - `.history` user-confirmed recommendations from typing history
|
||||
// - `.contacts` imported from the iOS Contacts framework
|
||||
// - `.recentEdit` extracted from edits the user made to a
|
||||
// polished transcript before sending
|
||||
@@ -274,12 +274,28 @@ extension PersonalDictionary {
|
||||
return entries.first { $0.term.lowercased() == key }
|
||||
}
|
||||
|
||||
/// Insert or update a manual entry. Returns the saved entry.
|
||||
/// Insert or update a manual entry. Kept as a convenience for existing callers.
|
||||
@discardableResult
|
||||
public mutating func upsertManual(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
upsert(
|
||||
term: term,
|
||||
existingID: existingID,
|
||||
source: .manual,
|
||||
regenerateAliases: regenerateAliases
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert or update an entry while preserving its user-visible origin.
|
||||
@discardableResult
|
||||
public mutating func upsert(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
source: Entry.Source,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -292,7 +308,7 @@ extension PersonalDictionary {
|
||||
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.source = source
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
@@ -307,7 +323,7 @@ extension PersonalDictionary {
|
||||
var entry = entries[idx]
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.source = source
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
@@ -318,7 +334,7 @@ extension PersonalDictionary {
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual,
|
||||
source: source,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
|
||||
configuration.localeId = localeId
|
||||
persistConfiguration()
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + user's LLM polish (requires user API key).
|
||||
@@ -220,16 +220,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return }
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing-grid haptic strength (off / light / strong). Default is light.
|
||||
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity {
|
||||
didSet {
|
||||
@@ -445,7 +435,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
uiLanguage = configuration.uiLanguage
|
||||
translationTargetLocaleId = configuration.translationTargetLocaleId
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = configuration.keyboardHapticIntensity
|
||||
polishIntensity = configuration.polishIntensity
|
||||
aiResponseLength = configuration.aiResponseLength
|
||||
@@ -551,7 +540,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
uiLanguage = fresh.uiLanguage
|
||||
translationTargetLocaleId = fresh.translationTargetLocaleId
|
||||
handednessPreference = fresh.handednessPreference
|
||||
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = fresh.keyboardHapticIntensity
|
||||
polishIntensity = fresh.polishIntensity
|
||||
aiResponseLength = fresh.aiResponseLength
|
||||
|
||||
@@ -70,6 +70,18 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
mergedCatalog.filter { !layout.isEnabled($0.id) }
|
||||
}
|
||||
|
||||
public var skillManagementEnabledSkills: [AIClipboardSkill] {
|
||||
enabledSkills.filter {
|
||||
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
public var skillManagementAvailableSkills: [AIClipboardSkill] {
|
||||
availableSkills.filter {
|
||||
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
public func userSkill(id: String) -> AIUserSkill? {
|
||||
userCatalog.skill(id: id)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
empathyReplyID,
|
||||
businessReplyID
|
||||
]
|
||||
/// Contextual system actions remain available to semantic ranking but are
|
||||
/// not user-managed entries in the host app's Skills catalog.
|
||||
public static let hiddenFromSkillManagementIDs: Set<String> = [
|
||||
replyID,
|
||||
declineInvitationID,
|
||||
empathyReplyID,
|
||||
acceptInvitationID,
|
||||
callPhoneID,
|
||||
createContactID,
|
||||
clarifyRequestID
|
||||
]
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
|
||||
@@ -86,7 +86,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
public var translationEnabled: Bool { configuration.translationEnabled }
|
||||
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
|
||||
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
|
||||
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
|
||||
@@ -140,6 +139,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
|
||||
public func setLocaleId(_ id: String) {
|
||||
mutateConfiguration { $0.localeId = id }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setEngineMode(_ mode: String) {
|
||||
@@ -180,11 +180,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) {
|
||||
mutateConfiguration { $0.keyboardHapticIntensity = intensity }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
@@ -438,6 +433,19 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
// v6 persists canonical IDs for the consolidated reply, summary,
|
||||
// and clarification skills. `sanitized` performs the mapping.
|
||||
if storedMigrationVersion < 7 {
|
||||
// Product baseline: install these text skills once for existing
|
||||
// layouts. Skill-management visibility is a separate policy.
|
||||
additionIDs.formUnion([
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
])
|
||||
}
|
||||
let additions = catalog.map(\.id).filter {
|
||||
additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
|
||||
}
|
||||
@@ -463,7 +471,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 6
|
||||
private static let currentAgentSkillDefaultsMigrationVersion = 7
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
@@ -547,6 +555,28 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Applies a read-modify-write operation to the latest persisted dictionary.
|
||||
///
|
||||
/// Returning `nil` cancels the write. Callers must not mutate the dictionary
|
||||
/// before returning `nil`.
|
||||
@discardableResult
|
||||
public func updatePersonalDictionary<Result>(
|
||||
_ transform: (inout PersonalDictionary) -> Result?
|
||||
) -> (dictionary: PersonalDictionary, result: Result)? {
|
||||
var output: (dictionary: PersonalDictionary, result: Result)?
|
||||
mutateConfiguration { config in
|
||||
guard let result = transform(&config.personalDictionary) else { return }
|
||||
output = (config.personalDictionary, result)
|
||||
}
|
||||
guard let output else { return nil }
|
||||
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
#if os(iOS)
|
||||
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
|
||||
#endif
|
||||
return output
|
||||
}
|
||||
|
||||
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
|
||||
mutateConfiguration { config in
|
||||
config.personalDictionary.entries.removeAll { $0.id == id }
|
||||
|
||||
@@ -31,8 +31,8 @@ public enum ClipboardSkillSemanticRanker {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns only skills supported by current semantic evidence. No matching
|
||||
/// label produces no recommendation instead of a fixed fallback row.
|
||||
/// Returns semantically relevant skills and always keeps the generic Reply
|
||||
/// action available as a safe fallback for accepted clipboard text.
|
||||
public static func recommended(
|
||||
skills: [AIClipboardSkill],
|
||||
sourceText: String,
|
||||
@@ -42,22 +42,42 @@ public enum ClipboardSkillSemanticRanker {
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
guard limit > 0 else { return [] }
|
||||
let scores = relevanceScores(
|
||||
var scores = relevanceScores(
|
||||
sourceText: sourceText,
|
||||
analysis: analysis,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID }
|
||||
if genericReply != nil {
|
||||
scores[AIClipboardSkillCatalog.replyID, default: 0] = max(
|
||||
1,
|
||||
scores[AIClipboardSkillCatalog.replyID, default: 0]
|
||||
)
|
||||
}
|
||||
let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
|
||||
var selected: [AIClipboardSkill] = []
|
||||
var replyCount = 0
|
||||
var specializedReplyCount = 0
|
||||
for skill in sorted(relevant, scores: scores) {
|
||||
guard selected.count < limit else { break }
|
||||
let mustReserveGenericReply = genericReply != nil
|
||||
&& !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID })
|
||||
&& skill.id != AIClipboardSkillCatalog.replyID
|
||||
let availableCount = limit - (mustReserveGenericReply ? 1 : 0)
|
||||
guard selected.count < availableCount else { continue }
|
||||
if skill.id == AIClipboardSkillCatalog.replyID {
|
||||
selected.append(skill)
|
||||
continue
|
||||
}
|
||||
if skill.supportsReplyStyle {
|
||||
guard replyCount < maximumReplyRecommendations else { continue }
|
||||
replyCount += 1
|
||||
guard specializedReplyCount < maximumReplyRecommendations else { continue }
|
||||
specializedReplyCount += 1
|
||||
}
|
||||
selected.append(skill)
|
||||
}
|
||||
if let genericReply,
|
||||
selected.count < limit,
|
||||
!selected.contains(where: { $0.id == genericReply.id }) {
|
||||
selected.append(genericReply)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
||||
/// Opt-in clipboard history capture (mirrored from App Group).
|
||||
@Published public var clipboardHistoryEnabled: Bool = false
|
||||
/// Current extension permission state. Clipboard history remains readable
|
||||
/// without Full Access, but new system pasteboard entries cannot be captured.
|
||||
@Published public var hasFullAccess: Bool = false
|
||||
public var needsClipboardFullAccessHint: Bool {
|
||||
clipboardHistoryEnabled && !hasFullAccess
|
||||
}
|
||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||
@Published public var clipboardCandidateBarEnabled: Bool = false
|
||||
/// Skills-tab order for clipboard chips. Empty → hint carousel.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// PersonalDictionaryEntryService.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Keeps every user-confirmed term on the same save, alias-generation,
|
||||
// and cloud-sync path, regardless of which UI initiated the change.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class PersonalDictionaryEntryService {
|
||||
public typealias AliasGeneration = @MainActor (String) async -> [String]
|
||||
public typealias CloudPush = @MainActor (PersonalDictionary) async -> Void
|
||||
|
||||
public struct SavedEntry: Sendable {
|
||||
public let entryID: UUID
|
||||
public let term: String
|
||||
public let source: PersonalDictionary.Entry.Source
|
||||
public let dictionary: PersonalDictionary
|
||||
public let shouldGenerateAliases: Bool
|
||||
}
|
||||
|
||||
private let store: AppGroupStore
|
||||
private let generateAliases: AliasGeneration
|
||||
private let pushToCloud: CloudPush
|
||||
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
aliasGeneration: AliasGeneration? = nil,
|
||||
cloudPush: CloudPush? = nil
|
||||
) {
|
||||
self.store = store
|
||||
if let aliasGeneration {
|
||||
generateAliases = aliasGeneration
|
||||
} else {
|
||||
let generator = DictionaryAliasGenerator()
|
||||
generateAliases = { term in
|
||||
await generator.generateAliases(for: term)
|
||||
}
|
||||
}
|
||||
pushToCloud = cloudPush ?? { dictionary in
|
||||
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves immediately so alias generation never blocks the user's action.
|
||||
public func saveEntry(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
source: PersonalDictionary.Entry.Source,
|
||||
minimumUsageCount: Int? = nil
|
||||
) -> SavedEntry? {
|
||||
var shouldGenerateAliases = false
|
||||
guard let mutation = store.updatePersonalDictionary({ dictionary -> PersonalDictionary.Entry? in
|
||||
let previousTerm = existingID.flatMap { id in
|
||||
dictionary.entries.first(where: { $0.id == id })?.term
|
||||
}
|
||||
let termChanged = previousTerm.map {
|
||||
$0.caseInsensitiveCompare(term) != .orderedSame
|
||||
} ?? true
|
||||
|
||||
guard let saved = dictionary.upsert(
|
||||
term: term,
|
||||
existingID: existingID,
|
||||
source: source
|
||||
),
|
||||
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
|
||||
return nil
|
||||
}
|
||||
if let minimumUsageCount {
|
||||
dictionary.entries[index].usageCount = max(
|
||||
dictionary.entries[index].usageCount,
|
||||
minimumUsageCount
|
||||
)
|
||||
}
|
||||
dictionary.version += 1
|
||||
shouldGenerateAliases = existingID == nil || termChanged
|
||||
return dictionary.entries[index]
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SavedEntry(
|
||||
entryID: mutation.result.id,
|
||||
term: mutation.result.term,
|
||||
source: mutation.result.source,
|
||||
dictionary: mutation.dictionary,
|
||||
shouldGenerateAliases: shouldGenerateAliases
|
||||
)
|
||||
}
|
||||
|
||||
/// Finishes the asynchronous work against the latest dictionary snapshot.
|
||||
///
|
||||
/// The identity and canonical term checks prevent a late LLM response from
|
||||
/// restoring a deleted entry or attaching aliases after the term changed.
|
||||
@discardableResult
|
||||
public func finishSaving(_ saved: SavedEntry) async -> PersonalDictionary {
|
||||
await pushToCloud(store.personalDictionary)
|
||||
guard saved.shouldGenerateAliases else { return store.personalDictionary }
|
||||
|
||||
let aliases = await generateAliases(saved.term)
|
||||
guard !aliases.isEmpty else { return store.personalDictionary }
|
||||
|
||||
guard let mutation = store.updatePersonalDictionary({ dictionary -> UUID? in
|
||||
guard let current = dictionary.entries.first(where: { $0.id == saved.entryID }),
|
||||
current.term == saved.term,
|
||||
current.source == saved.source else {
|
||||
return nil
|
||||
}
|
||||
dictionary.updateAliases(for: saved.entryID, aliases: aliases)
|
||||
dictionary.version += 1
|
||||
return saved.entryID
|
||||
}) else {
|
||||
return store.personalDictionary
|
||||
}
|
||||
|
||||
await pushToCloud(mutation.dictionary)
|
||||
// Cloud sync may merge remote entries into local storage while pushing.
|
||||
return store.personalDictionary
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public enum PolishStyleLearningCorpusBuilder {
|
||||
public static let requiredEffectiveCharacterCount = 5_000
|
||||
public static let requiredEffectiveCharacterCount = 2_500
|
||||
|
||||
public static func build(
|
||||
from entries: [SpeechHistoryEntry]
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SettingsDeepLink: String, Sendable {
|
||||
public enum SettingsDeepLink: String, Sendable, Equatable {
|
||||
case aiService
|
||||
case speechRecognition
|
||||
case textPolish
|
||||
case clipboard
|
||||
|
||||
private static let pendingKey = "settings.pendingDeepLink"
|
||||
|
||||
@@ -19,11 +19,11 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
dictationCharacterCount + translationCharacterCount + aiCharacterCount
|
||||
}
|
||||
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
|
||||
/// the home page's 7-day chart.
|
||||
/// the home page and dashboard usage visualizations.
|
||||
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
|
||||
|
||||
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
|
||||
/// chart window so a device that syncs in late still contributes recent days.
|
||||
/// How many days of daily buckets to retain on disk. This preserves several
|
||||
/// complete monthly views when a device syncs late.
|
||||
private static let dailyRetentionDays = 90
|
||||
|
||||
public let defaults: UserDefaults
|
||||
@@ -121,13 +121,18 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
|
||||
}
|
||||
|
||||
// MARK: - 7-day chart data
|
||||
// MARK: - Daily chart data
|
||||
|
||||
/// One day's dictation total for the home page chart.
|
||||
/// One day's dictation total for home and dashboard visualizations.
|
||||
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
|
||||
public let date: Date
|
||||
public let value: Int
|
||||
public var id: Date { date }
|
||||
|
||||
public init(date: Date, value: Int) {
|
||||
self.date = date
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing 7 local days (oldest → newest), zero-filled for days with no
|
||||
@@ -151,6 +156,34 @@ public final class UsageStatisticsStore: ObservableObject {
|
||||
return points
|
||||
}
|
||||
|
||||
/// Every local-calendar day in the current month, including future days.
|
||||
/// Missing buckets are zero-filled so the UI can always render a complete
|
||||
/// calendar instead of changing shape as usage accumulates.
|
||||
public var currentMonth: [DailyUsagePoint] {
|
||||
Self.currentMonth(from: dailyDictationCharacters)
|
||||
}
|
||||
|
||||
public static func currentMonth(
|
||||
from daily: [String: Int],
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> [DailyUsagePoint] {
|
||||
let monthComponents = calendar.dateComponents([.year, .month], from: now)
|
||||
guard let monthStart = calendar.date(from: monthComponents),
|
||||
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
|
||||
else { return [] }
|
||||
|
||||
return dayRange.compactMap { day in
|
||||
guard let date = calendar.date(
|
||||
byAdding: .day,
|
||||
value: day - 1,
|
||||
to: monthStart
|
||||
) else { return nil }
|
||||
let key = UsageStatisticsDayKey.key(for: date, calendar: calendar)
|
||||
return DailyUsagePoint(date: date, value: daily[key] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time cleanup: the pre-fix code overwrote a device slice with the
|
||||
/// cross-device *sum*, so every reload/record re-added the other devices'
|
||||
/// totals and one slice ballooned to ~8× the true usage. We can't recover
|
||||
|
||||
@@ -60,6 +60,9 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
public static let slotCount = 3
|
||||
/// In-vocabulary words only yield to a much more common transposition / neighbor.
|
||||
public static let inVocabularyFrequencyGap = 250
|
||||
/// Rare words benefit from explicit personal-dictionary treatment; common
|
||||
/// words are already covered well by the bundled lexicon.
|
||||
public static let personalTermFrequencyCeiling = 680
|
||||
|
||||
private let lexicon: EnglishLexicon
|
||||
|
||||
@@ -150,6 +153,36 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether repeated use of this word would add value to PersonalDictionary.
|
||||
/// Existing personal/system terms and common lexicon words need no prompt.
|
||||
func isPersonalTermCandidate(
|
||||
_ word: String,
|
||||
personalTerms: [String],
|
||||
systemWords: [String]
|
||||
) -> Bool {
|
||||
guard FrequentTermStore.normalizedCandidate(from: word) != nil,
|
||||
PersonalDictionary.isEnglishTypingHotword(word) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let lower = word.lowercased()
|
||||
guard !personalTerms.contains(where: { $0.lowercased() == lower }),
|
||||
!systemWords.contains(where: { $0.lowercased() == lower }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Preserve intentional product spelling even when the base word is
|
||||
// common enough to exist in the bundled lexicon (OpenAI, iOS26, GPT-5).
|
||||
let hasDistinctiveShape = word.dropFirst().contains(where: \.isUppercase)
|
||||
|| word.contains(where: \.isNumber)
|
||||
|| word.contains(where: { "-.+#".contains($0) })
|
||||
if hasDistinctiveShape {
|
||||
return true
|
||||
}
|
||||
|
||||
return lexicon.frequency(of: lower) <= Self.personalTermFrequencyCeiling
|
||||
}
|
||||
|
||||
// MARK: - Board
|
||||
|
||||
private struct Board {
|
||||
@@ -304,9 +337,11 @@ public struct EnglishSuggestionEngine: Sendable {
|
||||
private func isProtectedToken(_ typed: String) -> Bool {
|
||||
if typed.count <= 2 { return true }
|
||||
if typed.allSatisfy(\.isUppercase) { return true }
|
||||
if typed.dropFirst().contains(where: \.isUppercase) { return true }
|
||||
if typed.contains(where: \.isNumber) { return true }
|
||||
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
||||
if typed.contains("-") || typed.contains("_") { return true }
|
||||
if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+44
-21
@@ -1,13 +1,14 @@
|
||||
// RimeFrequentTermStore.swift
|
||||
// FrequentTermStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb
|
||||
// while the keyboard owns it can race the engine, so the extension records
|
||||
// eligible committed terms here and the host app ranks them for suggestions.
|
||||
// Small App Group sidecar for committed typing terms. Reading librime's
|
||||
// LevelDB userdb while the keyboard owns it can race the engine, so the
|
||||
// extension records eligible Chinese and English terms here and the host app
|
||||
// ranks them for personal-dictionary suggestions.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
public struct FrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
public var id: String { term.lowercased() }
|
||||
|
||||
public let term: String
|
||||
@@ -28,10 +29,11 @@ public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures repeated Rime candidate commits without writing to the curated
|
||||
/// Captures repeated eligible commits without writing to the curated
|
||||
/// PersonalDictionary until the user explicitly confirms a suggestion.
|
||||
public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "rimeTyping.frequentTerms.v1"
|
||||
public final class FrequentTermStore: @unchecked Sendable {
|
||||
public static let defaultsKey = "typing.frequentTerms.v2"
|
||||
public static let legacyRimeDefaultsKey = "rimeTyping.frequentTerms.v1"
|
||||
public static let minimumSuggestionCount = 2
|
||||
public static let maximumTrackedTerms = 256
|
||||
|
||||
@@ -52,7 +54,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
let key = term.lowercased()
|
||||
if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) {
|
||||
let existing = terms[index]
|
||||
terms[index] = RimeFrequentTerm(
|
||||
terms[index] = FrequentTerm(
|
||||
term: term,
|
||||
commitCount: min(10_000, existing.commitCount + 1),
|
||||
firstSeenAt: existing.firstSeenAt,
|
||||
@@ -60,7 +62,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
)
|
||||
} else {
|
||||
terms.append(
|
||||
RimeFrequentTerm(
|
||||
FrequentTerm(
|
||||
term: term,
|
||||
commitCount: 1,
|
||||
firstSeenAt: date,
|
||||
@@ -78,11 +80,11 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
saveLocked(Array(terms.prefix(Self.maximumTrackedTerms)))
|
||||
}
|
||||
|
||||
/// Repeated, recent Rime commits that are not already curated.
|
||||
/// Repeated, recent commits that are not already curated.
|
||||
public func suggestions(
|
||||
excludingPersonalTerms personalTerms: Set<String>,
|
||||
limit: Int = 5
|
||||
) -> [RimeFrequentTerm] {
|
||||
) -> [FrequentTerm] {
|
||||
guard limit > 0 else { return [] }
|
||||
let excluded = Set(personalTerms.map { $0.lowercased() })
|
||||
|
||||
@@ -111,13 +113,14 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
static func normalizedCandidate(from text: String) -> String? {
|
||||
let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard (2...12).contains(term.count),
|
||||
!commonTerms.contains(term),
|
||||
guard (2...32).contains(term.count),
|
||||
!commonTerms.contains(term.lowercased()),
|
||||
!term.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
}) else {
|
||||
@@ -131,7 +134,8 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
semanticCharacterCount += 1
|
||||
continue
|
||||
}
|
||||
// Product names and proper nouns commonly contain these separators.
|
||||
// Product names, proper nouns, and English contractions commonly
|
||||
// contain these separators.
|
||||
guard allowedSeparators.contains(Character(String(scalar))) else {
|
||||
return nil
|
||||
}
|
||||
@@ -139,21 +143,40 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
return semanticCharacterCount >= 2 ? term : nil
|
||||
}
|
||||
|
||||
private func loadLocked() -> [RimeFrequentTerm] {
|
||||
guard let data = defaults.data(forKey: Self.defaultsKey),
|
||||
let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else {
|
||||
private func loadLocked() -> [FrequentTerm] {
|
||||
if let data = defaults.data(forKey: Self.defaultsKey),
|
||||
let terms = try? JSONDecoder().decode([FrequentTerm].self, from: data) {
|
||||
return terms
|
||||
}
|
||||
|
||||
guard let legacyData = defaults.data(forKey: Self.legacyRimeDefaultsKey),
|
||||
let legacyTerms = try? JSONDecoder().decode([FrequentTerm].self, from: legacyData) else {
|
||||
return []
|
||||
}
|
||||
return terms
|
||||
|
||||
// The legacy value has the same Codable shape, so migration only
|
||||
// changes its key and keeps every user's existing Chinese history.
|
||||
saveLocked(legacyTerms)
|
||||
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
|
||||
return legacyTerms
|
||||
}
|
||||
|
||||
private func saveLocked(_ terms: [RimeFrequentTerm]) {
|
||||
private func saveLocked(_ terms: [FrequentTerm]) {
|
||||
guard let data = try? JSONEncoder().encode(terms) else { return }
|
||||
defaults.set(data, forKey: Self.defaultsKey)
|
||||
}
|
||||
|
||||
/// Avoid recommending ubiquitous conversational glue as a personal term.
|
||||
private static let commonTerms: Set<String> = [
|
||||
"a", "about", "after", "all", "also", "am", "an", "and", "any", "are",
|
||||
"as", "at", "be", "because", "been", "but", "by", "can", "could", "did",
|
||||
"do", "does", "for", "from", "get", "got", "had", "has", "have", "he",
|
||||
"her", "here", "him", "his", "how", "i", "if", "in", "is", "it", "its",
|
||||
"just", "me", "more", "my", "no", "not", "now", "of", "on", "one", "or",
|
||||
"our", "out", "she", "so", "some", "than", "that", "the", "their", "them",
|
||||
"then", "there", "they", "this", "to", "too", "up", "us", "was", "we",
|
||||
"were", "what", "when", "where", "which", "who", "why", "will", "with",
|
||||
"would", "you", "your",
|
||||
"一个", "一下", "不会", "不是", "什么", "他们", "但是", "你们", "你好",
|
||||
"可能", "可以", "因为", "好的", "如果", "已经", "应该", "怎么", "我们",
|
||||
"所以", "时候", "明天", "昨天", "有点", "没有", "然后", "现在", "知道",
|
||||
@@ -161,5 +184,5 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
|
||||
"那里", "那样", "需要", "今天", "就是"
|
||||
]
|
||||
|
||||
private static let allowedSeparators: Set<Character> = ["-", ".", "+", "#", "·"]
|
||||
private static let allowedSeparators: Set<Character> = ["-", "'", ".", "+", "#", "·"]
|
||||
}
|
||||
@@ -7,14 +7,14 @@
|
||||
import Foundation
|
||||
|
||||
public enum TypingHabitStore {
|
||||
/// Clears English boosts and Chinese Rime user dictionaries.
|
||||
/// Clears English boosts, cross-language frequent terms, and Rime user dictionaries.
|
||||
/// Does not touch PersonalDictionary / osg_personal.
|
||||
public static func clearAll(
|
||||
englishStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
frequentTermStore: FrequentTermStore = FrequentTermStore()
|
||||
) async throws {
|
||||
englishStore.clear()
|
||||
rimeFrequentTermStore.clear()
|
||||
frequentTermStore.clear()
|
||||
try await RimeResourceInstaller.shared.clearUserDictionary()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var capsLock: Bool = false
|
||||
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends).
|
||||
@Published public private(set) var shiftHeld: Bool = false
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var candidateRevision: UInt64 = 0
|
||||
@Published public private(set) var composition: TypingComposition = .empty {
|
||||
didSet {
|
||||
if oldValue != composition {
|
||||
candidateRevision &+= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var isPreparingEngine: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
/// Chinese-only: key grid replaced by a same-height candidate grid.
|
||||
@Published public private(set) var isCandidatePanelExpanded: Bool = false
|
||||
@@ -29,8 +37,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// Chinese composition is also skipped so passwords never enter Rime userdb.
|
||||
@Published public var suggestionsEnabled: Bool = true {
|
||||
didSet {
|
||||
guard oldValue, !suggestionsEnabled else { return }
|
||||
abandonChineseComposition()
|
||||
guard oldValue != suggestionsEnabled else { return }
|
||||
if !suggestionsEnabled {
|
||||
clearDocumentScopedState()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `UITextChecker` completions / guesses. Empty in unit tests.
|
||||
@@ -54,12 +64,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engineFactory: @MainActor () -> RimeEngineBridging
|
||||
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
|
||||
private let hostHeavyProvider: @MainActor () -> Bool
|
||||
private let learningStore: EnglishLearningStore
|
||||
private let rimeFrequentTermStore: RimeFrequentTermStore
|
||||
private let frequentTermStore: FrequentTermStore
|
||||
private var engineStorage: RimeEngineBridging?
|
||||
private var englishStorage: EnglishSuggestionEngine?
|
||||
private var prepared = false
|
||||
private var prepareTask: Task<Void, Never>?
|
||||
private var engineEpoch: UInt64 = 0
|
||||
private var documentPresentationCounter: UInt64 = 0
|
||||
private var activeDocumentPresentationID: UInt64?
|
||||
|
||||
private var engine: RimeEngineBridging {
|
||||
if let engineStorage { return engineStorage }
|
||||
@@ -99,13 +113,17 @@ public final class TypingSessionController: ObservableObject {
|
||||
layout: TypingLayoutProviding = StandardTypingLayout(),
|
||||
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
|
||||
learningStore: EnglishLearningStore = EnglishLearningStore(),
|
||||
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
|
||||
frequentTermStore: FrequentTermStore = FrequentTermStore(),
|
||||
hostHeavyProvider: @escaping @MainActor () -> Bool = {
|
||||
FlowSessionBridge.isHostHeavy()
|
||||
}
|
||||
) {
|
||||
self.engineFactory = engine ?? { LibrimeEngine() }
|
||||
self.layout = layout
|
||||
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
|
||||
self.hostHeavyProvider = hostHeavyProvider
|
||||
self.learningStore = learningStore
|
||||
self.rimeFrequentTermStore = rimeFrequentTermStore
|
||||
self.frequentTermStore = frequentTermStore
|
||||
// Avoid constructing librime until the first Chinese keystroke / prepare.
|
||||
schema = TypingInputConfiguration.shared.schema
|
||||
}
|
||||
@@ -134,7 +152,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
let hostHeavy = FlowSessionBridge.isHostHeavy()
|
||||
let hostHeavy = hostHeavyProvider()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.enter.begin",
|
||||
details: "language=\(language.rawValue) schema=\(schema.rawValue) "
|
||||
@@ -165,6 +183,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
syncAutocapitalization()
|
||||
isPreparingEngine = !prepared
|
||||
if hostHeavy {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
@@ -172,24 +191,28 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
prepareTask = Task { [weak self] in
|
||||
for _ in 0..<40 {
|
||||
while self?.hostHeavyProvider() == true {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if !FlowSessionBridge.isHostHeavy() {
|
||||
await self.prepareIfNeeded()
|
||||
self.prepareTask = nil
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
guard let self, self.engineEpoch == epoch, !Task.isCancelled else { return }
|
||||
await self.prepareIfNeeded(engineEpoch: epoch)
|
||||
if self.engineEpoch == epoch {
|
||||
self.prepareTask = nil
|
||||
}
|
||||
self?.prepareTask = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
if prepareTask == nil, !prepared {
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
prepareTask = Task { [weak self] in
|
||||
await self?.prepareIfNeeded()
|
||||
self?.prepareTask = nil
|
||||
await self?.prepareIfNeeded(engineEpoch: epoch)
|
||||
guard let self, self.engineEpoch == epoch else { return }
|
||||
self.prepareTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,17 +223,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
|
||||
engineEpoch &+= 1
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
endDocumentPresentation()
|
||||
engineStorage?.teardown()
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
isPreparingEngine = false
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
@@ -220,6 +240,32 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts a new host-document scope without tearing down dictionaries or
|
||||
/// the Rime runtime. A keyboard presentation can move between apps while
|
||||
/// the extension process and controller remain alive.
|
||||
@discardableResult
|
||||
public func beginDocumentPresentation() -> UInt64 {
|
||||
documentPresentationCounter &+= 1
|
||||
let presentationID = documentPresentationCounter
|
||||
activeDocumentPresentationID = presentationID
|
||||
candidateRevision &+= 1
|
||||
clearDocumentScopedState()
|
||||
return presentationID
|
||||
}
|
||||
|
||||
/// Invalidates all candidates and input shadows owned by the outgoing
|
||||
/// document. Heavy engines stay warm until typing mode itself is left.
|
||||
public func endDocumentPresentation() {
|
||||
documentPresentationCounter &+= 1
|
||||
activeDocumentPresentationID = nil
|
||||
candidateRevision &+= 1
|
||||
clearDocumentScopedState()
|
||||
}
|
||||
|
||||
public func isCurrentDocumentPresentation(_ presentationID: UInt64) -> Bool {
|
||||
activeDocumentPresentationID == presentationID
|
||||
}
|
||||
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
guard canExpandCandidatePanel else {
|
||||
isCandidatePanelExpanded = false
|
||||
@@ -248,7 +294,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
if !raw.isEmpty { output = .insert(raw) }
|
||||
}
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
engineStorage?.setLanguage(newLanguage)
|
||||
page = .letters
|
||||
isCandidatePanelExpanded = false
|
||||
clearPeriodShortcut()
|
||||
@@ -258,7 +304,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
englishEngine.prepare()
|
||||
refreshEnglishSuggestions()
|
||||
syncAutocapitalization()
|
||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
if activeDocumentPresentationID != nil {
|
||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||
}
|
||||
} else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
EnglishLexicon.shared.unload()
|
||||
@@ -267,6 +315,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
return output
|
||||
}
|
||||
|
||||
/// Reloads host-curated terms after App Group configuration changes.
|
||||
public func reloadPersonalDictionaryTerms() {
|
||||
refreshPersonalTerms()
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes raw preedit, selects the next built-in scheme, and returns the
|
||||
/// text that the caller should insert before switching.
|
||||
public func cycleSchema() -> TypingOutput {
|
||||
@@ -371,7 +427,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
clearOneShotShiftIfNeeded()
|
||||
recordRimeCommit(committed)
|
||||
recordChineseCommit(committed)
|
||||
return committed.isEmpty ? .none : .insert(committed)
|
||||
}
|
||||
|
||||
@@ -387,7 +443,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -403,7 +459,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return .insert(text)
|
||||
}
|
||||
|
||||
@@ -423,13 +479,28 @@ public final class TypingSessionController: ObservableObject {
|
||||
// Selecting always collapses; follow-up composition may reopen ▼.
|
||||
isCandidatePanelExpanded = false
|
||||
syncCandidatePanelVisibility()
|
||||
recordRimeCommit(text)
|
||||
recordChineseCommit(text)
|
||||
return text.isEmpty ? .none : .insert(text)
|
||||
}
|
||||
|
||||
private func recordRimeCommit(_ text: String) {
|
||||
/// Candidate taps originate from a rendered snapshot. Reject the tap when
|
||||
/// that snapshot has already been replaced by a newer composition.
|
||||
public func selectCandidate(
|
||||
id candidateID: String,
|
||||
candidateRevision expectedRevision: UInt64? = nil
|
||||
) -> TypingOutput {
|
||||
if let expectedRevision, expectedRevision != candidateRevision {
|
||||
return .none
|
||||
}
|
||||
guard let index = composition.candidates.firstIndex(where: { $0.id == candidateID }) else {
|
||||
return .none
|
||||
}
|
||||
return selectCandidate(at: index)
|
||||
}
|
||||
|
||||
private func recordChineseCommit(_ text: String) {
|
||||
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
|
||||
rimeFrequentTermStore.recordCommittedText(text)
|
||||
frequentTermStore.recordCommittedText(text)
|
||||
}
|
||||
|
||||
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
|
||||
@@ -575,6 +646,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
UIKitEnglishSystemLexicon.learnWord(word)
|
||||
#endif
|
||||
}
|
||||
recordEnglishCommit(word)
|
||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||
return suffix.isEmpty ? .none : .insert(suffix)
|
||||
}
|
||||
@@ -597,6 +669,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
#if canImport(UIKit)
|
||||
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||
#endif
|
||||
recordEnglishCommit(pending.original)
|
||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||
}
|
||||
@@ -609,6 +682,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
} else {
|
||||
learningStore.recordAcceptance(of: chosen)
|
||||
}
|
||||
recordEnglishCommit(chosen)
|
||||
|
||||
if !englishCurrentWord.isEmpty {
|
||||
let deleteCount = englishCurrentWord.count
|
||||
@@ -627,6 +701,19 @@ public final class TypingSessionController: ObservableObject {
|
||||
return .insert(chosen + " ")
|
||||
}
|
||||
|
||||
private func recordEnglishCommit(_ text: String) {
|
||||
guard language == .english,
|
||||
suggestionsEnabled,
|
||||
englishEngine.isPersonalTermCandidate(
|
||||
text,
|
||||
personalTerms: personalTermsCache,
|
||||
systemWords: supplementaryWords
|
||||
) else {
|
||||
return
|
||||
}
|
||||
frequentTermStore.recordCommittedText(text)
|
||||
}
|
||||
|
||||
private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
|
||||
guard language == .english, !englishCurrentWord.isEmpty else { return .none }
|
||||
return commitEnglishWord(suffix: "")
|
||||
@@ -696,8 +783,15 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// Rebuilds English suggestion state from the real caret context.
|
||||
/// At the document end, callbacks keep the local shadow when a host
|
||||
/// briefly reports the immediately preceding edit (common in Notes).
|
||||
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
||||
public func synchronizeEnglishDocumentContext(
|
||||
caretMoved: Bool = false,
|
||||
presentationID: UInt64? = nil
|
||||
) {
|
||||
guard language == .english else { return }
|
||||
if let presentationID,
|
||||
activeDocumentPresentationID != presentationID {
|
||||
return
|
||||
}
|
||||
if caretMoved {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
@@ -843,6 +937,16 @@ public final class TypingSessionController: ObservableObject {
|
||||
pendingAutocorrection = nil
|
||||
}
|
||||
|
||||
private func clearDocumentScopedState() {
|
||||
engineStorage?.clearComposition()
|
||||
composition = .empty
|
||||
isCandidatePanelExpanded = false
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
|
||||
private func synchronizeEnglishWordState(
|
||||
precedingText: String,
|
||||
followingText: String?
|
||||
@@ -942,15 +1046,19 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
private func prepareIfNeeded(engineEpoch expectedEpoch: UInt64) async {
|
||||
guard !prepared else { return }
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
if hostHeavyProvider() {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
details: "hostHeavy=1"
|
||||
)
|
||||
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
|
||||
return
|
||||
while hostHeavyProvider() {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
}
|
||||
}
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.begin",
|
||||
@@ -963,9 +1071,11 @@ public final class TypingSessionController: ObservableObject {
|
||||
)
|
||||
do {
|
||||
try await engine.prepare()
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
engine.setLanguage(language)
|
||||
prepared = true
|
||||
engineReady = engine.isReady
|
||||
isPreparingEngine = false
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
lastErrorNeedsHostDeployment = false
|
||||
@@ -981,10 +1091,12 @@ public final class TypingSessionController: ObservableObject {
|
||||
category: "boot"
|
||||
)
|
||||
} catch {
|
||||
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
|
||||
lastError = error.localizedDescription
|
||||
lastErrorNeedsHostDeployment =
|
||||
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
|
||||
engineReady = false
|
||||
isPreparingEngine = false
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.failed",
|
||||
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))"
|
||||
@@ -1005,9 +1117,13 @@ public final class TypingSessionController: ObservableObject {
|
||||
guard prepareTask == nil else { return }
|
||||
guard RimeResourceInstaller.isReady else { return }
|
||||
OSGDiag.log("rime.prepare retry after deployment", category: "boot")
|
||||
engineEpoch &+= 1
|
||||
let epoch = engineEpoch
|
||||
isPreparingEngine = true
|
||||
prepareTask = Task { [weak self] in
|
||||
await self?.prepareIfNeeded()
|
||||
self?.prepareTask = nil
|
||||
await self?.prepareIfNeeded(engineEpoch: epoch)
|
||||
guard let self, self.engineEpoch == epoch else { return }
|
||||
self.prepareTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
/* v0.3.0: Personal dictionary sources */
|
||||
"dict.source.manual" = "Manual";
|
||||
"dict.source.history" = "Auto-learned";
|
||||
"dict.source.history" = "Recommended hotword";
|
||||
"dict.source.contacts" = "From Contacts";
|
||||
"dict.source.recentEdit" = "From recent edit";
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
"mac.styles.learn.generating" = "Generating…";
|
||||
"mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"mac.styles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"mac.styles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
|
||||
"mac.styles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
|
||||
"mac.styles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
"mac.styles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
|
||||
@@ -234,6 +234,7 @@
|
||||
"stat.weekChart.title" = "Last 7 days";
|
||||
"stat.weekChart.caption" = "Dictation chars";
|
||||
"stat.weekChart.empty" = "No dictation yet this week";
|
||||
"stat.monthChart.caption" = "Monthly dictation chars";
|
||||
"mac.status.chipReady" = "Ready";
|
||||
"mac.status.chipProcessing" = "Processing";
|
||||
"mac.overlay.listening" = "Listening";
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
/* v0.3.0: 词库来源 */
|
||||
"dict.source.manual" = "手动添加";
|
||||
"dict.source.history" = "自动学习";
|
||||
"dict.source.history" = "推荐热词";
|
||||
"dict.source.contacts" = "来自通讯录";
|
||||
"dict.source.recentEdit" = "来自最近编辑";
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
"mac.styles.learn.generating" = "生成中…";
|
||||
"mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
|
||||
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"mac.styles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
@@ -233,6 +233,7 @@
|
||||
"stat.weekChart.title" = "近 7 天";
|
||||
"stat.weekChart.caption" = "听写字数";
|
||||
"stat.weekChart.empty" = "本周还没有听写记录";
|
||||
"stat.monthChart.caption" = "本月听写字数";
|
||||
"mac.status.chipReady" = "就绪";
|
||||
"mac.status.chipProcessing" = "处理中";
|
||||
"mac.overlay.listening" = "聆听中";
|
||||
|
||||
@@ -21,6 +21,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testSystemSemanticSkillsStayEnabledButHiddenFromSkillManagement() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
let hiddenIDs = AIClipboardSkillCatalog.hiddenFromSkillManagementIDs
|
||||
|
||||
XCTAssertTrue(hiddenIDs.isSubset(of: Set(store.enabledSkills.map(\.id))))
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementEnabledSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementAvailableSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.skillManagementEnabledSkills.contains {
|
||||
$0.id == AIClipboardSkillCatalog.translateID
|
||||
}
|
||||
)
|
||||
|
||||
store.disable(AIClipboardSkillCatalog.replyID)
|
||||
|
||||
XCTAssertFalse(
|
||||
store.skillManagementAvailableSkills.contains {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.mergedCatalog.contains {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyEnabledListIsPreserved() {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
@@ -30,7 +61,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
|
||||
}
|
||||
|
||||
func testLegacyLayoutAppendsNewDefaultSkillsWithoutRestoringDisabledLegacySkill() throws {
|
||||
func testLegacyLayoutInstallsCurrentRequiredDefaultSkills() throws {
|
||||
let defaults = makeDefaults()
|
||||
let legacy = AIAgentSkillLayout(
|
||||
enabledIDs: [
|
||||
@@ -50,7 +81,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
Array(migrated.enabledIDs.prefix(2)),
|
||||
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
|
||||
)
|
||||
XCTAssertFalse(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
|
||||
}
|
||||
@@ -77,10 +108,16 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.playfulReplyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -106,10 +143,16 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -135,8 +178,14 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -167,17 +216,63 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
6
|
||||
7
|
||||
)
|
||||
}
|
||||
|
||||
func testVersionSixLayoutInstallsRequiredDefaultsOnlyOnce() throws {
|
||||
let defaults = makeDefaults()
|
||||
let initial = AIAgentSkillLayout(
|
||||
enabledIDs: [AIClipboardSkillCatalog.playfulReplyID],
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
defaults.set(
|
||||
try JSONEncoder().encode(initial),
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillLayout
|
||||
)
|
||||
defaults.set(
|
||||
6,
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
)
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertEqual(
|
||||
store.agentSkillLayout.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.playfulReplyID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
|
||||
store.setAgentSkillLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: store.agentSkillLayout.enabledIDs.filter {
|
||||
$0 != AIClipboardSkillCatalog.replyID
|
||||
},
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(store.agentSkillLayout.isEnabled(AIClipboardSkillCatalog.replyID))
|
||||
}
|
||||
|
||||
func testCannotEnableExportSkillBeforeShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
|
||||
@@ -34,6 +34,33 @@ final class AIHistoryAndUsageTests: XCTestCase {
|
||||
XCTAssertEqual(store.totalInputCharacterCount, 4)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCurrentMonthBuildsCompleteLeapMonthAndZeroFillsMissingDays() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0))
|
||||
let now = try XCTUnwrap(
|
||||
calendar.date(from: DateComponents(year: 2024, month: 2, day: 14))
|
||||
)
|
||||
|
||||
let points = UsageStatisticsStore.currentMonth(
|
||||
from: [
|
||||
"2024-02-01": 120,
|
||||
"2024-02-29": 480
|
||||
],
|
||||
now: now,
|
||||
calendar: calendar
|
||||
)
|
||||
|
||||
XCTAssertEqual(points.count, 29)
|
||||
XCTAssertEqual(points.first?.value, 120)
|
||||
XCTAssertEqual(points[13].value, 0)
|
||||
XCTAssertEqual(points.last?.value, 480)
|
||||
XCTAssertEqual(
|
||||
points.map { calendar.component(.day, from: $0.date) },
|
||||
Array(1...29)
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyHistoryEntryDefaultsToDictationSource() throws {
|
||||
let payload: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
|
||||
@@ -194,6 +194,51 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh")
|
||||
}
|
||||
|
||||
func testRecreatedClientRestoresRetainedSessionAndRefreshesIt() async throws {
|
||||
let retained = makeAccountSession(accessExpiry: 1_020)
|
||||
let replacement = makeAccountSession(
|
||||
accessToken: "access-after-reinstall",
|
||||
refreshToken: "refresh-after-reinstall"
|
||||
)
|
||||
let transport = QueueAccountTransport([
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(retained)),
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(replacement))
|
||||
])
|
||||
let store = InMemoryAccountSecurityStore()
|
||||
let originalClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
_ = try await originalClient.signInWithApple(
|
||||
AppleSignInRequest(
|
||||
identityToken: "identity",
|
||||
authorizationCode: "authorization",
|
||||
nonce: "raw-nonce",
|
||||
deviceCheckToken: "device-token",
|
||||
appAttest: nil
|
||||
)
|
||||
)
|
||||
|
||||
let recreatedClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
let accessToken = try await recreatedClient.accessTokenForAuthorizedRequest()
|
||||
|
||||
XCTAssertEqual(accessToken, replacement.accessToken)
|
||||
let stored = await store.session
|
||||
XCTAssertEqual(stored, replacement)
|
||||
let requests = await transport.requests
|
||||
XCTAssertEqual(requests.map(\.url?.path), [
|
||||
"/v1/auth/apple",
|
||||
"/v1/auth/refresh"
|
||||
])
|
||||
}
|
||||
|
||||
func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws {
|
||||
let old = makeAccountSession()
|
||||
let replacement = makeAccountSession(
|
||||
|
||||
@@ -60,6 +60,7 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
|
||||
let handled = coordinator.handleIncomingURL(
|
||||
URL(string: "https://osglab.com/i/\(validCode)")!
|
||||
)
|
||||
@@ -123,6 +124,83 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(restoreCount, 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTransientRestoreFailureCanRetryWithoutSigningOut() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
restoreFailureCount: 1
|
||||
)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: InMemoryPendingReferralStore()
|
||||
)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
XCTAssertEqual(coordinator.sessionPhase, .restoring)
|
||||
XCTAssertEqual(coordinator.operationErrorKey, "account.error.restore")
|
||||
let firstClearCount = await service.managedGatewayClearCount()
|
||||
XCTAssertEqual(firstClearCount, 0)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
|
||||
XCTAssertNil(coordinator.operationErrorKey)
|
||||
let restoreCount = await service.restoreCount()
|
||||
XCTAssertEqual(restoreCount, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSignInOperationRemainsVisibleUntilAuthenticationCompletes() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: nil,
|
||||
signInSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
signInDelayNanoseconds: 20_000_000
|
||||
)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: InMemoryPendingReferralStore()
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
let signIn = Task {
|
||||
await coordinator.signIn(
|
||||
with: AppleAuthorizationPayload(
|
||||
identityToken: "identity",
|
||||
authorizationCode: "authorization",
|
||||
nonce: "nonce"
|
||||
)
|
||||
)
|
||||
}
|
||||
while coordinator.operation != .signingIn {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
XCTAssertEqual(coordinator.operation, .signingIn)
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
|
||||
|
||||
await signIn.value
|
||||
|
||||
XCTAssertNil(coordinator.operation)
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() async {
|
||||
let account = AccountSession(
|
||||
@@ -529,12 +607,15 @@ private final class AccountSessionEventSourceStub: AccountSessionEventSourcing {
|
||||
|
||||
private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing {
|
||||
private let restored: AccountSession?
|
||||
private let signedInAccount: AccountSession?
|
||||
private let centerSnapshot: AccountCenterSnapshot?
|
||||
private let refreshedSnapshot: AccountCenterSnapshot?
|
||||
private let shouldFailRedemption: Bool
|
||||
private let shouldFailAccountRefresh: Bool
|
||||
private let signOutDelayNanoseconds: UInt64
|
||||
private let signInDelayNanoseconds: UInt64
|
||||
private let accountLoadDelayNanoseconds: UInt64
|
||||
private var remainingRestoreFailures: Int
|
||||
private var redeemed: [String] = []
|
||||
private var centerLoadCount = 0
|
||||
private var logoutCount = 0
|
||||
@@ -545,30 +626,43 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
|
||||
init(
|
||||
restoredSession: AccountSession?,
|
||||
signInSession: AccountSession? = nil,
|
||||
snapshot: AccountCenterSnapshot? = nil,
|
||||
refreshedSnapshot: AccountCenterSnapshot? = nil,
|
||||
shouldFailRedemption: Bool = false,
|
||||
shouldFailAccountRefresh: Bool = false,
|
||||
signOutDelayNanoseconds: UInt64 = 0,
|
||||
signInDelayNanoseconds: UInt64 = 0,
|
||||
restoreFailureCount: Int = 0,
|
||||
accountLoadDelayNanoseconds: UInt64 = 0
|
||||
) {
|
||||
restored = restoredSession
|
||||
signedInAccount = signInSession ?? restoredSession
|
||||
centerSnapshot = snapshot
|
||||
self.refreshedSnapshot = refreshedSnapshot
|
||||
self.shouldFailRedemption = shouldFailRedemption
|
||||
self.shouldFailAccountRefresh = shouldFailAccountRefresh
|
||||
self.signOutDelayNanoseconds = signOutDelayNanoseconds
|
||||
self.signInDelayNanoseconds = signInDelayNanoseconds
|
||||
remainingRestoreFailures = restoreFailureCount
|
||||
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
sessionRestoreCount += 1
|
||||
if remainingRestoreFailures > 0 {
|
||||
remainingRestoreFailures -= 1
|
||||
throw AccountServiceSpyError.failed
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
guard let restored else { throw AccountIntegrationError.unavailable }
|
||||
return restored
|
||||
if signInDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: signInDelayNanoseconds)
|
||||
}
|
||||
guard let signedInAccount else { throw AccountIntegrationError.unavailable }
|
||||
return signedInAccount
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
|
||||
@@ -30,7 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId)
|
||||
XCTAssertFalse(config.translationEnabled)
|
||||
XCTAssertEqual(config.handednessPreference, .left)
|
||||
XCTAssertTrue(config.cursorDragNavigationEnabled)
|
||||
// Legacy field remains decode-compatible after the UI feature was removed.
|
||||
XCTAssertFalse(config.cursorDragNavigationEnabled)
|
||||
XCTAssertEqual(config.keyboardHapticIntensity, .light)
|
||||
XCTAssertEqual(config.polishIntensity, .light)
|
||||
XCTAssertEqual(config.aiResponseLength, .medium)
|
||||
@@ -93,6 +94,24 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
|
||||
}
|
||||
|
||||
func testAppGroupStorePersistsLocaleChanges() {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
store.setLocaleId("en-US")
|
||||
|
||||
XCTAssertEqual(store.localeId, "en-US")
|
||||
}
|
||||
|
||||
func testRemovedCursorDragSettingStillDecodesLegacyValue() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(true, forKey: AppGroupConfiguration.Keys.cursorDragNavigationEnabled)
|
||||
|
||||
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertTrue(loaded.cursorDragNavigationEnabled)
|
||||
}
|
||||
|
||||
func testFieldLevelSavePreservesNewerUnrelatedProcessChange() {
|
||||
let defaults = makeDefaults()
|
||||
let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
@@ -45,7 +45,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.translateID])
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() {
|
||||
@@ -91,7 +97,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -111,7 +118,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -130,6 +138,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.openLinkID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeWebPageID))
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testSinglePhoneNumberOffersCallAndCreateContact() {
|
||||
@@ -149,7 +158,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -169,7 +179,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -190,6 +201,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.callPhoneID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.createContactID))
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testTaskListPromotesTodoAndOrganizationSkills() {
|
||||
@@ -259,7 +271,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(ranked, baseline)
|
||||
}
|
||||
|
||||
func testRecommendationsSelectOnlySemanticallyRelevantSkills() {
|
||||
func testRecommendationsAddReplyToSemanticallyRelevantSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "北京市朝阳区望京街 10 号,到了给我电话。",
|
||||
@@ -268,12 +280,18 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
limit: 5
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.navigateID])
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.navigateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
}
|
||||
|
||||
func testRecommendationsStayEmptyWhenNoSemanticLabelMatches() {
|
||||
func testRecommendationsFallBackToReplyWhenNoSemanticLabelMatches() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "知道了",
|
||||
@@ -282,7 +300,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
limit: 5
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.isEmpty)
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testNegativeReplyableMessageDoesNotOfferPlayfulReply() {
|
||||
@@ -300,7 +318,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testInvitationKeepsTwoSpecificRepliesWithoutGenericReplies() {
|
||||
func testInvitationKeepsSpecificRepliesAndGenericReply() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "今晚七点老地方吃饭,你能来吗?",
|
||||
@@ -319,12 +337,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.acceptInvitationID,
|
||||
AIClipboardSkillCatalog.declineInvitationID
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testRecommendationsNeverContainMoreThanTwoReplySkills() {
|
||||
func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "Could you send the final proposal by Friday?",
|
||||
|
||||
@@ -116,6 +116,47 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testLocalAndPolishOnlySetupLinksToTextPolish() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: true,
|
||||
isASRConfigured: true
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
"home.setup.polishKeyMissing"
|
||||
)
|
||||
}
|
||||
|
||||
func testCloudSetupLinksToSpeechRecognitionWhenASRIsMissing() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
.speechRecognition
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
"home.setup.cloudIncomplete"
|
||||
)
|
||||
}
|
||||
|
||||
private func descriptor(
|
||||
for lifecycle: FlowPiPLifecycleState
|
||||
) -> FlowHomePiPStatusDescriptor {
|
||||
|
||||
+53
-9
@@ -1,15 +1,15 @@
|
||||
// RimeFrequentTermStoreTests.swift
|
||||
// FrequentTermStoreTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
final class FrequentTermStoreTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var suiteName: String!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
suiteName = "RimeFrequentTermStoreTests.\(UUID().uuidString)"
|
||||
suiteName = "FrequentTermStoreTests.\(UUID().uuidString)"
|
||||
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
suiteName = nil
|
||||
}
|
||||
|
||||
func testRepeatedRimeCommitBecomesSuggestion() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
func testRepeatedCommitBecomesSuggestion() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
store.recordCommittedText("少数派")
|
||||
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
|
||||
@@ -32,7 +32,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSuggestionsExcludeExistingDictionaryTermsAndCommonWords() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for _ in 0..<4 {
|
||||
store.recordCommittedText("我们")
|
||||
store.recordCommittedText("飞书文档")
|
||||
@@ -47,7 +47,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSuggestionsRankFrequencyBeforeRecency() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
let earlier = Date(timeIntervalSince1970: 100)
|
||||
let later = Date(timeIntervalSince1970: 200)
|
||||
for _ in 0..<3 {
|
||||
@@ -64,7 +64,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPunctuationAndSingleCharactersAreIgnored() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for _ in 0..<3 {
|
||||
store.recordCommittedText("我")
|
||||
store.recordCommittedText("你好!")
|
||||
@@ -75,7 +75,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testClearRemovesLearnedSuggestions() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
store.recordCommittedText("少数派")
|
||||
store.recordCommittedText("少数派")
|
||||
XCTAssertFalse(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
@@ -84,4 +84,48 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
}
|
||||
|
||||
func testEnglishTermsAreCaseInsensitiveAndCommonWordsAreIgnored() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for term in ["OpenAI", "openAI", "the", "the"] {
|
||||
store.recordCommittedText(term)
|
||||
}
|
||||
|
||||
let suggestion = store.suggestions(excludingPersonalTerms: []).first
|
||||
|
||||
XCTAssertEqual(suggestion?.term, "openAI")
|
||||
XCTAssertEqual(suggestion?.commitCount, 2)
|
||||
}
|
||||
|
||||
func testEnglishNamesAndProductSeparatorsAreAccepted() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for term in ["O'Connor", "O'Connor", "GPT-5", "GPT-5"] {
|
||||
store.recordCommittedText(term)
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
Set(store.suggestions(excludingPersonalTerms: []).map(\.term)),
|
||||
["O'Connor", "GPT-5"]
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyRimeTermsMigrateWithoutLosingHistory() throws {
|
||||
let legacyTerm = FrequentTerm(
|
||||
term: "少数派",
|
||||
commitCount: 3,
|
||||
firstSeenAt: Date(timeIntervalSince1970: 100),
|
||||
lastSeenAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
defaults.set(
|
||||
try JSONEncoder().encode([legacyTerm]),
|
||||
forKey: FrequentTermStore.legacyRimeDefaultsKey
|
||||
)
|
||||
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
let suggestions = store.suggestions(excludingPersonalTerms: [])
|
||||
|
||||
XCTAssertEqual(suggestions.first, legacyTerm)
|
||||
XCTAssertNotNil(defaults.data(forKey: FrequentTermStore.defaultsKey))
|
||||
XCTAssertNil(defaults.data(forKey: FrequentTermStore.legacyRimeDefaultsKey))
|
||||
}
|
||||
}
|
||||
@@ -147,15 +147,15 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym)
|
||||
}
|
||||
|
||||
func testPersonalDictionaryMigratesLegacyHistorySource() {
|
||||
let legacy = PersonalDictionary(entries: [
|
||||
func testPersonalDictionaryPreservesRecommendedHistorySource() {
|
||||
let dictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history)
|
||||
])
|
||||
let data = try! JSONEncoder().encode(legacy)
|
||||
let data = try! JSONEncoder().encode(dictionary)
|
||||
defaults.set(data, forKey: "config.personalDictionary.v1")
|
||||
|
||||
let loaded = store.personalDictionary
|
||||
XCTAssertEqual(loaded.entries.first?.source, .manual)
|
||||
XCTAssertEqual(loaded.entries.first?.source, .history)
|
||||
XCTAssertEqual(loaded.entries.first?.term, "Kubernetes")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// PersonalDictionaryEntryServiceTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Verifies that every manual-entry UI gets the same alias-generation behavior
|
||||
// without allowing a late asynchronous response to overwrite newer edits.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class PersonalDictionaryEntryServiceTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.entry-service.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSuggestedTermGeneratesAliasesAndPreservesUsageCount() async throws {
|
||||
var pushedDictionaries: [PersonalDictionary] = []
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["洛基", "肉鸡"] },
|
||||
cloudPush: { pushedDictionaries.append($0) }
|
||||
)
|
||||
|
||||
let saved = try XCTUnwrap(
|
||||
service.saveEntry(term: "Rocky", source: .history, minimumUsageCount: 3)
|
||||
)
|
||||
XCTAssertTrue(saved.shouldGenerateAliases)
|
||||
XCTAssertEqual(saved.dictionary.entries.first?.source, .history)
|
||||
XCTAssertEqual(saved.dictionary.entries.first?.usageCount, 3)
|
||||
XCTAssertTrue(saved.dictionary.entries.first?.aliases.isEmpty == true)
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(Set(finished.entries.first?.aliases ?? []), Set(["洛基", "肉鸡"]))
|
||||
XCTAssertEqual(finished.entries.first?.usageCount, 3)
|
||||
XCTAssertEqual(pushedDictionaries.count, 2)
|
||||
}
|
||||
|
||||
func testAliasCompletionPreservesChangesMadeWhileGenerating() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["swift u i"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "SwiftUI", source: .manual))
|
||||
store.updatePersonalDictionary { dictionary -> UUID? in
|
||||
guard let concurrent = dictionary.upsertManual(term: "Cursor") else { return nil }
|
||||
dictionary.version += 1
|
||||
return concurrent.id
|
||||
}
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(Set(finished.entries.map(\.term)), Set(["SwiftUI", "Cursor"]))
|
||||
XCTAssertEqual(
|
||||
finished.entry(matchingTerm: "SwiftUI")?.aliases,
|
||||
["swift u i"]
|
||||
)
|
||||
}
|
||||
|
||||
func testAliasCompletionDoesNotRestoreDeletedEntry() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["late alias"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Deleted", source: .manual))
|
||||
store.deletePersonalDictionaryEntry(id: saved.entryID)
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertNil(finished.entries.first(where: { $0.id == saved.entryID }))
|
||||
XCTAssertNotNil(finished.deletedEntryIDs[saved.entryID])
|
||||
}
|
||||
|
||||
func testAliasCompletionIgnoresEntryRenamedAfterSave() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["stale alias"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Before", source: .manual))
|
||||
store.updatePersonalDictionary { dictionary -> UUID? in
|
||||
guard let renamed = dictionary.upsertManual(
|
||||
term: "After",
|
||||
existingID: saved.entryID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
dictionary.version += 1
|
||||
return renamed.id
|
||||
}
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(finished.entries.first?.term, "After")
|
||||
XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true)
|
||||
}
|
||||
|
||||
func testAliasGenerationFailureKeepsSavedEntry() async throws {
|
||||
var pushCount = 0
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in [] },
|
||||
cloudPush: { _ in pushCount += 1 }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Durable", source: .manual))
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(finished.entries.first?.term, "Durable")
|
||||
XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true)
|
||||
XCTAssertEqual(pushCount, 1)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// PolishStyleLearningServiceTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// Verifies corpus eligibility, the 5,000-character gate, and that style
|
||||
// Verifies corpus eligibility, the 2,500-character gate, and that style
|
||||
// generation receives both paired examples and the prompts that produced them.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
@@ -54,7 +54,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertEqual(corpus.examples.count, 1)
|
||||
XCTAssertEqual(corpus.examples.first?.polishStyleID, "builtin.light")
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 7)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 4_993)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 2_493)
|
||||
XCTAssertFalse(corpus.isReady)
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 7)
|
||||
}
|
||||
|
||||
func testCorpusUnlocksAtFiveThousandEffectiveCharacters() {
|
||||
let text = String(repeating: "字", count: 5_000)
|
||||
func testCorpusUnlocksAtTwoThousandFiveHundredEffectiveCharacters() {
|
||||
let text = String(repeating: "字", count: 2_500)
|
||||
let corpus = PolishStyleLearningCorpusBuilder.build(
|
||||
from: [
|
||||
SpeechHistoryEntry(
|
||||
@@ -86,7 +86,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 5_000)
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 2_500)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 0)
|
||||
XCTAssertTrue(corpus.isReady)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setActivePolishStyleId(activeStyle.id)
|
||||
|
||||
let source = String(repeating: "测试语料", count: 1_250)
|
||||
let source = String(repeating: "测试语料", count: 625)
|
||||
let corpus = PolishStyleLearningCorpus(
|
||||
examples: [
|
||||
PolishStyleLearningExample(
|
||||
@@ -120,7 +120,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
createdAt: Date()
|
||||
)
|
||||
],
|
||||
effectiveCharacterCount: 5_000
|
||||
effectiveCharacterCount: 2_500
|
||||
)
|
||||
let client = StyleLearningCapturingClient(
|
||||
response: ##"{"name":"我的说话风格","prompt":"# 角色\n自然直接\n# 风格边界\n不改变原意\n# 示例\n输入 → 输出","allowsAddedEmoji":false}"##
|
||||
@@ -156,7 +156,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
createdAt: Date()
|
||||
)
|
||||
],
|
||||
effectiveCharacterCount: 5_000
|
||||
effectiveCharacterCount: 2_500
|
||||
)
|
||||
let service = PolishStyleLearningService(
|
||||
store: store,
|
||||
@@ -169,7 +169,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
} catch let error as PolishStyleLearningError {
|
||||
XCTAssertEqual(
|
||||
error,
|
||||
.insufficientCorpus(required: 5_000, actual: 5)
|
||||
.insufficientCorpus(required: 2_500, actual: 5)
|
||||
)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
|
||||
@@ -35,6 +35,19 @@ final class AccountCenterUITests: XCTestCase {
|
||||
XCTAssertTrue(element("account.purchase.3000tks", in: app).exists)
|
||||
}
|
||||
|
||||
func testAppleSignInShowsLoadingStateUntilAuthenticationCompletes() {
|
||||
let app = launch(arguments: [
|
||||
"--account-ui-test",
|
||||
"--account-signin-loading"
|
||||
])
|
||||
|
||||
XCTAssertTrue(
|
||||
element("account.signIn.loading", in: app)
|
||||
.waitForExistence(timeout: 3)
|
||||
)
|
||||
XCTAssertFalse(element("account.signIn.apple", in: app).exists)
|
||||
}
|
||||
|
||||
func testVerifiedCreditPurchaseShowsSuccessState() {
|
||||
let app = launch()
|
||||
let purchase = element("account.purchase.500tks", in: app)
|
||||
@@ -59,6 +72,12 @@ final class AccountCenterUITests: XCTestCase {
|
||||
element("account.purchaseHistory.list", in: app)
|
||||
.waitForExistence(timeout: 5)
|
||||
)
|
||||
let purchaseRow = element(
|
||||
"account.purchaseHistory.row.2000000000199",
|
||||
in: app
|
||||
)
|
||||
XCTAssertTrue(purchaseRow.waitForExistence(timeout: 5))
|
||||
XCTAssertGreaterThanOrEqual(purchaseRow.frame.height, 64)
|
||||
}
|
||||
|
||||
func testManagedCreditsRequireFirstUseCloudConsent() {
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ OSGKeyboard is a voice input tool for iPhone, iPad, and Mac. Its iOS keyboard co
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[Website](https://hkgood.github.io/OSGKeyboard/?lang=en) · [中文](./README.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) · [Changelog](./CHANGELOG.md)
|
||||
@@ -52,10 +52,10 @@ OSGKeyboard is a voice input tool for iPhone, iPad, and Mac. Its iOS keyboard co
|
||||
### Clipboard and skills
|
||||
|
||||
- Clipboard history is off by default; when enabled, it keeps up to 15 plain-text items in the device-local App Group
|
||||
- Reply, Summarize, and Translate appear briefly after a copy
|
||||
- On-device semantic analysis recommends up to five relevant actions from the complete skill catalog after a copy
|
||||
- Built-in skills extract tasks and events, save to Notes, and open map navigation through Apple Shortcuts or map apps
|
||||
- Create custom skills with a name, SF Symbol, prompt, and optional iCloud Shortcut
|
||||
- Pin up to eight skills to the keyboard and reorder them by long press
|
||||
- Install any number of skills; manage and reorder them in the Skills tab while the keyboard dynamically shows relevant actions for the current clipboard text
|
||||
|
||||
## Three service paths
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的语音输入工具。iOS 键盘
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[官网](https://hkgood.github.io/OSGKeyboard/) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) · [更新记录](./CHANGELOG.md)
|
||||
@@ -52,10 +52,10 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的语音输入工具。iOS 键盘
|
||||
### 剪贴板与技能
|
||||
|
||||
- 剪贴板历史默认关闭,开启后最多保存 15 条纯文本,仅存于本机 App Group
|
||||
- 复制后短时间显示回复、总结、翻译等快捷技能
|
||||
- 复制后由本地语义分析从完整技能目录中推荐最多 5 个相关操作
|
||||
- 内置待办、日程与备忘录可通过 Apple 快捷指令导出;地图导航直接打开高德、百度或 Apple 地图
|
||||
- 可创建自定义技能:名称、SF Symbol、提示词和可选 iCloud 快捷指令
|
||||
- 最多将 8 个技能放到键盘,支持长按拖动排序
|
||||
- 技能安装数量不限;技能页可安装、卸载和排序,键盘按当前剪贴板内容动态展示相关技能
|
||||
|
||||
## 三种服务路径
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,12 @@ TEST_ROOTS = [
|
||||
]
|
||||
SHARED_TEST_ROOT = ROOT / "Tests"
|
||||
SHARED_TEST_TARGETS = ["OSGKeyboardTests", "OSGKeyboardMacTests"]
|
||||
XCTEST_CLASS_PATTERN = re.compile(
|
||||
r"^[ \t]*(?:(?:@\w+(?:\([^)]*\))?)[ \t]+)*"
|
||||
r"(?:final[ \t]+)?class[ \t]+([A-Za-z_]\w*)"
|
||||
r"[ \t]*:[ \t]*XCTestCase\b",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
@@ -77,6 +84,11 @@ def collect_tests(manifest: dict, group_ids: list[str]) -> list[str]:
|
||||
return tests
|
||||
|
||||
|
||||
def discover_xctest_classes(path: Path) -> list[str]:
|
||||
"""Return every XCTestCase class declared in a Swift test source file."""
|
||||
return XCTEST_CLASS_PATTERN.findall(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def discover_on_disk_test_classes() -> dict[str, Path]:
|
||||
"""Map Target/ClassName -> swift path for *Tests.swift files (exclude helpers)."""
|
||||
found: dict[str, Path] = {}
|
||||
@@ -86,14 +98,15 @@ def discover_on_disk_test_classes() -> dict[str, Path]:
|
||||
target = root.name
|
||||
for path in sorted(root.glob("*Tests.swift")):
|
||||
# Skip non-XCTest helpers that happen to end with Tests (none today).
|
||||
class_name = path.stem
|
||||
test_id = f"{target}/{class_name}"
|
||||
found[test_id] = path
|
||||
for class_name in discover_xctest_classes(path):
|
||||
test_id = f"{target}/{class_name}"
|
||||
found[test_id] = path
|
||||
if SHARED_TEST_ROOT.is_dir():
|
||||
for path in sorted(SHARED_TEST_ROOT.glob("*Tests.swift")):
|
||||
for target in SHARED_TEST_TARGETS:
|
||||
test_id = f"{target}/{path.stem}"
|
||||
found[test_id] = path
|
||||
for class_name in discover_xctest_classes(path):
|
||||
for target in SHARED_TEST_TARGETS:
|
||||
test_id = f"{target}/{class_name}"
|
||||
found[test_id] = path
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"tests": [
|
||||
"OSGKeyboardTests/SettingsCloudSyncTests",
|
||||
"OSGKeyboardTests/PersonalDictionaryCloudSyncTests",
|
||||
"OSGKeyboardTests/PersonalDictionaryEntryServiceTests",
|
||||
"OSGKeyboardTests/PersonalDictionaryMergeTests",
|
||||
"OSGKeyboardTests/SpeechHistoryCloudSyncTests",
|
||||
"OSGKeyboardTests/SpeechHistoryDayDeletionTests",
|
||||
@@ -178,12 +179,14 @@
|
||||
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
||||
"OSGKeyboardTests/AIHintPoolTests",
|
||||
"OSGKeyboardTests/AIHintKeywordExtractorTests",
|
||||
"OSGKeyboardTests/AIClipboardSkillTests",
|
||||
"OSGKeyboardTests/WebPageContentServiceTests",
|
||||
"OSGKeyboardTests/AIAgentSkillLayoutTests",
|
||||
"OSGKeyboardTests/AIUserSkillTests",
|
||||
"OSGKeyboardTests/AIUserSkillStoreTests",
|
||||
"OSGKeyboardTests/AIEventExtractionTests",
|
||||
"OSGKeyboardTests/AIAddressExtractionTests",
|
||||
"OSGKeyboardTests/AIMapNavigationTests",
|
||||
"OSGKeyboardTests/AINoteExportTests",
|
||||
"OSGKeyboardTests/ClipboardSemanticAnalyzerTests",
|
||||
"OSGKeyboardTests/ClipboardSkillSemanticRankerTests",
|
||||
@@ -191,7 +194,7 @@
|
||||
"OSGKeyboardTests/ClipboardHistoryStoreTests",
|
||||
"OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests",
|
||||
"OSGKeyboardTests/OfficialSkillCatalogTests",
|
||||
"OSGKeyboardTests/RimeFrequentTermStoreTests"
|
||||
"OSGKeyboardTests/FrequentTermStoreTests"
|
||||
]
|
||||
},
|
||||
"host_misc": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user