checkpoint before checking out main

This commit is contained in:
Rocky
2026-08-25 13:42:00 +08:00
parent 57844ce615
commit dccafe6f9e
105 changed files with 4302 additions and 1323 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"image-name" : "OSGKeyboard Icon2.png",
"name" : "OSGKeyboard Icon2",
"position" : {
"scale" : 0.505,
"scale" : 0.508,
"translation-in-points" : [
8,
6
+68 -62
View File
@@ -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)
)
}
}
-1
View File
@@ -31,6 +31,5 @@ struct HelpFeedbackView: View {
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.link.support")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
+1 -2
View File
@@ -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
View File
@@ -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"))
+6
View File
@@ -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")
+10 -6
View File
@@ -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)
)
}
}
-4
View File
@@ -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()
}
}
+23 -35
View File
@@ -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)
}
}
+9 -23
View File
@@ -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? {
+31 -37
View File
@@ -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)
}
}
+28 -21
View File
@@ -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)
+22 -32
View File
@@ -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)
+32 -40
View File
@@ -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()
}
+91 -276
View File
@@ -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 {
+14 -9
View File
@@ -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))
+14 -10
View File
@@ -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…";
+14 -10
View File
@@ -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" = "正在登录…";